diff --git a/lib/services/github/github_sync_service.dart b/lib/services/github/github_sync_service.dart index b29ffde..e40c3af 100644 --- a/lib/services/github/github_sync_service.dart +++ b/lib/services/github/github_sync_service.dart @@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../../features/records/records_service.dart'; +import '../auth/auth_service.dart'; import '../export_service.dart'; import '../field_test/field_test_export_service.dart'; +import '../sync/firebase_sync_provider.dart'; +import '../sync/sync_models.dart'; import 'github_auth_service.dart'; enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired } @@ -31,7 +34,13 @@ class GitHubUploadResult { }) : timestamp = timestamp ?? DateTime.now(); } -/// Offline-first background synchronization service for GitHub ride logs. +/// Ride synchronization service. +/// +/// Historical versions of the ride-detail screen called this service directly, +/// which required a separate GitHub PAT even when the user was already signed +/// into MotorCloud with Google/Firebase. When no GitHub PAT is configured we now +/// use the authoritative Firebase + R2 provider, matching Settings > Back Up All. +/// GitHub remains available when a PAT is explicitly configured. class GitHubSyncService extends ChangeNotifier { final AppDatabase _db; final GitHubAuthService _auth; @@ -57,20 +66,13 @@ class GitHubSyncService extends ChangeNotifier { if (isRideUploaded(rideId)) return; _uploadQueue.add(rideId); _state = GitHubUploadState.queued; - _statusMessage = 'Ride #$rideId queued for GitHub upload'; + _statusMessage = 'Ride #$rideId queued for cloud synchronization'; notifyListeners(); processQueue(); } Future processQueue() async { if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return; - final token = _auth.getToken(); - if (token == null || token.isEmpty) { - _state = GitHubUploadState.authRequired; - _statusMessage = 'GitHub authorization required. Google/Firebase sign-in does not authorize GitHub repository writes.'; - notifyListeners(); - return; - } final toProcess = List.from(_uploadQueue); for (final rideId in toProcess) { await uploadRide(rideId); @@ -78,35 +80,82 @@ class GitHubSyncService extends ChangeNotifier { } Future uploadRide(int rideId, {bool isManual = false}) async { - final token = _auth.getToken(); - if (token == null || token.isEmpty) { - _state = GitHubUploadState.authRequired; - _statusMessage = 'GitHub authorization required. Google/Firebase sign-in is separate from GitHub repository access.'; - notifyListeners(); - return GitHubUploadResult( - success: false, - message: 'GitHub authorization required. Configure a GitHub Personal Access Token before uploading ride logs.', - ); + // IMPORTANT: Google/Firebase authentication is the normal MotorCloud cloud + // authentication. A GitHub PAT is optional and only needed for explicit + // GitHub repository export. + final githubToken = _auth.getToken(); + if (githubToken == null || githubToken.isEmpty) { + return _uploadToMotorCloud(rideId); } + return _uploadToGitHub(rideId, githubToken); + } + + Future _uploadToMotorCloud(int rideId) async { + _state = GitHubUploadState.uploading; + _statusMessage = 'Backing up Ride #$rideId to MotorCloud...'; + notifyListeners(); + + try { + final provider = FirebaseSyncProvider( + db: _db, + authService: FirebaseAuthService(), + ); + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + if (result.success) { + await _prefs.setBool('github_uploaded_ride_$rideId', true); + await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String()); + if (result.remoteId != null) { + await _prefs.setString('github_sha_ride_$rideId', result.remoteId!); + } + _uploadQueue.remove(rideId); + _state = GitHubUploadState.success; + _statusMessage = 'Ride #$rideId backed up to MotorCloud'; + notifyListeners(); + return GitHubUploadResult( + success: true, + message: _statusMessage, + commitSha: result.remoteId, + targetPath: 'MotorCloud', + ); + } + + if (result.requiresAuth) { + _state = GitHubUploadState.authRequired; + } else { + _state = GitHubUploadState.failed; + } + _statusMessage = result.errorMessage ?? 'MotorCloud backup failed'; + notifyListeners(); + return GitHubUploadResult(success: false, message: _statusMessage); + } catch (e) { + _state = GitHubUploadState.failed; + _statusMessage = 'MotorCloud backup failed: $e'; + notifyListeners(); + return GitHubUploadResult(success: false, message: _statusMessage); + } + } + + Future _uploadToGitHub(int rideId, String token) async { _state = GitHubUploadState.uploading; _statusMessage = 'Uploading Ride #$rideId to GitHub...'; notifyListeners(); try { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); - if (ride == null) { - _state = GitHubUploadState.failed; - _statusMessage = 'Ride #$rideId not found in local database'; - notifyListeners(); - return GitHubUploadResult(success: false, message: 'Ride record not found'); - } + if (ride == null) throw HttpException('Ride #$rideId not found in local database'); final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal()); final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session'; final repo = _auth.getRepository(); final branch = _auth.getBranch(); - final fieldTestExport = FieldTestExportService(_db); final exportService = ExportService(_db); final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId); @@ -122,8 +171,8 @@ class GitHubSyncService extends ChangeNotifier { ..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get(); final summaryRecord = await (_db.select(_db.fieldTestSummaries) ..where((s) => s.rideId.equals(rideId))).getSingleOrNull(); - final appIdentity = AppIdentity.current(); + final readme = StringBuffer() ..writeln('=====================================================') ..writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT') @@ -153,8 +202,6 @@ class GitHubSyncService extends ChangeNotifier { 'topSpeedMph': ride.topSpeedMph, 'maxLeanLeft': ride.maxLeanLeft, 'maxLeanRight': ride.maxLeanRight, - 'maxMovingLeanLeft': ride.maxLeanLeft, - 'maxMovingLeanRight': ride.maxLeanRight, 'maxAccelG': ride.maxAccelG, 'maxBrakingG': ride.maxBrakingG, 'averageSpeed': ride.averageSpeed, @@ -177,8 +224,6 @@ class GitHubSyncService extends ChangeNotifier { String? lastCommitSha; for (final entry in filesToUpload.entries) { final filePath = entry.key; - final fileContent = entry.value; - String? existingSha; final getRes = await _client.get( Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'), headers: { @@ -187,13 +232,12 @@ class GitHubSyncService extends ChangeNotifier { 'User-Agent': 'MotorSignal-Telemetry/1.0.2', }, ).timeout(const Duration(seconds: 10)); - if (getRes.statusCode == 200) { - existingSha = jsonDecode(getRes.body)['sha']; - } + String? existingSha; + if (getRes.statusCode == 200) existingSha = jsonDecode(getRes.body)['sha']; final payload = { 'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]', - 'content': base64.encode(utf8.encode(fileContent)), + 'content': base64.encode(utf8.encode(entry.value)), 'branch': branch, }; if (existingSha != null) payload['sha'] = existingSha; @@ -212,7 +256,7 @@ class GitHubSyncService extends ChangeNotifier { if (putRes.statusCode == 200 || putRes.statusCode == 201) { lastCommitSha = jsonDecode(putRes.body)['commit']?['sha']; } else if (putRes.statusCode == 401 || putRes.statusCode == 403) { - throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}). Reconnect or replace the GitHub token.'); + throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}).'); } else { throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})'); } @@ -225,13 +269,12 @@ class GitHubSyncService extends ChangeNotifier { _state = GitHubUploadState.success; _statusMessage = 'Ride #$rideId successfully uploaded to $repo ($branch)'; notifyListeners(); - return GitHubUploadResult(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName); } catch (e) { _state = GitHubUploadState.failed; _statusMessage = 'Upload failed for Ride #$rideId: $e'; notifyListeners(); - return GitHubUploadResult(success: false, message: 'Upload failed: $e'); + return GitHubUploadResult(success: false, message: _statusMessage); } } }