diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 1c7158d3..0f35e4c0 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -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 diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 2697b877..05662d6b 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index de361c3f..6fbac70e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/mobile/lib/core/telemetry/mobile_otel.dart b/mobile/lib/core/telemetry/mobile_otel.dart new file mode 100644 index 00000000..d4effe70 --- /dev/null +++ b/mobile/lib/core/telemetry/mobile_otel.dart @@ -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 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 runMobileSpan( + String name, + Future Function() body, { + Map 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(); + } +} diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart new file mode 100644 index 00000000..bf7bed45 --- /dev/null +++ b/mobile/lib/data/api/api_client.dart @@ -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? 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 _trusted; + late final String _baseUrl; + + String get baseUrl => _baseUrl; + + Future setAuthToken(String token) async { + _dio.options.headers['Authorization'] = 'Bearer $token'; + } + + Future> validateTokenRaw() { + return _dio.get('/api/v1/timer/status'); + } + + Future> getUsersMe() async { + final res = await _dio.get>('/api/v1/users/me'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getCurrentUser() async { + final me = await getUsersMe(); + final u = me['user']; + if (u is Map) return u; + if (u is Map) return Map.from(u); + return {}; + } + + Future> getTimerStatus() async { + final res = await _dio.get>('/api/v1/timer/status'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> startTimer({ + required int projectId, + int? taskId, + String? notes, + int? templateId, + }) async { + final body = { + '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>('/api/v1/timer/start', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> stopTimer() async { + final res = await _dio.post>('/api/v1/timer/stop'); + final code = res.statusCode ?? 0; + if (code >= 200 && code < 300) { + return Map.from(res.data ?? {}); + } + throw DioException( + requestOptions: res.requestOptions, + response: res, + type: DioExceptionType.badResponse, + ); + } + + Future> getTimeEntries({ + int? projectId, + String? startDate, + String? endDate, + bool? billable, + int? page, + int? perPage, + }) async { + final res = await _dio.get>( + '/api/v1/time-entries', + queryParameters: { + 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.from(res.data ?? {}); + } + + Future> getTimeEntry(int entryId) async { + final res = await _dio.get>('/api/v1/time-entries/$entryId'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> createTimeEntry({ + required int projectId, + int? taskId, + required String startTime, + String? endTime, + String? notes, + String? tags, + bool? billable, + }) async { + final body = { + '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>('/api/v1/time-entries', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> updateTimeEntry( + int entryId, { + int? projectId, + int? taskId, + String? startTime, + String? endTime, + String? notes, + String? tags, + bool? billable, + }) async { + final body = { + 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>('/api/v1/time-entries/$entryId', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future deleteTimeEntry(int entryId) async { + final res = await _dio.delete>('/api/v1/time-entries/$entryId'); + _throwIfError(res); + } + + Future> getProjects({ + String? status, + int? clientId, + int? page, + int? perPage, + }) async { + final res = await _dio.get>( + '/api/v1/projects', + queryParameters: { + 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.from(res.data ?? {}); + } + + Future> getProject(int projectId) async { + final res = await _dio.get>('/api/v1/projects/$projectId'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getTasks({ + int? projectId, + String? status, + int? page, + int? perPage, + }) async { + final res = await _dio.get>( + '/api/v1/tasks', + queryParameters: { + 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.from(res.data ?? {}); + } + + Future> getTask(int taskId) async { + final res = await _dio.get>('/api/v1/tasks/$taskId'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getClients({ + String? status, + int? page, + int? perPage, + }) async { + final res = await _dio.get>( + '/api/v1/clients', + queryParameters: { + if (status != null) 'status': status, + if (page != null) 'page': page, + if (perPage != null) 'per_page': perPage, + }, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getInvoices({ + int? page, + int? perPage, + String? status, + }) async { + final res = await _dio.get>( + '/api/v1/invoices', + queryParameters: { + if (page != null) 'page': page, + if (perPage != null) 'per_page': perPage, + if (status != null) 'status': status, + }, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getExpenses({ + int? page, + int? perPage, + }) async { + final res = await _dio.get>( + '/api/v1/expenses', + queryParameters: { + if (page != null) 'page': page, + if (perPage != null) 'per_page': perPage, + }, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> createExpense(Map body) async { + final res = await _dio.post>('/api/v1/expenses', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> createInvoice(Map body) async { + final res = await _dio.post>('/api/v1/invoices', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> updateInvoice(int invoiceId, Map body) async { + final res = await _dio.patch>('/api/v1/invoices/$invoiceId', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getTimesheetPeriods({ + String? startDate, + String? endDate, + String? status, + }) async { + final res = await _dio.get>( + '/api/v1/timesheet-periods', + queryParameters: { + if (startDate != null) 'start_date': startDate, + if (endDate != null) 'end_date': endDate, + if (status != null) 'status': status, + }, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getCapacityReport({ + required String startDate, + required String endDate, + }) async { + final res = await _dio.get>( + '/api/v1/reports/capacity', + queryParameters: {'start_date': startDate, 'end_date': endDate}, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getLeaveTypes() async { + final res = await _dio.get>('/api/v1/time-off/leave-types'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getTimeOffRequests() async { + final res = await _dio.get>('/api/v1/time-off/requests'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> getTimeOffBalances() async { + final res = await _dio.get>('/api/v1/time-off/balances'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future submitTimesheetPeriod(int periodId) async { + final res = await _dio.post>('/api/v1/timesheet-periods/$periodId/submit'); + _throwIfError(res); + } + + Future approveTimesheetPeriod(int periodId, {String? comment}) async { + final res = await _dio.post>( + '/api/v1/timesheet-periods/$periodId/approve', + data: {if (comment != null) 'comment': comment}, + ); + _throwIfError(res); + } + + Future rejectTimesheetPeriod(int periodId, {String? reason}) async { + final r = (reason ?? 'Rejected').trim(); + final res = await _dio.post>( + '/api/v1/timesheet-periods/$periodId/reject', + data: {'reason': r.isEmpty ? 'Rejected' : r}, + ); + _throwIfError(res); + } + + Future deleteTimesheetPeriod(int periodId) async { + final res = await _dio.delete>('/api/v1/timesheet-periods/$periodId'); + _throwIfError(res); + } + + Future> createTimeOffRequest({ + required int leaveTypeId, + required String startDate, + required String endDate, + double? requestedHours, + String? comment, + }) async { + final body = { + '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>('/api/v1/time-off/requests', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future approveTimeOffRequest(int requestId, {String? comment}) async { + final res = await _dio.post>( + '/api/v1/time-off/requests/$requestId/approve', + data: {if (comment != null) 'comment': comment}, + ); + _throwIfError(res); + } + + Future rejectTimeOffRequest(int requestId, {String? comment}) async { + final res = await _dio.post>( + '/api/v1/time-off/requests/$requestId/reject', + data: {if (comment != null) 'comment': comment}, + ); + _throwIfError(res); + } + + Future deleteTimeOffRequest(int requestId) async { + final res = await _dio.delete>('/api/v1/time-off/requests/$requestId'); + _throwIfError(res); + } + + void _throwIfError(Response 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, + ); + } +} diff --git a/mobile/lib/data/models/project.dart b/mobile/lib/data/models/project.dart new file mode 100644 index 00000000..c825cce7 --- /dev/null +++ b/mobile/lib/data/models/project.dart @@ -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 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 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()); + } +} diff --git a/mobile/lib/data/models/task.dart b/mobile/lib/data/models/task.dart new file mode 100644 index 00000000..0381bc05 --- /dev/null +++ b/mobile/lib/data/models/task.dart @@ -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 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 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()); + } +} diff --git a/mobile/lib/data/models/time_entry.dart b/mobile/lib/data/models/time_entry.dart new file mode 100644 index 00000000..7dcf7ce0 --- /dev/null +++ b/mobile/lib/data/models/time_entry.dart @@ -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 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 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()); + } +} diff --git a/mobile/lib/data/models/time_entry_requirements.dart b/mobile/lib/data/models/time_entry_requirements.dart new file mode 100644 index 00000000..9cd0d97c --- /dev/null +++ b/mobile/lib/data/models/time_entry_requirements.dart @@ -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? 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, + ); + } +} diff --git a/mobile/lib/data/models/timer.dart b/mobile/lib/data/models/timer.dart new file mode 100644 index 00000000..42a551f9 --- /dev/null +++ b/mobile/lib/data/models/timer.dart @@ -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 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 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')}'; + } +} diff --git a/mobile/lib/data/models/user_prefs.dart b/mobile/lib/data/models/user_prefs.dart new file mode 100644 index 00000000..fa1ea5a5 --- /dev/null +++ b/mobile/lib/data/models/user_prefs.dart @@ -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? 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(), + ); + } +} diff --git a/mobile/lib/data/storage/local_storage.dart b/mobile/lib/data/storage/local_storage.dart new file mode 100644 index 00000000..9a12ae49 --- /dev/null +++ b/mobile/lib/data/storage/local_storage.dart @@ -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? _box; + + static Future init() async { + await Hive.initFlutter(); + _box = await Hive.openBox(_boxName); + } + + static Box get _b { + final b = _box; + if (b == null) { + throw StateError('LocalStorage.init() was not called'); + } + return b; + } + + static Future getTimer() async { + final raw = _b.get(_kTimer); + if (raw == null || raw.isEmpty) return null; + final map = jsonDecode(raw) as Map; + return tt.Timer.fromJson(map); + } + + static Future saveTimer(tt.Timer timer) async { + await _b.put(_kTimer, jsonEncode(timer.toJson())); + } + + static Future clearTimer() async { + await _b.delete(_kTimer); + } + + static Future> getAllTimeEntries() async { + final raw = _b.get(_kEntries); + if (raw == null || raw.isEmpty) return []; + final list = jsonDecode(raw) as List; + return list + .map((e) => TimeEntry.fromJson(Map.from(e as Map))) + .toList(); + } + + static Future 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 deleteTimeEntry(int entryId) async { + final all = await getAllTimeEntries(); + all.removeWhere((e) => e.id == entryId); + await _persistEntries(all); + } + + static Future _persistEntries(List entries) async { + await _b.put( + _kEntries, + jsonEncode(entries.map((e) => e.toJson()).toList()), + ); + } + + static Future>> getSyncQueue() async { + final raw = _b.get(_kQueue); + if (raw == null || raw.isEmpty) return []; + final list = jsonDecode(raw) as List; + return list.map((e) => Map.from(e as Map)).toList(); + } + + static Future setSyncQueue(List> queue) async { + await _b.put(_kQueue, jsonEncode(queue)); + } +} diff --git a/mobile/lib/data/storage/sync_service.dart b/mobile/lib/data/storage/sync_service.dart new file mode 100644 index 00000000..9d756786 --- /dev/null +++ b/mobile/lib/data/storage/sync_service.dart @@ -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 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 queueDeleteTimeEntry(int entryId) async { + final q = await LocalStorage.getSyncQueue(); + q.add({ + 'op': 'delete_time_entry', + 'entry_id': entryId, + }); + await LocalStorage.setSyncQueue(q); + } + + Future syncAll() async { + await processQueue(); + await syncFromServer(); + } + + Future processQueue() async { + final api = _api; + if (api == null) return; + + final q = await LocalStorage.getSyncQueue(); + final remaining = >[]; + + 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 syncFromServer() async { + final api = _api; + if (api == null) return; + try { + final res = await api.getTimeEntries(perPage: 200); + final raw = res['time_entries'] as List? ?? []; + for (final e in raw) { + if (e is Map) { + final entry = TimeEntry.fromJson(Map.from(e)); + await LocalStorage.saveTimeEntry(entry); + } + } + } catch (_) {} + } +} diff --git a/mobile/lib/domain/repositories/time_tracking_repository.dart b/mobile/lib/domain/repositories/time_tracking_repository.dart index 027f421e..9628549a 100644 --- a/mobile/lib/domain/repositories/time_tracking_repository.dart +++ b/mobile/lib/domain/repositories/time_tracking_repository.dart @@ -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); 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); await LocalStorage.clearTimer(); return entry; @@ -334,7 +342,12 @@ class TimeTrackingRepository { /// Sync pending operations Future syncPending() async { - await _syncService?.syncAll(); + await runMobileSpan( + 'mobile.sync.pending', + () async { + await _syncService?.syncAll(); + }, + ); } // ==================== Project Operations ==================== diff --git a/mobile/lib/domain/usecases/sync_usecase.dart b/mobile/lib/domain/usecases/sync_usecase.dart index 0dc22833..46fd03f1 100644 --- a/mobile/lib/domain/usecases/sync_usecase.dart +++ b/mobile/lib/domain/usecases/sync_usecase.dart @@ -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 (_) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 04390d39..587f8085 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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(), diff --git a/mobile/lib/presentation/screens/login_screen.dart b/mobile/lib/presentation/screens/login_screen.dart index 6c7526d7..1badd40a 100644 --- a/mobile/lib/presentation/screens/login_screen.dart +++ b/mobile/lib/presentation/screens/login_screen.dart @@ -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 { 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(() { diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1bc47a92..5d7f2161 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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