feat(mobile): add data layer, OTLP telemetry, and CI build fixes

Implement the missing Flutter data layer so release builds compile: Dio ApiClient for /api/v1 (timer, time entries, projects, tasks, finance, time-off, users/me), JSON models, Hive LocalStorage, and offline SyncService queue.

Add OpenTelemetry (opentelemetry package) with initMobileOpenTelemetry() reading OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_TOKEN via --dart-define, matching server OTLP base URL and Basic auth behavior. Instrument login token validation, timer start/stop, and sync pending.

Fix SyncUseCase to import storage SyncService, use trusted insecure hosts, and call syncAll().

GitHub Actions (build-mobile.yml, cd-release.yml): run flutter test; pass OTLP secrets into flutter build apk/appbundle/ios; switch iOS CI to release simulator builds and package build/ios/iphonesimulator/Runner.app to avoid requiring an Apple Development Team for generic device builds.

.gitignore: allow tracking mobile/lib/data/ despite the repo-wide data/ ignore rule.
This commit is contained in:
Dries Peeters
2026-03-28 18:01:10 +01:00
parent 4007ee2ca8
commit da85aedefb
18 changed files with 1100 additions and 72 deletions
+27 -31
View File
@@ -45,14 +45,19 @@ jobs:
working-directory: mobile
run: dart run flutter_launcher_icons
# Skip tests for now - mobile app is incomplete (tests exist but lib/ source code is missing)
# - name: Run tests
# working-directory: mobile
# run: flutter test
- name: Run tests
working-directory: mobile
run: flutter test
- name: Build APK
working-directory: mobile
run: flutter build apk --release
env:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
run: |
flutter build apk --release \
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
- name: Upload APK
uses: actions/upload-artifact@v4
@@ -92,43 +97,34 @@ jobs:
dart run flutter_launcher_icons
dart run flutter_launcher_icons -f flutter_launcher_icons_ios.yaml
- name: Configure iOS project for device build without code signing
# Simulator build avoids Apple Development Team / provisioning (device ipa still needs signing locally).
- name: Run tests
working-directory: mobile
run: flutter test
- name: Build iOS (simulator)
working-directory: mobile
env:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
run: |
# Disable code signing requirements in Xcode project
sed -i '' 's/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGN_IDENTITY = .*;/CODE_SIGN_IDENTITY = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = .*;/CODE_SIGN_IDENTITY[sdk=iphoneos*] = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGNING_REQUIRED = .*;/CODE_SIGNING_REQUIRED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGNING_ALLOWED = .*;/CODE_SIGNING_ALLOWED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
# Also set for all configurations
sed -i '' 's/ProvisioningStyle = Automatic;/ProvisioningStyle = Manual;/g' ios/Runner.xcodeproj/project.pbxproj
# Skip tests for now - mobile app is incomplete (tests exist but lib/ source code is missing)
# - name: Run tests
# working-directory: mobile
# run: flutter test
- name: Build iOS
working-directory: mobile
run: flutter build ios --release --no-codesign
flutter build ios --release --simulator \
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
- name: Create iOS archive
if: success()
working-directory: mobile
run: |
mkdir -p dist
# Package the built iOS app (IPA would require codesigning, so we'll package the .app)
if [ -d "build/ios/iphoneos/Runner.app" ]; then
cd build/ios/iphoneos
if [ -d "build/ios/iphonesimulator/Runner.app" ]; then
cd build/ios/iphonesimulator
zip -r ../../../dist/TimeTracker-iOS.zip Runner.app
cd ../../..
else
echo "Warning: Runner.app not found at build/ios/iphoneos/Runner.app"
echo "Listing build/ios directory:"
ls -la build/ios/ || echo "build/ios directory does not exist"
echo "Listing build directory:"
ls -la build/ || echo "build directory does not exist"
echo "Warning: Runner.app not found at build/ios/iphonesimulator/Runner.app"
ls -la build/ios/ || true
ls -la build/ || true
fi
- name: Upload iOS build
+35 -25
View File
@@ -960,13 +960,29 @@ jobs:
working-directory: mobile
run: dart run flutter_launcher_icons
- name: Run mobile tests
working-directory: mobile
run: flutter test
- name: Build APK
working-directory: mobile
run: flutter build apk --release
env:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
run: |
flutter build apk --release \
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
- name: Build App Bundle
working-directory: mobile
run: flutter build appbundle --release
env:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
run: |
flutter build appbundle --release \
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
continue-on-error: true
- name: Upload Android APK
@@ -1022,42 +1038,36 @@ jobs:
dart run flutter_launcher_icons
dart run flutter_launcher_icons -f flutter_launcher_icons_ios.yaml
- name: Configure iOS project for device build without code signing
- name: Run mobile tests
working-directory: mobile
run: |
# Disable code signing requirements in Xcode project
sed -i '' 's/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGN_IDENTITY = .*;/CODE_SIGN_IDENTITY = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = .*;/CODE_SIGN_IDENTITY[sdk=iphoneos*] = "";/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGNING_REQUIRED = .*;/CODE_SIGNING_REQUIRED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
sed -i '' 's/CODE_SIGNING_ALLOWED = .*;/CODE_SIGNING_ALLOWED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
# Also set for all configurations
sed -i '' 's/ProvisioningStyle = Automatic;/ProvisioningStyle = Manual;/g' ios/Runner.xcodeproj/project.pbxproj
run: flutter test
- name: Build iOS (no codesign)
- name: Build iOS (simulator)
working-directory: mobile
run: flutter build ios --release --no-codesign
env:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
run: |
flutter build ios --release --simulator \
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
- name: Create iOS archive
if: success()
working-directory: mobile
run: |
mkdir -p dist
# Package the built iOS app (IPA would require codesigning, so we'll package the .app)
if [ -d "build/ios/iphoneos/Runner.app" ]; then
cd build/ios/iphoneos
if [ -d "build/ios/iphonesimulator/Runner.app" ]; then
cd build/ios/iphonesimulator
zip -r ../../../dist/TimeTracker-iOS-${{ needs.determine-version.outputs.version }}.zip Runner.app
cd ../../..
echo "✅ iOS archive created successfully"
echo "✅ iOS simulator archive created successfully"
ls -lh dist/
else
echo "❌ ERROR: Runner.app not found at build/ios/iphoneos/Runner.app"
echo "Listing build/ios directory:"
ls -la build/ios/ || echo "build/ios directory does not exist"
echo "Listing build directory:"
ls -la build/ || echo "build directory does not exist"
echo "Searching for Runner.app:"
find build -name "Runner.app" -type d 2>/dev/null || echo "Runner.app not found anywhere"
echo "❌ ERROR: Runner.app not found at build/ios/iphonesimulator/Runner.app"
ls -la build/ios/ || true
ls -la build/ || true
find build -name "Runner.app" -type d 2>/dev/null || true
exit 1
fi
+3
View File
@@ -131,6 +131,9 @@ dmypy.json
# Application specific
data/
# Flutter app source lives under mobile/lib/data/ (do not treat as runtime data dir)
!mobile/lib/data/
!mobile/lib/data/**
logs/
backups/
*.db
@@ -0,0 +1,96 @@
import 'dart:convert';
import 'package:opentelemetry/api.dart' as otel_api;
import 'package:opentelemetry/sdk.dart' as otel_sdk;
import 'package:package_info_plus/package_info_plus.dart';
bool _mobileOtelInitialized = false;
/// OTLP endpoint and token from `--dart-define=OTEL_EXPORTER_OTLP_*` (CI parity with main app).
/// Values are embedded in release binaries—avoid for untrusted distribution.
Future<void> initMobileOpenTelemetry() async {
const endpoint = String.fromEnvironment('OTEL_EXPORTER_OTLP_ENDPOINT');
const token = String.fromEnvironment('OTEL_EXPORTER_OTLP_TOKEN');
if (endpoint.isEmpty || token.isEmpty) {
return;
}
var base = endpoint.trim();
if (base.endsWith('/')) {
base = base.substring(0, base.length - 1);
}
if (base.endsWith('/v1/logs')) {
base = base.substring(0, base.length - '/v1/logs'.length);
} else {
final idx = base.lastIndexOf('/v1/logs');
if (idx != -1) {
base = base.substring(0, idx);
}
}
final traceUri = Uri.parse('$base/v1/traces');
final authHeader = buildOtlpAuthHeader(token);
final info = await PackageInfo.fromPlatform();
final resource = otel_sdk.Resource([
otel_api.Attribute.fromString('service.name', 'timetracker-mobile'),
otel_api.Attribute.fromString('service.version', info.version),
]);
final exporter = otel_sdk.CollectorExporter(
traceUri,
headers: {'Authorization': authHeader},
);
final provider = otel_sdk.TracerProviderBase(
resource: resource,
processors: [
otel_sdk.BatchSpanProcessor(exporter),
],
);
otel_api.registerGlobalTracerProvider(provider);
_mobileOtelInitialized = true;
}
/// Matches [app.telemetry.service._build_otlp_auth_header] (Basic / instance:token).
String buildOtlpAuthHeader(String token) {
final value = token.trim();
if (value.toLowerCase().startsWith('basic ')) {
return value;
}
if (value.contains(':')) {
final encoded = base64Encode(utf8.encode(value));
return 'Basic $encoded';
}
return 'Basic $value';
}
/// No-op if OpenTelemetry was not initialized.
otel_api.Tracer? get mobileTracerOrNull {
if (!_mobileOtelInitialized) return null;
return otel_api.globalTracerProvider.getTracer('timetracker.mobile');
}
Future<T> runMobileSpan<T>(
String name,
Future<T> Function() body, {
Map<String, String> attributes = const {},
}) async {
final tracer = mobileTracerOrNull;
if (tracer == null) {
return body();
}
final span = tracer.startSpan(name);
for (final e in attributes.entries) {
span.setAttribute(otel_api.Attribute.fromString(e.key, e.value));
}
try {
return await body();
} catch (e, st) {
span.setStatus(otel_api.StatusCode.error, e.toString());
span.recordException(e, stackTrace: st);
rethrow;
} finally {
span.end();
}
}
+418
View File
@@ -0,0 +1,418 @@
import 'package:dio/dio.dart';
import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart';
/// HTTP client for TimeTracker `/api/v1` (Bearer token after login).
class ApiClient {
ApiClient({
required String baseUrl,
Set<String>? trustedInsecureHosts,
}) : _trusted = trustedInsecureHosts ?? {},
_dio = Dio() {
var normalized = baseUrl.trim();
if (!normalized.endsWith('/')) {
normalized = '$normalized/';
}
_baseUrl = normalized;
_dio.options = BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
validateStatus: (_) => true,
);
configureDioTrustedHosts(_dio, _trusted);
}
final Dio _dio;
final Set<String> _trusted;
late final String _baseUrl;
String get baseUrl => _baseUrl;
Future<void> setAuthToken(String token) async {
_dio.options.headers['Authorization'] = 'Bearer $token';
}
Future<Response<dynamic>> validateTokenRaw() {
return _dio.get<dynamic>('/api/v1/timer/status');
}
Future<Map<String, dynamic>> getUsersMe() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/users/me');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getCurrentUser() async {
final me = await getUsersMe();
final u = me['user'];
if (u is Map<String, dynamic>) return u;
if (u is Map) return Map<String, dynamic>.from(u);
return {};
}
Future<Map<String, dynamic>> getTimerStatus() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/timer/status');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> startTimer({
required int projectId,
int? taskId,
String? notes,
int? templateId,
}) async {
final body = <String, dynamic>{
'project_id': projectId,
if (taskId != null) 'task_id': taskId,
if (notes != null) 'notes': notes,
if (templateId != null) 'template_id': templateId,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timer/start', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> stopTimer() async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timer/stop');
final code = res.statusCode ?? 0;
if (code >= 200 && code < 300) {
return Map<String, dynamic>.from(res.data ?? {});
}
throw DioException(
requestOptions: res.requestOptions,
response: res,
type: DioExceptionType.badResponse,
);
}
Future<Map<String, dynamic>> getTimeEntries({
int? projectId,
String? startDate,
String? endDate,
bool? billable,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/time-entries',
queryParameters: <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (startDate != null) 'start_date': startDate,
if (endDate != null) 'end_date': endDate,
if (billable != null) 'billable': billable.toString(),
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeEntry(int entryId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-entries/$entryId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final body = <String, dynamic>{
'project_id': projectId,
'start_time': startTime,
if (taskId != null) 'task_id': taskId,
if (endTime != null) 'end_time': endTime,
if (notes != null) 'notes': notes,
if (tags != null) 'tags': tags,
if (billable != null) 'billable': billable,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/time-entries', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> updateTimeEntry(
int entryId, {
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final body = <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (taskId != null) 'task_id': taskId,
if (startTime != null) 'start_time': startTime,
if (endTime != null) 'end_time': endTime,
if (notes != null) 'notes': notes,
if (tags != null) 'tags': tags,
if (billable != null) 'billable': billable,
};
final res = await _dio.patch<Map<String, dynamic>>('/api/v1/time-entries/$entryId', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> deleteTimeEntry(int entryId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/time-entries/$entryId');
_throwIfError(res);
}
Future<Map<String, dynamic>> getProjects({
String? status,
int? clientId,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/projects',
queryParameters: <String, dynamic>{
if (status != null) 'status': status,
if (clientId != null) 'client_id': clientId,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getProject(int projectId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/projects/$projectId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTasks({
int? projectId,
String? status,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/tasks',
queryParameters: <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (status != null) 'status': status,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTask(int taskId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/tasks/$taskId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getClients({
String? status,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/clients',
queryParameters: <String, dynamic>{
if (status != null) 'status': status,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getInvoices({
int? page,
int? perPage,
String? status,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/invoices',
queryParameters: <String, dynamic>{
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
if (status != null) 'status': status,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getExpenses({
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/expenses',
queryParameters: <String, dynamic>{
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createExpense(Map<String, dynamic> body) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/expenses', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createInvoice(Map<String, dynamic> body) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/invoices', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> updateInvoice(int invoiceId, Map<String, dynamic> body) async {
final res = await _dio.patch<Map<String, dynamic>>('/api/v1/invoices/$invoiceId', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimesheetPeriods({
String? startDate,
String? endDate,
String? status,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/timesheet-periods',
queryParameters: <String, dynamic>{
if (startDate != null) 'start_date': startDate,
if (endDate != null) 'end_date': endDate,
if (status != null) 'status': status,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getCapacityReport({
required String startDate,
required String endDate,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/reports/capacity',
queryParameters: {'start_date': startDate, 'end_date': endDate},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getLeaveTypes() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/leave-types');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeOffRequests() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/requests');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeOffBalances() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/balances');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> submitTimesheetPeriod(int periodId) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timesheet-periods/$periodId/submit');
_throwIfError(res);
}
Future<void> approveTimesheetPeriod(int periodId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/timesheet-periods/$periodId/approve',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> rejectTimesheetPeriod(int periodId, {String? reason}) async {
final r = (reason ?? 'Rejected').trim();
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/timesheet-periods/$periodId/reject',
data: {'reason': r.isEmpty ? 'Rejected' : r},
);
_throwIfError(res);
}
Future<void> deleteTimesheetPeriod(int periodId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/timesheet-periods/$periodId');
_throwIfError(res);
}
Future<Map<String, dynamic>> createTimeOffRequest({
required int leaveTypeId,
required String startDate,
required String endDate,
double? requestedHours,
String? comment,
}) async {
final body = <String, dynamic>{
'leave_type_id': leaveTypeId,
'start_date': startDate,
'end_date': endDate,
if (requestedHours != null) 'requested_hours': requestedHours,
if (comment != null && comment.isNotEmpty) 'comment': comment,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/time-off/requests', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> approveTimeOffRequest(int requestId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/time-off/requests/$requestId/approve',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> rejectTimeOffRequest(int requestId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/time-off/requests/$requestId/reject',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> deleteTimeOffRequest(int requestId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/time-off/requests/$requestId');
_throwIfError(res);
}
void _throwIfError(Response<dynamic> res) {
final code = res.statusCode ?? 0;
if (code >= 200 && code < 300) return;
final data = res.data;
String msg = 'HTTP $code';
if (data is Map) {
final err = data['error'] ?? data['message'];
if (err != null) msg = err.toString();
}
throw DioException(
requestOptions: res.requestOptions,
response: res,
type: DioExceptionType.badResponse,
message: msg,
);
}
}
+47
View File
@@ -0,0 +1,47 @@
class Project {
final int id;
final String name;
final String? client;
final String status;
final bool billable;
final DateTime? createdAt;
final DateTime? updatedAt;
const Project({
required this.id,
required this.name,
this.client,
this.status = 'active',
this.billable = true,
this.createdAt,
this.updatedAt,
});
factory Project.fromJson(Map<String, dynamic> json) {
return Project(
id: (json['id'] as num).toInt(),
name: (json['name'] ?? '').toString(),
client: json['client']?.toString(),
status: (json['status'] ?? 'active').toString(),
billable: json['billable'] == true,
createdAt: _parseDt(json['created_at']),
updatedAt: _parseDt(json['updated_at']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'client': client,
'status': status,
'billable': billable,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
static DateTime? _parseDt(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
return DateTime.tryParse(v.toString());
}
}
+51
View File
@@ -0,0 +1,51 @@
class Task {
final int id;
final int projectId;
final String name;
final String status;
final String? priority;
final int? createdBy;
final DateTime? createdAt;
final DateTime? updatedAt;
const Task({
required this.id,
required this.projectId,
required this.name,
this.status = 'todo',
this.priority,
this.createdBy,
this.createdAt,
this.updatedAt,
});
factory Task.fromJson(Map<String, dynamic> json) {
return Task(
id: (json['id'] as num).toInt(),
projectId: (json['project_id'] as num?)?.toInt() ?? 0,
name: (json['name'] ?? '').toString(),
status: (json['status'] ?? 'todo').toString(),
priority: json['priority']?.toString(),
createdBy: (json['created_by'] as num?)?.toInt(),
createdAt: _parseDt(json['created_at']),
updatedAt: _parseDt(json['updated_at']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'project_id': projectId,
'name': name,
'status': status,
'priority': priority,
'created_by': createdBy,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
static DateTime? _parseDt(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
return DateTime.tryParse(v.toString());
}
}
+98
View File
@@ -0,0 +1,98 @@
class TimeEntry {
final int id;
final int userId;
final int? projectId;
final int? taskId;
final DateTime? startTime;
final DateTime? endTime;
final int? durationSeconds;
final String? notes;
final String? tags;
final String source;
final bool billable;
final bool paid;
final DateTime? createdAt;
final DateTime? updatedAt;
const TimeEntry({
required this.id,
this.userId = 0,
this.projectId,
this.taskId,
this.startTime,
this.endTime,
this.durationSeconds,
this.notes,
this.tags,
this.source = 'manual',
this.billable = true,
this.paid = false,
this.createdAt,
this.updatedAt,
});
factory TimeEntry.fromJson(Map<String, dynamic> json) {
return TimeEntry(
id: (json['id'] as num).toInt(),
userId: (json['user_id'] as num?)?.toInt() ?? 0,
projectId: (json['project_id'] as num?)?.toInt(),
taskId: (json['task_id'] as num?)?.toInt(),
startTime: _parseDt(json['start_time']),
endTime: _parseDt(json['end_time']),
durationSeconds: (json['duration_seconds'] as num?)?.toInt(),
notes: json['notes']?.toString(),
tags: json['tags']?.toString(),
source: (json['source'] ?? 'manual').toString(),
billable: json['billable'] != false,
paid: json['paid'] == true,
createdAt: _parseDt(json['created_at']),
updatedAt: _parseDt(json['updated_at']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'user_id': userId,
'project_id': projectId,
'task_id': taskId,
'start_time': startTime?.toIso8601String(),
'end_time': endTime?.toIso8601String(),
'duration_seconds': durationSeconds,
'notes': notes,
'tags': tags,
'source': source,
'billable': billable,
'paid': paid,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
String get formattedDuration {
final secs = durationSeconds;
if (secs == null || secs <= 0) {
if (startTime != null && endTime != null) {
final d = endTime!.difference(startTime!);
return _formatSeconds(d.inSeconds);
}
return '0m';
}
return _formatSeconds(secs);
}
static String _formatSeconds(int totalSeconds) {
final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60;
if (h > 0) {
return m > 0 ? '${h}h ${m}m' : '${h}h';
}
if (m > 0) return '${m}m';
final s = totalSeconds % 60;
return '${s}s';
}
static DateTime? _parseDt(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
return DateTime.tryParse(v.toString());
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
@immutable
class TimeEntryRequirements {
final bool requireTask;
final bool requireDescription;
final int descriptionMinLength;
const TimeEntryRequirements({
this.requireTask = false,
this.requireDescription = false,
this.descriptionMinLength = 0,
});
factory TimeEntryRequirements.fromJson(Map<String, dynamic>? json) {
if (json == null || json.isEmpty) {
return const TimeEntryRequirements();
}
return TimeEntryRequirements(
requireTask: json['require_task'] == true,
requireDescription: json['require_description'] == true,
descriptionMinLength: (json['description_min_length'] as num?)?.toInt() ?? 0,
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/foundation.dart';
/// Active timer row from `/api/v1/timer/status` (same fields as server time entry JSON).
@immutable
class Timer {
final int id;
final int userId;
final int? projectId;
final int? taskId;
final DateTime startTime;
final String? notes;
const Timer({
required this.id,
this.userId = 0,
this.projectId,
this.taskId,
required this.startTime,
this.notes,
});
factory Timer.fromJson(Map<String, dynamic> json) {
final startRaw = json['start_time'];
final start = startRaw is DateTime
? startRaw
: DateTime.tryParse(startRaw?.toString() ?? '') ?? DateTime.now();
return Timer(
id: (json['id'] as num).toInt(),
userId: (json['user_id'] as num?)?.toInt() ?? 0,
projectId: (json['project_id'] as num?)?.toInt(),
taskId: (json['task_id'] as num?)?.toInt(),
startTime: start,
notes: json['notes']?.toString(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'user_id': userId,
'project_id': projectId,
'task_id': taskId,
'start_time': startTime.toIso8601String(),
'notes': notes,
};
String get formattedElapsed {
final d = DateTime.now().difference(startTime);
final h = d.inHours;
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60);
return '${h.toString().padLeft(2, '0')}:'
'${m.toString().padLeft(2, '0')}:'
'${s.toString().padLeft(2, '0')}';
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
@immutable
class UserPrefs {
final String dateFormatKey;
final String timeFormatKey;
final String timezone;
const UserPrefs({
this.dateFormatKey = 'YYYY-MM-DD',
this.timeFormatKey = '24h',
this.timezone = 'UTC',
});
factory UserPrefs.fromJson(Map<String, dynamic>? json) {
if (json == null || json.isEmpty) {
return const UserPrefs();
}
return UserPrefs(
dateFormatKey: (json['date_format'] ?? 'YYYY-MM-DD').toString(),
timeFormatKey: (json['time_format'] ?? '24h').toString(),
timezone: (json['timezone'] ?? 'UTC').toString(),
);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:timetracker_mobile/data/models/timer.dart' as tt;
import 'package:timetracker_mobile/data/models/time_entry.dart';
class LocalStorage {
LocalStorage._();
static const _boxName = 'timetracker_local_v1';
static const _kTimer = 'timer';
static const _kEntries = 'time_entries';
static const _kQueue = 'sync_queue';
static Box<String>? _box;
static Future<void> init() async {
await Hive.initFlutter();
_box = await Hive.openBox<String>(_boxName);
}
static Box<String> get _b {
final b = _box;
if (b == null) {
throw StateError('LocalStorage.init() was not called');
}
return b;
}
static Future<tt.Timer?> getTimer() async {
final raw = _b.get(_kTimer);
if (raw == null || raw.isEmpty) return null;
final map = jsonDecode(raw) as Map<String, dynamic>;
return tt.Timer.fromJson(map);
}
static Future<void> saveTimer(tt.Timer timer) async {
await _b.put(_kTimer, jsonEncode(timer.toJson()));
}
static Future<void> clearTimer() async {
await _b.delete(_kTimer);
}
static Future<List<TimeEntry>> getAllTimeEntries() async {
final raw = _b.get(_kEntries);
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => TimeEntry.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
static Future<void> saveTimeEntry(TimeEntry entry) async {
final all = await getAllTimeEntries();
final idx = all.indexWhere((e) => e.id == entry.id);
if (idx >= 0) {
all[idx] = entry;
} else {
all.add(entry);
}
await _persistEntries(all);
}
static Future<void> deleteTimeEntry(int entryId) async {
final all = await getAllTimeEntries();
all.removeWhere((e) => e.id == entryId);
await _persistEntries(all);
}
static Future<void> _persistEntries(List<TimeEntry> entries) async {
await _b.put(
_kEntries,
jsonEncode(entries.map((e) => e.toJson()).toList()),
);
}
static Future<List<Map<String, dynamic>>> getSyncQueue() async {
final raw = _b.get(_kQueue);
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
return list.map((e) => Map<String, dynamic>.from(e as Map)).toList();
}
static Future<void> setSyncQueue(List<Map<String, dynamic>> queue) async {
await _b.put(_kQueue, jsonEncode(queue));
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
class SyncService {
SyncService(this._api);
final ApiClient? _api;
static Future<void> queueCreateTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final q = await LocalStorage.getSyncQueue();
q.add({
'op': 'create_time_entry',
'project_id': projectId,
if (taskId != null) 'task_id': taskId,
'start_time': startTime,
if (endTime != null) 'end_time': endTime,
if (notes != null) 'notes': notes,
if (tags != null) 'tags': tags,
if (billable != null) 'billable': billable,
});
await LocalStorage.setSyncQueue(q);
}
static Future<void> queueDeleteTimeEntry(int entryId) async {
final q = await LocalStorage.getSyncQueue();
q.add({
'op': 'delete_time_entry',
'entry_id': entryId,
});
await LocalStorage.setSyncQueue(q);
}
Future<void> syncAll() async {
await processQueue();
await syncFromServer();
}
Future<void> processQueue() async {
final api = _api;
if (api == null) return;
final q = await LocalStorage.getSyncQueue();
final remaining = <Map<String, dynamic>>[];
for (final op in q) {
final type = op['op']?.toString();
try {
if (type == 'create_time_entry') {
await api.createTimeEntry(
projectId: (op['project_id'] as num).toInt(),
taskId: (op['task_id'] as num?)?.toInt(),
startTime: op['start_time'].toString(),
endTime: op['end_time']?.toString(),
notes: op['notes']?.toString(),
tags: op['tags']?.toString(),
billable: op['billable'] as bool?,
);
} else if (type == 'delete_time_entry') {
await api.deleteTimeEntry((op['entry_id'] as num).toInt());
} else {
remaining.add(op);
}
} catch (_) {
remaining.add(op);
}
}
await LocalStorage.setSyncQueue(remaining);
}
Future<void> syncFromServer() async {
final api = _api;
if (api == null) return;
try {
final res = await api.getTimeEntries(perPage: 200);
final raw = res['time_entries'] as List<dynamic>? ?? [];
for (final e in raw) {
if (e is Map) {
final entry = TimeEntry.fromJson(Map<String, dynamic>.from(e));
await LocalStorage.saveTimeEntry(entry);
}
}
} catch (_) {}
}
}
@@ -5,6 +5,7 @@ import 'package:timetracker_mobile/data/models/timer.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
import 'package:timetracker_mobile/data/models/project.dart';
import 'package:timetracker_mobile/data/models/task.dart';
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
import 'package:timetracker_mobile/data/storage/sync_service.dart';
@@ -94,11 +95,15 @@ class TimeTrackingRepository {
return timer;
}
final response = await apiClient!.startTimer(
projectId: projectId,
taskId: taskId,
notes: notes,
templateId: templateId,
final response = await runMobileSpan(
'mobile.timer.start',
() => apiClient!.startTimer(
projectId: projectId,
taskId: taskId,
notes: notes,
templateId: templateId,
),
attributes: {'project_id': '$projectId'},
);
final timer = Timer.fromJson(response['timer'] as Map<String, dynamic>);
await LocalStorage.saveTimer(timer);
@@ -114,7 +119,10 @@ class TimeTrackingRepository {
throw Exception('Not connected to server');
}
try {
final response = await apiClient!.stopTimer();
final response = await runMobileSpan(
'mobile.timer.stop',
() => apiClient!.stopTimer(),
);
final entry = TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
await LocalStorage.clearTimer();
return entry;
@@ -334,7 +342,12 @@ class TimeTrackingRepository {
/// Sync pending operations
Future<void> syncPending() async {
await _syncService?.syncAll();
await runMobileSpan(
'mobile.sync.pending',
() async {
await _syncService?.syncAll();
},
);
}
// ==================== Project Operations ====================
+8 -8
View File
@@ -1,9 +1,10 @@
import 'dart:async';
import '../../core/config/app_config.dart';
import '../../data/api/api_client.dart';
import '../../data/local/database/sync_service.dart';
import '../../utils/auth/auth_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/data/storage/sync_service.dart';
import 'package:timetracker_mobile/utils/auth/auth_service.dart';
class SyncUseCase {
final Connectivity _connectivity = Connectivity();
@@ -47,13 +48,12 @@ class SyncUseCase {
return false;
}
final apiClient = ApiClient(baseUrl: serverUrl);
final trustedHosts = await AppConfig.getTrustedInsecureHosts();
final apiClient = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
await apiClient.setAuthToken(token);
final syncService = SyncService(apiClient);
await syncService.processQueue();
await syncService.syncFromServer();
await syncService.syncAll();
return true;
} catch (_) {
+2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/core/constants/app_constants.dart';
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
import 'package:timetracker_mobile/core/theme/app_theme.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
import 'package:timetracker_mobile/presentation/providers/theme_mode_provider.dart';
@@ -11,6 +12,7 @@ import 'package:timetracker_mobile/presentation/screens/home_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalStorage.init();
await initMobileOpenTelemetry();
runApp(
const ProviderScope(
child: TimeTrackerApp(),
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/core/constants/app_constants.dart';
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/utils/network/connection_diagnostics.dart';
import 'package:timetracker_mobile/utils/ssl/certificate_error.dart';
@@ -110,7 +111,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final apiClient = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
await apiClient.setAuthToken(token);
final validationResponse = await apiClient.validateTokenRaw();
final validationResponse = await runMobileSpan(
'mobile.login.validate_token',
() => apiClient.validateTokenRaw(),
);
final status = validationResponse.statusCode;
if (status != 200) {
setState(() {
+3
View File
@@ -42,6 +42,9 @@ dependencies:
package_info_plus: ^8.0.0
google_fonts: ^8.0.0
# OpenTelemetry OTLP/HTTP traces (same env names as server: OTEL_EXPORTER_OTLP_* via --dart-define)
opentelemetry: ^0.18.11
dev_dependencies:
flutter_test:
sdk: flutter