From cd5f575f9b9ca5be7d0c47e6187ce0cd697556dc Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 02:48:59 -0400 Subject: [PATCH] feat: add safe manual MotorCloud ride restore --- lib/features/settings/account_screen.dart | 561 +++++++++++++++--- .../sync/cloud_reconciliation_service.dart | 182 ++++-- .../sync/cloud_restore_controller.dart | 128 ++++ lib/services/sync/cloud_restore_service.dart | 167 ++++-- lib/services/sync/sync_providers.dart | 87 ++- test/cloud_restore_manual_test.dart | 434 ++++++++++++++ 6 files changed, 1346 insertions(+), 213 deletions(-) create mode 100644 lib/services/sync/cloud_restore_controller.dart create mode 100644 test/cloud_restore_manual_test.dart diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 9e89a62..22e31f9 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -5,6 +5,7 @@ import '../../database/database.dart'; import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_state.dart'; import '../../services/auth/auth_user.dart'; +import '../../services/sync/cloud_restore_controller.dart'; import '../../services/sync/sync_providers.dart'; class AccountScreen extends ConsumerStatefulWidget { @@ -39,9 +40,12 @@ class _AccountScreenState extends ConsumerState { final authState = ref.watch(authNotifierProvider); final themeMode = ref.watch(dashboardThemeModeProvider); final isEvolution = themeMode == DashboardThemeMode.evolution; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; - final bgColor = isEvolution ? Colors.black : Theme.of(context).scaffoldBackgroundColor; + final bgColor = isEvolution + ? Colors.black + : Theme.of(context).scaffoldBackgroundColor; final titleFont = isEvolution ? 'Sora' : null; return Scaffold( @@ -65,18 +69,38 @@ class _AccountScreenState extends ConsumerState { Widget content; if (authState.isAuthenticated) { - content = _buildSignedInView(context, authState.user!, isEvolution, isWide); + content = _buildSignedInView( + context, + authState.user!, + isEvolution, + isWide, + ); } else if (authState.isAnonymous) { - content = _buildAnonymousView(context, authState.user!, isEvolution, isWide); + content = _buildAnonymousView( + context, + authState.user!, + isEvolution, + isWide, + ); } else { - content = _buildSignedOutView(context, authState, isEvolution, isWide); + content = _buildSignedOutView( + context, + authState, + isEvolution, + isWide, + ); } return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), child: Center( child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: isWide ? 640 : double.infinity), + constraints: BoxConstraints( + maxWidth: isWide ? 640 : double.infinity, + ), child: content, ), ), @@ -114,7 +138,9 @@ class _AccountScreenState extends ConsumerState { Text( _formMode == _AuthFormMode.signIn ? 'SIGN IN TO MOTORCLOUD' - : (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'RESET PASSWORD'), + : (_formMode == _AuthFormMode.register + ? 'CREATE ACCOUNT' + : 'RESET PASSWORD'), style: TextStyle( fontFamily: isEvolution ? 'Sora' : null, fontSize: 13, @@ -142,16 +168,25 @@ class _AccountScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.redAccent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.redAccent.withValues(alpha: 0.6)), + border: Border.all( + color: Colors.redAccent.withValues(alpha: 0.6), + ), ), child: Row( children: [ - const Icon(Icons.error_outline, color: Colors.redAccent, size: 18), + const Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 18, + ), const SizedBox(width: 8), Expanded( child: Text( authState.errorMessage!, - style: const TextStyle(fontSize: 11, color: Colors.redAccent), + style: const TextStyle( + fontSize: 11, + color: Colors.redAccent, + ), ), ), ], @@ -165,16 +200,25 @@ class _AccountScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.greenAccent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.greenAccent.withValues(alpha: 0.6)), + border: Border.all( + color: Colors.greenAccent.withValues(alpha: 0.6), + ), ), child: Row( children: [ - const Icon(Icons.check_circle_outline, color: Colors.greenAccent, size: 18), + const Icon( + Icons.check_circle_outline, + color: Colors.greenAccent, + size: 18, + ), const SizedBox(width: 8), Expanded( child: Text( _statusMessage!, - style: const TextStyle(fontSize: 11, color: Colors.greenAccent), + style: const TextStyle( + fontSize: 11, + color: Colors.greenAccent, + ), ), ), ], @@ -188,14 +232,22 @@ class _AccountScreenState extends ConsumerState { backgroundColor: Colors.white, foregroundColor: Colors.black87, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon( + Icons.g_mobiledata, + size: 24, + color: Colors.redAccent, ), - icon: const Icon(Icons.g_mobiledata, size: 24, color: Colors.redAccent), label: const Text( 'Continue with Google', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold), ), - onPressed: authState.isLoading ? null : () => _handleGoogleSignIn(), + onPressed: authState.isLoading + ? null + : () => _handleGoogleSignIn(), ), const SizedBox(height: 16), const Row( @@ -203,7 +255,14 @@ class _AccountScreenState extends ConsumerState { Expanded(child: Divider(color: Colors.white24)), Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Text('OR', style: TextStyle(color: Colors.white38, fontSize: 10, fontWeight: FontWeight.bold)), + child: Text( + 'OR', + style: TextStyle( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), ), Expanded(child: Divider(color: Colors.white24)), ], @@ -219,10 +278,17 @@ class _AccountScreenState extends ConsumerState { controller: _displayNameController, decoration: InputDecoration( labelText: 'Display Name (Optional)', - prefixIcon: const Icon(Icons.person_outline, size: 18), + prefixIcon: const Icon( + Icons.person_outline, + size: 18, + ), filled: true, - fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + fillColor: isEvolution + ? const Color(0xFF141414) + : Colors.white10, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), isDense: true, ), style: const TextStyle(fontSize: 13), @@ -236,8 +302,12 @@ class _AccountScreenState extends ConsumerState { labelText: 'Email Address', prefixIcon: const Icon(Icons.email_outlined, size: 18), filled: true, - fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + fillColor: isEvolution + ? const Color(0xFF141414) + : Colors.white10, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), isDense: true, ), style: const TextStyle(fontSize: 13), @@ -261,15 +331,23 @@ class _AccountScreenState extends ConsumerState { prefixIcon: const Icon(Icons.lock_outline, size: 18), suffixIcon: IconButton( icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, size: 18, color: Colors.white54, ), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), ), filled: true, - fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + fillColor: isEvolution + ? const Color(0xFF141414) + : Colors.white10, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), isDense: true, ), style: const TextStyle(fontSize: 13), @@ -277,7 +355,8 @@ class _AccountScreenState extends ConsumerState { if (value == null || value.isEmpty) { return 'Please enter your password'; } - if (_formMode == _AuthFormMode.register && value.length < 6) { + if (_formMode == _AuthFormMode.register && + value.length < 6) { return 'Password must be at least 6 characters'; } return null; @@ -293,16 +372,30 @@ class _AccountScreenState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), onPressed: authState.isLoading ? null : () => _submitForm(), child: authState.isLoading - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black)) + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) : Text( _formMode == _AuthFormMode.signIn ? 'SIGN IN' - : (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'SEND RESET LINK'), - style: const TextStyle(fontWeight: FontWeight.w900, letterSpacing: 0.8), + : (_formMode == _AuthFormMode.register + ? 'CREATE ACCOUNT' + : 'SEND RESET LINK'), + style: const TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + ), ), ), const SizedBox(height: 12), @@ -315,14 +408,24 @@ class _AccountScreenState extends ConsumerState { _formMode = _AuthFormMode.forgotPassword; _statusMessage = null; }), - child: const Text('Forgot Password?', style: TextStyle(fontSize: 11, color: Colors.white70)), + child: const Text( + 'Forgot Password?', + style: TextStyle(fontSize: 11, color: Colors.white70), + ), ), TextButton( onPressed: () => setState(() { _formMode = _AuthFormMode.register; _statusMessage = null; }), - child: const Text('Create Account', style: TextStyle(fontSize: 11, color: Color(0xFF00E5FF), fontWeight: FontWeight.bold)), + child: const Text( + 'Create Account', + style: TextStyle( + fontSize: 11, + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + ), + ), ), ] else if (_formMode == _AuthFormMode.register) ...[ TextButton( @@ -330,7 +433,13 @@ class _AccountScreenState extends ConsumerState { _formMode = _AuthFormMode.signIn; _statusMessage = null; }), - child: const Text('Already have an account? Sign In', style: TextStyle(fontSize: 11, color: Color(0xFF00E5FF))), + child: const Text( + 'Already have an account? Sign In', + style: TextStyle( + fontSize: 11, + color: Color(0xFF00E5FF), + ), + ), ), ] else ...[ TextButton( @@ -338,7 +447,10 @@ class _AccountScreenState extends ConsumerState { _formMode = _AuthFormMode.signIn; _statusMessage = null; }), - child: const Text('Back to Sign In', style: TextStyle(fontSize: 11, color: Colors.white70)), + child: const Text( + 'Back to Sign In', + style: TextStyle(fontSize: 11, color: Colors.white70), + ), ), ], ], @@ -349,11 +461,20 @@ class _AccountScreenState extends ConsumerState { foregroundColor: Colors.white70, side: const BorderSide(color: Colors.white24), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.person_outline, size: 18), - label: const Text('Continue as Guest / Anonymous Rider', style: TextStyle(fontSize: 11)), - onPressed: authState.isLoading ? null : () => ref.read(authNotifierProvider.notifier).signInAnonymously(), + label: const Text( + 'Continue as Guest / Anonymous Rider', + style: TextStyle(fontSize: 11), + ), + onPressed: authState.isLoading + ? null + : () => ref + .read(authNotifierProvider.notifier) + .signInAnonymously(), ), ], ), @@ -380,10 +501,18 @@ class _AccountScreenState extends ConsumerState { children: [ CircleAvatar( radius: 28, - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2), - backgroundImage: user.photoUrl != null ? NetworkImage(user.photoUrl!) : null, + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.2), + backgroundImage: user.photoUrl != null + ? NetworkImage(user.photoUrl!) + : null, child: user.photoUrl == null - ? const Icon(Icons.person, color: Color(0xFF00E5FF), size: 32) + ? const Icon( + Icons.person, + color: Color(0xFF00E5FF), + size: 32, + ) : null, ), const SizedBox(width: 16), @@ -403,15 +532,26 @@ class _AccountScreenState extends ConsumerState { const SizedBox(height: 2), Text( user.email ?? 'No email associated', - style: const TextStyle(fontSize: 12, color: Colors.white70), + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), ), const SizedBox(height: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( - color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + color: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), - border: Border.all(color: const Color(0xFF00E5FF), width: 0.8), + border: Border.all( + color: const Color(0xFF00E5FF), + width: 0.8, + ), ), child: Text( 'AUTHENTICATED (${user.providerId ?? "cloud"})', @@ -431,11 +571,18 @@ class _AccountScreenState extends ConsumerState { ), const Divider(height: 32, color: Colors.white12), _buildInfoRow('User ID', user.uid, isMonospace: true), - if (user.providerId != null) _buildInfoRow('Provider', user.providerId!), + if (user.providerId != null) + _buildInfoRow('Provider', user.providerId!), if (user.createdAt != null) - _buildInfoRow('Account Created', user.createdAt!.toLocal().toString().split('.')[0]), + _buildInfoRow( + 'Account Created', + user.createdAt!.toLocal().toString().split('.')[0], + ), if (user.lastSignInTime != null) - _buildInfoRow('Last Sign-In', user.lastSignInTime!.toLocal().toString().split('.')[0]), + _buildInfoRow( + 'Last Sign-In', + user.lastSignInTime!.toLocal().toString().split('.')[0], + ), const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -444,18 +591,31 @@ class _AccountScreenState extends ConsumerState { foregroundColor: Colors.redAccent, side: const BorderSide(color: Colors.redAccent), padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.logout, size: 18), - label: const Text('SIGN OUT', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8)), - onPressed: () => ref.read(authNotifierProvider.notifier).signOut(), + label: const Text( + 'SIGN OUT', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + onPressed: () => + ref.read(authNotifierProvider.notifier).signOut(), ), ), ], ), ), const SizedBox(height: 16), - _buildCloudSyncSection(context: context, isEvolution: isEvolution, isAuthenticated: true), + _buildCloudSyncSection( + context: context, + isEvolution: isEvolution, + isAuthenticated: true, + ), const SizedBox(height: 16), _buildLocalRidingActiveCard(isEvolution), ], @@ -481,7 +641,11 @@ class _AccountScreenState extends ConsumerState { CircleAvatar( radius: 28, backgroundColor: Colors.amberAccent.withValues(alpha: 0.2), - child: const Icon(Icons.person_outline, color: Colors.amberAccent, size: 32), + child: const Icon( + Icons.person_outline, + color: Colors.amberAccent, + size: 32, + ), ), const SizedBox(width: 16), Expanded( @@ -499,11 +663,17 @@ class _AccountScreenState extends ConsumerState { ), const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.amberAccent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.amberAccent, width: 0.8), + border: Border.all( + color: Colors.amberAccent, + width: 0.8, + ), ), child: const Text( 'OFFLINE GUEST SESSION', @@ -526,7 +696,11 @@ class _AccountScreenState extends ConsumerState { const SizedBox(height: 16), const Text( 'You are currently using MotorSignal in local guest mode. All rides are stored locally in your SQLite database. You can link a full cloud account anytime.', - style: TextStyle(fontSize: 11, color: Colors.white70, height: 1.4), + style: TextStyle( + fontSize: 11, + color: Colors.white70, + height: 1.4, + ), ), const SizedBox(height: 20), SizedBox( @@ -536,18 +710,28 @@ class _AccountScreenState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.link, size: 18), - label: const Text('SIGN IN WITH FULL ACCOUNT', style: TextStyle(fontWeight: FontWeight.bold)), - onPressed: () => ref.read(authNotifierProvider.notifier).signOut(), + label: const Text( + 'SIGN IN WITH FULL ACCOUNT', + style: TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: () => + ref.read(authNotifierProvider.notifier).signOut(), ), ), ], ), ), const SizedBox(height: 16), - _buildCloudSyncSection(context: context, isEvolution: isEvolution, isAuthenticated: false), + _buildCloudSyncSection( + context: context, + isEvolution: isEvolution, + isAuthenticated: false, + ), const SizedBox(height: 16), _buildLocalRidingActiveCard(isEvolution), ], @@ -561,6 +745,8 @@ class _AccountScreenState extends ConsumerState { }) { final syncState = ref.watch(syncStateProvider); final syncManager = ref.watch(syncManagerProvider); + final restoreState = ref.watch(cloudRestoreControllerProvider); + final restoreController = ref.read(cloudRestoreControllerProvider.notifier); return _buildAuthCard( isEvolution: isEvolution, @@ -574,7 +760,9 @@ class _AccountScreenState extends ConsumerState { children: [ Icon( Icons.cloud_sync_outlined, - color: isEvolution ? const Color(0xFF00E5FF) : Colors.blueAccent, + color: isEvolution + ? const Color(0xFF00E5FF) + : Colors.blueAccent, size: 20, ), const SizedBox(width: 8), @@ -584,7 +772,9 @@ class _AccountScreenState extends ConsumerState { fontFamily: isEvolution ? 'Sora' : null, fontSize: 12, fontWeight: FontWeight.w800, - color: isEvolution ? const Color(0xFF00E5FF) : Colors.white, + color: isEvolution + ? const Color(0xFF00E5FF) + : Colors.white, letterSpacing: 0.5, ), ), @@ -596,7 +786,10 @@ class _AccountScreenState extends ConsumerState { SizedBox( width: 12, height: 12, - child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF)), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), ), SizedBox(width: 6), Text( @@ -611,7 +804,11 @@ class _AccountScreenState extends ConsumerState { if (!isAuthenticated) ...[ const Text( 'Sign in to back up your rides and access multi-device synchronization. All rides are safely stored on this device in your local Drift SQLite database.', - style: TextStyle(fontSize: 11, color: Colors.white70, height: 1.4), + style: TextStyle( + fontSize: 11, + color: Colors.white70, + height: 1.4, + ), ), ] else ...[ Container( @@ -624,29 +821,53 @@ class _AccountScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSyncStatItem('Synced', '${syncState.syncedCount}', Colors.greenAccent), - _buildSyncStatItem('Pending', '${syncState.pendingCount}', Colors.amberAccent), - _buildSyncStatItem('Failed', '${syncState.failedCount}', Colors.redAccent), + _buildSyncStatItem( + 'Synced', + '${syncState.syncedCount}', + Colors.greenAccent, + ), + _buildSyncStatItem( + 'Pending', + '${syncState.pendingCount}', + Colors.amberAccent, + ), + _buildSyncStatItem( + 'Failed', + '${syncState.failedCount}', + Colors.redAccent, + ), ], ), ), const SizedBox(height: 12), if (syncState.failedCount > 0) ...[ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: Colors.redAccent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.redAccent.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.redAccent.withValues(alpha: 0.4), + ), ), child: Row( children: [ - const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 16), + const Icon( + Icons.warning_amber_rounded, + color: Colors.redAccent, + size: 16, + ), const SizedBox(width: 8), Expanded( child: Text( '${syncState.failedCount} ride(s) need attention. Tap Retry to process.', - style: const TextStyle(fontSize: 11, color: Colors.white), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + ), ), ), TextButton( @@ -655,18 +876,29 @@ class _AccountScreenState extends ConsumerState { foregroundColor: Colors.redAccent, ), onPressed: () => syncManager.retryAllPending(), - child: const Text('RETRY', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)), + child: const Text( + 'RETRY', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), ), ], ), ), const SizedBox(height: 12), ], - _buildInfoRow('Cloud Provider', 'Firebase (Auth/Firestore) + Cloudflare R2'), + _buildInfoRow( + 'Cloud Provider', + 'Firebase (Auth/Firestore) + Cloudflare R2', + ), if (syncState.lastSuccessfulSync != null) _buildInfoRow( 'Last Successful Sync', - syncState.lastSuccessfulSync!.toLocal().toString().split('.')[0], + syncState.lastSuccessfulSync!.toLocal().toString().split( + '.', + )[0], ), if (syncState.lastError != null && syncState.failedCount > 0) _buildInfoRow('Last Sync Notice', syncState.lastError!), @@ -679,20 +911,31 @@ class _AccountScreenState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: syncState.isProcessing ? const SizedBox( width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), ) : const Icon(Icons.sync, size: 16), label: Text( syncState.isProcessing ? 'SYNCING...' : 'SYNC NOW', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 0.6), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), ), - onPressed: syncState.isProcessing ? null : () => syncManager.processQueue(), + onPressed: syncState.isProcessing + ? null + : () => syncManager.processQueue(), ), ), const SizedBox(width: 8), @@ -702,18 +945,111 @@ class _AccountScreenState extends ConsumerState { foregroundColor: Colors.white, side: const BorderSide(color: Colors.white24), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.backup_outlined, size: 16), label: const Text( 'BACK UP ALL', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 0.6), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), ), onPressed: () => _handleBackupAllHistoricalRides(), ), ), ], ), + const SizedBox(height: 12), + const Text( + 'Download your backed-up ride history and telemetry to this device.', + style: TextStyle( + fontSize: 11, + color: Colors.white70, + height: 1.35, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white24), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: restoreState.isRestoring + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.cloud_download_outlined, size: 16), + label: Text( + restoreState.isRestoring + ? 'RESTORING...' + : 'RESTORE FROM MOTORCLOUD', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), + ), + onPressed: (!isAuthenticated || restoreState.isRestoring) + ? null + : () => restoreController.restoreFromMotorCloud(), + ), + ), + if (restoreState.message != null) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: _restoreStatusColor( + restoreState.status, + ).withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _restoreStatusColor( + restoreState.status, + ).withValues(alpha: 0.5), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _restoreStatusIcon(restoreState.status), + size: 16, + color: _restoreStatusColor(restoreState.status), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + restoreState.message!, + style: const TextStyle( + fontSize: 11, + color: Colors.white, + height: 1.3, + ), + ), + ), + ], + ), + ), + ], ], ], ), @@ -759,7 +1095,9 @@ class _AccountScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Enqueued $enqueued local ride(s) for MotorCloud synchronization.'), + content: Text( + 'Enqueued $enqueued local ride(s) for MotorCloud synchronization.', + ), duration: const Duration(seconds: 3), ), ); @@ -767,6 +1105,38 @@ class _AccountScreenState extends ConsumerState { syncManager.processQueue(); } + Color _restoreStatusColor(CloudRestoreStatus status) { + switch (status) { + case CloudRestoreStatus.success: + return Colors.greenAccent; + case CloudRestoreStatus.partialFailure: + return Colors.amberAccent; + case CloudRestoreStatus.authRequired: + case CloudRestoreStatus.unavailable: + case CloudRestoreStatus.failure: + return Colors.redAccent; + case CloudRestoreStatus.idle: + case CloudRestoreStatus.restoring: + return const Color(0xFF00E5FF); + } + } + + IconData _restoreStatusIcon(CloudRestoreStatus status) { + switch (status) { + case CloudRestoreStatus.success: + return Icons.check_circle_outline; + case CloudRestoreStatus.partialFailure: + return Icons.warning_amber_rounded; + case CloudRestoreStatus.authRequired: + case CloudRestoreStatus.unavailable: + case CloudRestoreStatus.failure: + return Icons.error_outline; + case CloudRestoreStatus.idle: + case CloudRestoreStatus.restoring: + return Icons.cloud_sync_outlined; + } + } + Widget _buildOfflineNoticeCard(bool isEvolution, bool isFirebaseConfigured) { return Container( padding: const EdgeInsets.all(14), @@ -784,8 +1154,12 @@ class _AccountScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - isFirebaseConfigured ? Icons.cloud_done_outlined : Icons.offline_bolt_outlined, - color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent, + isFirebaseConfigured + ? Icons.cloud_done_outlined + : Icons.offline_bolt_outlined, + color: isFirebaseConfigured + ? const Color(0xFF00E5FF) + : Colors.amberAccent, size: 20, ), const SizedBox(width: 12), @@ -794,12 +1168,16 @@ class _AccountScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isFirebaseConfigured ? 'OFFLINE-FIRST ARCHITECTURE' : 'STANDALONE OFFLINE MODE', + isFirebaseConfigured + ? 'OFFLINE-FIRST ARCHITECTURE' + : 'STANDALONE OFFLINE MODE', style: TextStyle( fontFamily: isEvolution ? 'Sora' : null, fontSize: 11, fontWeight: FontWeight.w800, - color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent, + color: isFirebaseConfigured + ? const Color(0xFF00E5FF) + : Colors.amberAccent, letterSpacing: 0.6, ), ), @@ -808,7 +1186,11 @@ class _AccountScreenState extends ConsumerState { isFirebaseConfigured ? 'Signing in enables optional multi-device account sync. Offline riding, telemetry acquisition, local SQLite persistence, and forensic exports are always available without an account.' : 'MotorSignal is operating in local mode. All instruments, 20Hz sensor fusion, garage storage, and ride recordings remain 100% operational.', - style: const TextStyle(fontSize: 11, color: Colors.white70, height: 1.35), + style: const TextStyle( + fontSize: 11, + color: Colors.white70, + height: 1.35, + ), ), ], ), @@ -877,7 +1259,10 @@ class _AccountScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)), + Text( + label, + style: const TextStyle(fontSize: 11, color: Colors.white54), + ), Flexible( child: Text( value, diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart index 2089d2e..97571db 100644 --- a/lib/services/sync/cloud_reconciliation_service.dart +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -1,15 +1,64 @@ +// ignore_for_file: type_init_formals + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart' show Variable; +import 'package:firebase_core/firebase_core.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; +class CloudRideMetadataDocument { + final String id; + final Map data; + + const CloudRideMetadataDocument({required this.id, required this.data}); +} + +abstract class CloudRideMetadataStore { + Future> listRideDocuments(String uid); +} + +class FirestoreCloudRideMetadataStore implements CloudRideMetadataStore { + final FirebaseFirestore? _firestore; + + FirestoreCloudRideMetadataStore({FirebaseFirestore? firestore}) + : _firestore = firestore ?? _resolveFirestore(); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + @override + Future> listRideDocuments(String uid) async { + final firestore = _firestore; + if (firestore == null) { + throw StateError('Firebase services are unavailable or unconfigured.'); + } + final snapshot = await firestore + .collection('users') + .doc(uid) + .collection('rides') + .get(); + return snapshot.docs + .map( + (document) => + CloudRideMetadataDocument(id: document.id, data: document.data()), + ) + .toList(growable: false); + } +} + class CloudReconciliationResult { + final int discovered; final int added; final int unchanged; final int conflicts; final int skipped; const CloudReconciliationResult({ + this.discovered = 0, this.added = 0, this.unchanged = 0, this.conflicts = 0, @@ -18,12 +67,15 @@ class CloudReconciliationResult { CloudReconciliationResult operator +(CloudReconciliationResult other) { return CloudReconciliationResult( + discovered: discovered + other.discovered, added: added + other.added, unchanged: unchanged + other.unchanged, conflicts: conflicts + other.conflicts, skipped: skipped + other.skipped, ); } + + int get existing => unchanged + conflicts; } /// Reconciles cloud ride metadata into the local Drift database without ever @@ -32,13 +84,16 @@ class CloudReconciliationResult { class CloudReconciliationService { final AppDatabase _db; final AuthService _authService; - final FirebaseFirestore _firestore; + final CloudRideMetadataStore _metadataStore; CloudReconciliationService({ required AppDatabase this._db, required AuthService this._authService, FirebaseFirestore? firestore, - }) : _firestore = firestore ?? FirebaseFirestore.instance; + CloudRideMetadataStore? metadataStore, + }) : _metadataStore = + metadataStore ?? + FirestoreCloudRideMetadataStore(firestore: firestore); Future reconcileMetadata() async { final user = _authService.currentUser; @@ -46,27 +101,20 @@ class CloudReconciliationService { throw StateError('Authentication is required for cloud reconciliation.'); } - final snapshot = await _firestore - .collection('users') - .doc(user.uid) - .collection('rides') - .get(); + final documents = await _metadataStore.listRideDocuments(user.uid); - var result = const CloudReconciliationResult(); - for (final document in snapshot.docs) { + var result = CloudReconciliationResult(discovered: documents.length); + for (final document in documents) { result += await _reconcileDocument(document, user.uid); } return result; } Future _reconcileDocument( - DocumentSnapshot> document, + CloudRideMetadataDocument document, String ownerId, ) async { - final data = document.data(); - if (data == null) { - return const CloudReconciliationResult(skipped: 1); - } + final data = document.data; // Never trust a client-selected owner. Firestore rules are authoritative, // and this client-side guard prevents accidental cross-account materialization. @@ -80,11 +128,13 @@ class CloudReconciliationService { return const CloudReconciliationResult(skipped: 1); } - final localRows = await _db.customSelect( - 'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id ' - 'FROM rides WHERE cloud_ride_id = ?', - variables: [Variable(cloudRideId)], - ).get(); + final localRows = await _db + .customSelect( + 'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id ' + 'FROM rides WHERE cloud_ride_id = ?', + variables: [Variable(cloudRideId)], + ) + .get(); if (localRows.isNotEmpty) { final local = localRows.first.data; @@ -98,10 +148,12 @@ class CloudReconciliationService { final localRideId = _asInt(data['localRideId']); if (localRideId != null) { - final legacyRows = await _db.customSelect( - 'SELECT id, cloud_ride_id FROM rides WHERE id = ?', - variables: [Variable(localRideId)], - ).get(); + final legacyRows = await _db + .customSelect( + 'SELECT id, cloud_ride_id FROM rides WHERE id = ?', + variables: [Variable(localRideId)], + ) + .get(); if (legacyRows.isNotEmpty) { final existingCloudId = legacyRows.first.data['cloud_ride_id']; if (existingCloudId == null || existingCloudId.toString().isEmpty) { @@ -140,7 +192,8 @@ class CloudReconciliationService { required DateTime? endTime, required Map data, }) async { - await _db.customStatement(''' + await _db.customStatement( + ''' INSERT INTO rides ( cloud_ride_id, start_time, @@ -154,6 +207,8 @@ class CloudReconciliationService { distance, average_speed, top_speed, + moving_time, + stopped_time, elevation_gain, elevation_loss, motorcycle_id, @@ -161,31 +216,38 @@ class CloudReconciliationService { motorcycle_make, motorcycle_model, motorcycle_variant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', [ - cloudRideId, - startTime.toUtc().millisecondsSinceEpoch, - endTime?.toUtc().millisecondsSinceEpoch, - _asDouble(data['distanceMiles']), - _asDouble(data['topSpeedMph']), - _asDouble(data['maxLeanLeft']), - _asDouble(data['maxLeanRight']), - _asDouble(data['maxAccelG']), - _asDouble(data['maxBrakingG']), - _asDouble(data['distanceMiles']), - _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), - _asDouble(data['topSpeedMph']), - _asDouble(data['elevationGain']), - _asDouble(data['elevationLoss']), - _asInt(data['motorcycleId']), - _asInt(data['motorcycleYear']), - data['motorcycleMake']?.toString(), - data['motorcycleModel']?.toString(), - data['motorcycleVariant']?.toString(), - ]); + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + [ + cloudRideId, + startTime.toUtc().millisecondsSinceEpoch, + endTime?.toUtc().millisecondsSinceEpoch, + _asDouble(data['distanceMiles']), + _asDouble(data['topSpeedMph']), + _asDouble(data['maxLeanLeft']), + _asDouble(data['maxLeanRight']), + _asDouble(data['maxAccelG']), + _asDouble(data['maxBrakingG']), + _asDouble(data['distanceMiles']), + _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), + _asDouble(data['topSpeedMph']), + _asInt(data['movingTime']) ?? 0, + _asInt(data['stoppedTime']) ?? 0, + _asDouble(data['elevationGain']), + _asDouble(data['elevationLoss']), + _asInt(data['motorcycleId']), + _asInt(data['motorcycleYear']), + data['motorcycleMake']?.toString(), + data['motorcycleModel']?.toString(), + data['motorcycleVariant']?.toString(), + ], + ); } - bool _metadataDiffers(Map local, Map cloud) { + bool _metadataDiffers( + Map local, + Map cloud, + ) { final localDistance = _asDouble(local['distance_miles']); final cloudDistance = _asDouble(cloud['distanceMiles']); final localTopSpeed = _asDouble(local['top_speed_mph']); @@ -193,21 +255,35 @@ class CloudReconciliationService { // Only flag meaningful numeric divergence. Missing cloud fields are not // treated as authoritative and therefore cannot overwrite local values. - if (cloudDistance != null && localDistance != null && (localDistance - cloudDistance).abs() > 0.0001) return true; - if (cloudTopSpeed != null && localTopSpeed != null && (localTopSpeed - cloudTopSpeed).abs() > 0.0001) return true; + if (cloudDistance != null && + localDistance != null && + (localDistance - cloudDistance).abs() > 0.0001) { + return true; + } + if (cloudTopSpeed != null && + localTopSpeed != null && + (localTopSpeed - cloudTopSpeed).abs() > 0.0001) { + return true; + } return false; } - bool _isValidCloudRideId(String id) => RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id); + bool _isValidCloudRideId(String id) => + RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id); - int? _asInt(Object? value) => value is num ? value.toInt() : int.tryParse(value?.toString() ?? ''); + int? _asInt(Object? value) => + value is num ? value.toInt() : int.tryParse(value?.toString() ?? ''); - double? _asDouble(Object? value) => value is num ? value.toDouble() : double.tryParse(value?.toString() ?? ''); + double? _asDouble(Object? value) => value is num + ? value.toDouble() + : double.tryParse(value?.toString() ?? ''); DateTime? _readDateTime(Object? value) { if (value is Timestamp) return value.toDate().toUtc(); if (value is DateTime) return value.toUtc(); - if (value is num) return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true); + if (value is num) { + return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true); + } final parsed = DateTime.tryParse(value?.toString() ?? ''); return parsed?.toUtc(); } diff --git a/lib/services/sync/cloud_restore_controller.dart b/lib/services/sync/cloud_restore_controller.dart new file mode 100644 index 0000000..ee93918 --- /dev/null +++ b/lib/services/sync/cloud_restore_controller.dart @@ -0,0 +1,128 @@ +// ignore_for_file: prefer_initializing_formals + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_service.dart'; +import 'cloud_restore_service.dart'; + +enum CloudRestoreStatus { + idle, + restoring, + success, + partialFailure, + authRequired, + unavailable, + failure, +} + +class CloudRestoreUiState { + final CloudRestoreStatus status; + final CloudRestoreResult? result; + final String? message; + + const CloudRestoreUiState({ + this.status = CloudRestoreStatus.idle, + this.result, + this.message, + }); + + bool get isRestoring => status == CloudRestoreStatus.restoring; +} + +class CloudRestoreController extends StateNotifier { + final CloudRestoreService _restoreService; + final AuthService _authService; + + CloudRestoreController({ + required CloudRestoreService restoreService, + required AuthService authService, + }) : _restoreService = restoreService, + _authService = authService, + super(const CloudRestoreUiState()); + + Future restoreFromMotorCloud() async { + if (state.isRestoring) return state.result; + + final user = _authService.currentUser; + if (user == null || user.isAnonymous) { + state = const CloudRestoreUiState( + status: CloudRestoreStatus.authRequired, + message: 'Sign in with a MotorCloud account to restore ride history.', + ); + return null; + } + if (!_authService.isConfigured) { + state = const CloudRestoreUiState( + status: CloudRestoreStatus.unavailable, + message: + 'MotorCloud is unavailable. Try again when Firebase services are available.', + ); + return null; + } + + state = const CloudRestoreUiState(status: CloudRestoreStatus.restoring); + try { + final result = await _restoreService.restore(); + final hasFailures = result.failures > 0; + final hasRestoredOrMatched = + result.hasAnyRestoredData || + result.existingRidesMatched > 0 || + result.telemetryAlreadyPresent > 0 || + result.telemetrySkipped > 0; + final status = result.requiresAuthentication && !hasRestoredOrMatched + ? CloudRestoreStatus.authRequired + : (hasFailures && !hasRestoredOrMatched + ? CloudRestoreStatus.failure + : (hasFailures + ? CloudRestoreStatus.partialFailure + : CloudRestoreStatus.success)); + state = CloudRestoreUiState( + status: status, + result: result, + message: _formatResult(result), + ); + return result; + } on StateError catch (e) { + final message = e.message; + state = CloudRestoreUiState( + status: message.toLowerCase().contains('auth') + ? CloudRestoreStatus.authRequired + : CloudRestoreStatus.unavailable, + message: message, + ); + return null; + } catch (e) { + state = CloudRestoreUiState( + status: CloudRestoreStatus.failure, + message: 'MotorCloud restore failed: $e', + ); + return null; + } + } + + String _formatResult(CloudRestoreResult result) { + final parts = []; + if (result.metadataRowsAdded > 0) { + parts.add('${result.metadataRowsAdded} ride(s) added'); + } + if (result.telemetrySamplesRestored > 0) { + parts.add( + '${result.telemetrySamplesRestored} telemetry sample(s) restored', + ); + } + if (result.existingRidesMatched > 0 || result.telemetryAlreadyPresent > 0) { + final alreadyPresent = + result.existingRidesMatched > result.telemetryAlreadyPresent + ? result.existingRidesMatched + : result.telemetryAlreadyPresent; + parts.add('$alreadyPresent ride(s) already on this device'); + } + if (result.failures > 0) { + parts.add('${result.failures} item(s) need attention'); + } + if (parts.isEmpty) { + return 'No restorable MotorCloud rides were found for this account.'; + } + return 'MotorCloud restore complete: ${parts.join(', ')}.'; + } +} diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart index 2716aee..e595397 100644 --- a/lib/services/sync/cloud_restore_service.dart +++ b/lib/services/sync/cloud_restore_service.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:archive/archive.dart'; -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart' as drift; import 'cloud_reconciliation_service.dart'; @@ -12,18 +11,37 @@ import 'r2_download_service.dart'; class CloudRestoreResult { final CloudReconciliationResult metadata; - final int telemetryRestored; + final int telemetryRidesRestored; + final int telemetrySamplesRestored; final int telemetrySkipped; + final int telemetryAlreadyPresent; final int telemetryFailed; + final int telemetryAuthFailures; const CloudRestoreResult({ required this.metadata, - this.telemetryRestored = 0, + this.telemetryRidesRestored = 0, + this.telemetrySamplesRestored = 0, this.telemetrySkipped = 0, + this.telemetryAlreadyPresent = 0, this.telemetryFailed = 0, + this.telemetryAuthFailures = 0, }); - int get telemetryPending => telemetryFailed; + int get telemetryPending => telemetryFailed + telemetryAuthFailures; + int get cloudRidesDiscovered => metadata.discovered; + int get metadataRowsAdded => metadata.added; + int get existingRidesMatched => metadata.unchanged; + int get failures => + metadata.conflicts + + metadata.skipped + + telemetryFailed + + telemetryAuthFailures; + bool get requiresAuthentication => telemetryAuthFailures > 0; + bool get hasAnyRestoredData => + metadataRowsAdded > 0 || + telemetryRidesRestored > 0 || + telemetrySamplesRestored > 0; } /// Additive, resumable MotorCloud restore coordinator. @@ -36,68 +54,102 @@ class CloudRestoreService { final AuthService _authService; final CloudReconciliationService _metadata; final R2DownloadService _r2DownloadService; - final FirebaseFirestore _firestore; + final CloudRideMetadataStore _metadataStore; CloudRestoreService({ required AppDatabase db, required AuthService authService, CloudReconciliationService? metadata, R2DownloadService? r2DownloadService, - FirebaseFirestore? firestore, - }) : _db = db, - _authService = authService, - _metadata = metadata ?? CloudReconciliationService(db: db, authService: authService), - _r2DownloadService = r2DownloadService ?? R2DownloadService(), - _firestore = firestore ?? FirebaseFirestore.instance; + CloudRideMetadataStore? metadataStore, + }) : _db = db, + _authService = authService, + _metadata = + metadata ?? + CloudReconciliationService( + db: db, + authService: authService, + metadataStore: metadataStore, + ), + _r2DownloadService = r2DownloadService ?? R2DownloadService(), + _metadataStore = metadataStore ?? FirestoreCloudRideMetadataStore(); Future restore() async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { - throw StateError('Authentication is required to restore MotorCloud data.'); + throw StateError( + 'Authentication is required to restore MotorCloud data.', + ); } - final idToken = await _authService.getIdToken(); + var idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { - throw StateError('A valid Firebase authentication token is required to restore MotorCloud data.'); + idToken = await _authService.getIdToken(true); + } + if (idToken == null || idToken.isEmpty) { + throw StateError( + 'A valid Firebase authentication token is required to restore MotorCloud data.', + ); } final metadataResult = await _metadata.reconcileMetadata(); - final snapshot = await _firestore.collection('users').doc(user.uid).collection('rides').get(); + final documents = await _metadataStore.listRideDocuments(user.uid); - var telemetryRestored = 0; + var telemetryRidesRestored = 0; + var telemetrySamplesRestored = 0; var telemetrySkipped = 0; + var telemetryAlreadyPresent = 0; var telemetryFailed = 0; + var telemetryAuthFailures = 0; - for (final document in snapshot.docs) { - final data = document.data(); + for (final document in documents) { + final data = document.data; final cloudRideId = document.id; if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) { telemetrySkipped++; continue; } - final localRide = await (_db.select(_db.rides)..where((ride) => ride.cloudRideId.equals(cloudRideId))).getSingleOrNull(); + final localRide = + await (_db.select(_db.rides) + ..where((ride) => ride.cloudRideId.equals(cloudRideId))) + .getSingleOrNull(); if (localRide == null) { telemetryFailed++; continue; } - final telemetryCount = await (_db.select(_db.telemetrySamples)..where((sample) => sample.rideId.equals(localRide.id))).get().then((rows) => rows.length); + final telemetryCount = + await (_db.select(_db.telemetrySamples) + ..where((sample) => sample.rideId.equals(localRide.id))) + .get() + .then((rows) => rows.length); if (telemetryCount > 0) { - telemetrySkipped++; + telemetryAlreadyPresent++; continue; } - final result = await _r2DownloadService.downloadTelemetryBundle(idToken: idToken, cloudRideId: cloudRideId); + final result = await _r2DownloadService.downloadTelemetryBundle( + idToken: idToken, + cloudRideId: cloudRideId, + ); if (!result.success || result.zipBytes == null) { + if (result.requiresAuth) { + telemetryAuthFailures++; + continue; + } telemetryFailed++; continue; } try { - final restoredCount = await _restoreTelemetryCsv(localRide.id, result.zipBytes!); + final restoredCount = await _restoreTelemetryCsv( + localRide.id, + result.zipBytes!, + ); if (restoredCount > 0) { - telemetryRestored++; + telemetryRidesRestored++; + telemetrySamplesRestored += restoredCount; } else { telemetryFailed++; } @@ -108,16 +160,23 @@ class CloudRestoreService { return CloudRestoreResult( metadata: metadataResult, - telemetryRestored: telemetryRestored, + telemetryRidesRestored: telemetryRidesRestored, + telemetrySamplesRestored: telemetrySamplesRestored, telemetrySkipped: telemetrySkipped, + telemetryAlreadyPresent: telemetryAlreadyPresent, telemetryFailed: telemetryFailed, + telemetryAuthFailures: telemetryAuthFailures, ); } Future _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async { final archive = ZipDecoder().decodeBytes(zipBytes, verify: true); final file = archive.findFile('telemetry.csv'); - if (file == null) throw const FormatException('Telemetry bundle does not contain telemetry.csv.'); + if (file == null) { + throw const FormatException( + 'Telemetry bundle does not contain telemetry.csv.', + ); + } final csv = utf8.decode(file.content as List, allowMalformed: false); final lines = const LineSplitter().convert(csv); @@ -140,7 +199,8 @@ class CloudRestoreService { 'lateralG', ]; final header = lines.first.split(','); - if (header.length != expected.length || !_sameStringList(header, expected)) { + if (header.length != expected.length || + !_sameStringList(header, expected)) { throw const FormatException('Unsupported telemetry.csv schema.'); } @@ -148,28 +208,34 @@ class CloudRestoreService { for (final line in lines.skip(1)) { if (line.trim().isEmpty) continue; final values = line.split(','); - if (values.length != expected.length) throw const FormatException('Malformed telemetry.csv row.'); + if (values.length != expected.length) { + throw const FormatException('Malformed telemetry.csv row.'); + } final timestamp = DateTime.tryParse(values[0]); - if (timestamp == null) throw const FormatException('Telemetry row has an invalid timestamp.'); + if (timestamp == null) { + throw const FormatException('Telemetry row has an invalid timestamp.'); + } - companions.add(TelemetrySamplesCompanion.insert( - rideId: rideId, - timestamp: timestamp.toUtc(), - latitude: _nullableDouble(values[1]), - longitude: _nullableDouble(values[2]), - altitude: _nullableDouble(values[3]), - speed: _nullableDouble(values[4]), - heading: _nullableDouble(values[5]), - gpsAccuracy: _nullableDouble(values[6]), - leanAngle: _nullableDouble(values[7]), - pitchAngle: _nullableDouble(values[8]), - accelX: _nullableDouble(values[9]), - accelY: _nullableDouble(values[10]), - accelZ: _nullableDouble(values[11]), - longitudinalG: _nullableDouble(values[12]), - lateralG: _nullableDouble(values[13]), - )); + companions.add( + TelemetrySamplesCompanion.insert( + rideId: rideId, + timestamp: timestamp.toUtc(), + latitude: _nullableDouble(values[1]), + longitude: _nullableDouble(values[2]), + altitude: _nullableDouble(values[3]), + speed: _nullableDouble(values[4]), + heading: _nullableDouble(values[5]), + gpsAccuracy: _nullableDouble(values[6]), + leanAngle: _nullableDouble(values[7]), + pitchAngle: _nullableDouble(values[8]), + accelX: _nullableDouble(values[9]), + accelY: _nullableDouble(values[10]), + accelZ: _nullableDouble(values[11]), + longitudinalG: _nullableDouble(values[12]), + lateralG: _nullableDouble(values[13]), + ), + ); } if (companions.isEmpty) return 0; @@ -185,7 +251,11 @@ class CloudRestoreService { final trimmed = value.trim(); if (trimmed.isEmpty) return const drift.Value.absent(); final parsed = double.tryParse(trimmed); - if (parsed == null) throw const FormatException('Telemetry row contains an invalid numeric value.'); + if (parsed == null) { + throw const FormatException( + 'Telemetry row contains an invalid numeric value.', + ); + } return drift.Value(parsed); } @@ -197,5 +267,6 @@ class CloudRestoreService { return true; } - bool _isValidCloudRideId(String id) => RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id); + bool _isValidCloudRideId(String id) => + RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id); } diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index 958feee..28195de 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -9,13 +9,34 @@ import '../github/github_sync_service.dart'; import 'firebase_sync_provider.dart'; import 'github_sync_provider.dart'; import 'r2_upload_service.dart'; +import 'cloud_restore_controller.dart'; +import 'cloud_restore_service.dart'; import 'sync_bundle_worker.dart'; import 'sync_manager.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; -final r2UploadServiceProvider = Provider((ref) => R2UploadService()); -final syncBundleWorkerProvider = Provider((ref) => SyncBundleWorker(ref.watch(databaseProvider))); +final r2UploadServiceProvider = Provider( + (ref) => R2UploadService(), +); +final syncBundleWorkerProvider = Provider( + (ref) => SyncBundleWorker(ref.watch(databaseProvider)), +); + +final cloudRestoreServiceProvider = Provider( + (ref) => CloudRestoreService( + db: ref.watch(databaseProvider), + authService: ref.watch(authServiceProvider), + ), +); + +final cloudRestoreControllerProvider = + StateNotifierProvider( + (ref) => CloudRestoreController( + restoreService: ref.watch(cloudRestoreServiceProvider), + authService: ref.watch(authServiceProvider), + ), + ); /// Cloud sync state is account-scoped. The Firebase UID is intentionally part /// of the persisted namespace so signing out and into another account cannot @@ -27,27 +48,36 @@ final syncOutboxProvider = Provider((ref) { return SyncOutbox(prefs, storageNamespace: namespace); }); -final firebaseSyncProvider = Provider((ref) => FirebaseSyncProvider( - db: ref.watch(databaseProvider), - authService: ref.watch(authServiceProvider), - bundleWorker: ref.watch(syncBundleWorkerProvider), - r2UploadService: ref.watch(r2UploadServiceProvider), - )); +final firebaseSyncProvider = Provider( + (ref) => FirebaseSyncProvider( + db: ref.watch(databaseProvider), + authService: ref.watch(authServiceProvider), + bundleWorker: ref.watch(syncBundleWorkerProvider), + r2UploadService: ref.watch(r2UploadServiceProvider), + ), +); -final firebaseAccountSyncServiceProvider = Provider((ref) => FirebaseAccountSyncService( - db: ref.watch(databaseProvider), - auth: ref.watch(authServiceProvider), - )); +final firebaseAccountSyncServiceProvider = Provider( + (ref) => FirebaseAccountSyncService( + db: ref.watch(databaseProvider), + auth: ref.watch(authServiceProvider), + ), +); -final githubSyncProvider = Provider((ref) => GitHubSyncProvider( - service: ref.watch(githubSyncServiceProvider), - auth: ref.watch(githubAuthServiceProvider), - )); +final githubSyncProvider = Provider( + (ref) => GitHubSyncProvider( + service: ref.watch(githubSyncServiceProvider), + auth: ref.watch(githubAuthServiceProvider), + ), +); final syncManagerProvider = ChangeNotifierProvider((ref) { final manager = SyncManager( ref.watch(syncOutboxProvider), - initialProviders: [ref.watch(firebaseSyncProvider), ref.watch(githubSyncProvider)], + initialProviders: [ + ref.watch(firebaseSyncProvider), + ref.watch(githubSyncProvider), + ], defaultProviderId: 'firebase', ); final accountSync = ref.watch(firebaseAccountSyncServiceProvider); @@ -62,16 +92,25 @@ final syncManagerProvider = ChangeNotifierProvider((ref) { ref.listen(authNotifierProvider, (previous, next) { if (next.isAuthenticated && previous?.isAuthenticated != true) { manager.retryAuthRequired(); - accountSync.syncAccount().then((_) {}, onError: (_) { - // Profile/garage synchronization is downstream of riding and must - // never interrupt telemetry or ride persistence. - }); + accountSync.syncAccount().then( + (_) {}, + onError: (_) { + // Profile/garage synchronization is downstream of riding and must + // never interrupt telemetry or ride persistence. + }, + ); } }); ref.onDispose(manager.dispose); return manager; }); -final syncStateProvider = Provider((ref) => ref.watch(syncManagerProvider).currentState); -final pendingSyncCountProvider = Provider((ref) => ref.watch(syncStateProvider).pendingCount); -final isSyncProcessingProvider = Provider((ref) => ref.watch(syncStateProvider).isProcessing); +final syncStateProvider = Provider( + (ref) => ref.watch(syncManagerProvider).currentState, +); +final pendingSyncCountProvider = Provider( + (ref) => ref.watch(syncStateProvider).pendingCount, +); +final isSyncProcessingProvider = Provider( + (ref) => ref.watch(syncStateProvider).isProcessing, +); diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart new file mode 100644 index 0000000..a27ef54 --- /dev/null +++ b/test/cloud_restore_manual_test.dart @@ -0,0 +1,434 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/sync/cloud_reconciliation_service.dart'; +import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart'; +import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart'; +import 'package:motorcycle_dash/services/sync/r2_download_service.dart'; + +class _TestAuthService implements AuthService { + AuthUser? _user; + String? token = 'token'; + bool configured = true; + + _TestAuthService([this._user]); + + void setUser(AuthUser? user) => _user = user; + + @override + Stream get authStateChanges => Stream.value(currentState); + + @override + AuthState get currentState { + final user = _user; + if (user == null) { + return AuthState.unauthenticated(isFirebaseConfigured: configured); + } + return user.isAnonymous + ? AuthState.anonymous(user, isFirebaseConfigured: configured) + : AuthState.authenticated(user, isFirebaseConfigured: configured); + } + + @override + AuthUser? get currentUser => _user; + + @override + bool get isConfigured => configured; + + @override + Future getIdToken([bool forceRefresh = false]) async => token; + + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async { + _user = const AuthUser(uid: 'anon_1', isAnonymous: true); + } + + @override + Future signInWithEmailAndPassword(String email, String password) async { + _user = AuthUser(uid: 'user_a', email: email); + } + + @override + Future signInWithGoogle() async { + _user = const AuthUser(uid: 'user_google', email: 'rider@test.com'); + } + + @override + Future signOut() async { + _user = null; + } + + @override + void dispose() {} +} + +class _MemoryRideMetadataStore implements CloudRideMetadataStore { + final Map> byUid; + final List? documentsForEveryUser; + + _MemoryRideMetadataStore({ + Map>? byUid, + this.documentsForEveryUser, + }) : byUid = byUid ?? {}; + + @override + Future> listRideDocuments(String uid) async { + return documentsForEveryUser ?? byUid[uid] ?? const []; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Manual MotorCloud restore', () { + late AppDatabase db; + late _TestAuthService auth; + late int r2Requests; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + auth = _TestAuthService( + const AuthUser(uid: 'user_a', email: 'a@test.com'), + ); + r2Requests = 0; + }); + + tearDown(() async { + await db.close(); + }); + + CloudRestoreService service({ + required CloudRideMetadataStore store, + required http.Client client, + }) { + return CloudRestoreService( + db: db, + authService: auth, + metadataStore: store, + r2DownloadService: R2DownloadService( + client: client, + workerBaseUrl: 'https://worker.test', + ), + ); + } + + test( + 'clean install restores metadata, duration, telemetry, and is idempotent', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide( + id: 'ride_clean_install', + ownerId: 'user_a', + movingTime: 600, + stoppedTime: 45, + ), + ], + }, + ); + final zipBytes = _telemetryZip(samples: 2); + final client = _downloadClient(zipBytes); + final restore = service(store: store, client: client); + + final first = await restore.restore(); + final second = await restore.restore(); + + final rides = await db.select(db.rides).get(); + final samples = await db.select(db.telemetrySamples).get(); + + expect(first.cloudRidesDiscovered, 1); + expect(first.metadataRowsAdded, 1); + expect(first.telemetryRidesRestored, 1); + expect(first.telemetrySamplesRestored, 2); + expect(rides, hasLength(1)); + expect(rides.single.cloudRideId, 'ride_clean_install'); + expect(rides.single.movingTime, 600); + expect(rides.single.stoppedTime, 45); + expect(samples, hasLength(2)); + expect(second.metadataRowsAdded, 0); + expect(second.telemetrySamplesRestored, 0); + expect(second.telemetryAlreadyPresent, 1); + expect(await db.select(db.rides).get(), hasLength(1)); + expect(await db.select(db.telemetrySamples).get(), hasLength(2)); + }, + ); + + test( + 'signed out and anonymous users cannot mutate local restore data', + () async { + final store = _MemoryRideMetadataStore( + documentsForEveryUser: [ + _cloudRide(id: 'ride_auth_guard', ownerId: 'user_a'), + ], + ); + final restore = service( + store: store, + client: _downloadClient(_telemetryZip()), + ); + + auth.setUser(null); + await expectLater(restore.restore(), throwsA(isA())); + expect(await db.select(db.rides).get(), isEmpty); + + auth.setUser(const AuthUser(uid: 'anon_1', isAnonymous: true)); + await expectLater(restore.restore(), throwsA(isA())); + expect(await db.select(db.rides).get(), isEmpty); + }, + ); + + test( + 'missing R2 object keeps additive metadata but restores no telemetry', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [_cloudRide(id: 'ride_missing_r2', ownerId: 'user_a')], + }, + ); + final restore = service( + store: store, + client: MockClient((request) async { + r2Requests++; + return http.Response('missing', 404); + }), + ); + + final result = await restore.restore(); + + expect(result.metadataRowsAdded, 1); + expect(result.telemetryFailed, 1); + expect(r2Requests, 1); + expect(await db.select(db.rides).get(), hasLength(1)); + expect(await db.select(db.telemetrySamples).get(), isEmpty); + }, + ); + + test('SHA mismatch does not insert telemetry', () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [_cloudRide(id: 'ride_bad_sha', ownerId: 'user_a')], + }, + ); + final body = _telemetryZip(samples: 1); + final restore = service( + store: store, + client: MockClient( + (request) async => http.Response.bytes( + body, + 200, + headers: { + 'x-motorsignal-ride-id': request.url.queryParameters['rideId']!, + 'x-motorsignal-sha256': '0' * 64, + }, + ), + ), + ); + + final result = await restore.restore(); + + expect(result.metadataRowsAdded, 1); + expect(result.telemetryFailed, 1); + expect(await db.select(db.telemetrySamples).get(), isEmpty); + }); + + test('R2 authentication failures are reported separately', () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [_cloudRide(id: 'ride_auth_r2', ownerId: 'user_a')], + }, + ); + final restore = service( + store: store, + client: MockClient( + (request) async => http.Response('auth required', 401), + ), + ); + + final result = await restore.restore(); + + expect(result.metadataRowsAdded, 1); + expect(result.telemetryAuthFailures, 1); + expect(result.requiresAuthentication, isTrue); + expect(await db.select(db.telemetrySamples).get(), isEmpty); + }); + + test( + 'existing local telemetry is never overwritten or downloaded again', + () async { + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_existing_local'), + startTime: DateTime.utc(2026, 9, 3, 10), + ), + ); + await db + .into(db.telemetrySamples) + .insert( + TelemetrySamplesCompanion.insert( + rideId: rideId, + timestamp: DateTime.utc(2026, 9, 3, 10), + speed: const drift.Value(31), + ), + ); + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide(id: 'ride_existing_local', ownerId: 'user_a'), + ], + }, + ); + final restore = service( + store: store, + client: MockClient((request) async { + r2Requests++; + return http.Response.bytes(_telemetryZip(), 200); + }), + ); + + final result = await restore.restore(); + final samples = await db.select(db.telemetrySamples).get(); + + expect(result.telemetryAlreadyPresent, 1); + expect(r2Requests, 0); + expect(samples, hasLength(1)); + expect(samples.single.speed, 31); + }, + ); + + test( + 'wrong-owner cloud metadata is skipped for the current account', + () async { + final store = _MemoryRideMetadataStore( + documentsForEveryUser: [ + _cloudRide(id: 'ride_user_a_only', ownerId: 'user_a'), + ], + ); + auth.setUser(const AuthUser(uid: 'user_b', email: 'b@test.com')); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip()), + ).restore(); + + expect(result.cloudRidesDiscovered, 1); + expect(result.metadata.skipped, 1); + expect(await db.select(db.rides).get(), isEmpty); + expect(await db.select(db.telemetrySamples).get(), isEmpty); + }, + ); + + test( + 'controller reports authentication required without invoking restore', + () async { + auth.setUser(null); + final controller = CloudRestoreController( + authService: auth, + restoreService: service( + store: _MemoryRideMetadataStore( + documentsForEveryUser: [ + _cloudRide(id: 'ride_controller', ownerId: 'user_a'), + ], + ), + client: _downloadClient(_telemetryZip()), + ), + ); + + final result = await controller.restoreFromMotorCloud(); + + expect(result, isNull); + expect(controller.state.status, CloudRestoreStatus.authRequired); + expect(await db.select(db.rides).get(), isEmpty); + }, + ); + }); +} + +CloudRideMetadataDocument _cloudRide({ + required String id, + required String ownerId, + int movingTime = 120, + int stoppedTime = 10, +}) { + return CloudRideMetadataDocument( + id: id, + data: { + 'id': id, + 'ownerId': ownerId, + 'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(), + 'endTime': DateTime.utc(2026, 9, 3, 10, 12).toIso8601String(), + 'distanceMiles': 4.2, + 'topSpeedMph': 62.5, + 'maxLeanLeft': 12.0, + 'maxLeanRight': 14.0, + 'maxAccelG': 0.4, + 'maxBrakingG': -0.6, + 'averageSpeed': 35.0, + 'movingTime': movingTime, + 'stoppedTime': stoppedTime, + 'elevationGain': 50.0, + 'elevationLoss': 20.0, + 'motorcycleId': 7, + 'motorcycleYear': 2024, + 'motorcycleMake': 'Yamaha', + 'motorcycleModel': 'MT-09', + 'motorcycleVariant': 'SP', + 'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip', + }, + ); +} + +MockClient _downloadClient(List zipBytes) { + return MockClient((request) async { + final rideId = request.url.queryParameters['rideId']!; + return http.Response.bytes( + zipBytes, + 200, + headers: { + 'x-motorsignal-ride-id': rideId, + 'x-motorsignal-sha256': sha256.convert(zipBytes).toString(), + }, + ); + }); +} + +Uint8List _telemetryZip({int samples = 1}) { + final csv = StringBuffer() + ..writeln( + 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG', + ); + for (var i = 0; i < samples; i++) { + csv.writeln( + '2026-09-03T10:00:0${i}Z,37.$i,-122.$i,10,${20 + i},90,5,10,1,0.1,0.2,0.3,0.01,0.02', + ); + } + final bytes = utf8.encode(csv.toString()); + final archive = Archive() + ..addFile(ArchiveFile('telemetry.csv', bytes.length, bytes)); + return Uint8List.fromList( + ZipEncoder().encode(archive, modified: DateTime.utc(1980, 1, 1)), + ); +}