feat: add safe manual MotorCloud ride restore

This commit is contained in:
Developer 2026-09-03 02:48:59 -04:00
parent f080410005
commit cd5f575f9b
6 changed files with 1346 additions and 213 deletions

View File

@ -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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
_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<AccountScreen> {
_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<AccountScreen> {
_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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
),
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
),
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
}) {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
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,

View File

@ -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<String, dynamic> data;
const CloudRideMetadataDocument({required this.id, required this.data});
}
abstract class CloudRideMetadataStore {
Future<List<CloudRideMetadataDocument>> 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<List<CloudRideMetadataDocument>> 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<CloudReconciliationResult> 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<CloudReconciliationResult> _reconcileDocument(
DocumentSnapshot<Map<String, dynamic>> 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(
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<String>(cloudRideId)],
).get();
)
.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(
final legacyRows = await _db
.customSelect(
'SELECT id, cloud_ride_id FROM rides WHERE id = ?',
variables: [Variable<int>(localRideId)],
).get();
)
.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<String, dynamic> 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,8 +216,9 @@ class CloudReconciliationService {
motorcycle_make,
motorcycle_model,
motorcycle_variant
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', [
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
cloudRideId,
startTime.toUtc().millisecondsSinceEpoch,
endTime?.toUtc().millisecondsSinceEpoch,
@ -175,6 +231,8 @@ class CloudReconciliationService {
_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']),
@ -182,10 +240,14 @@ class CloudReconciliationService {
data['motorcycleMake']?.toString(),
data['motorcycleModel']?.toString(),
data['motorcycleVariant']?.toString(),
]);
],
);
}
bool _metadataDiffers(Map<String, dynamic> local, Map<String, dynamic> cloud) {
bool _metadataDiffers(
Map<String, dynamic> local,
Map<String, dynamic> 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();
}

View File

@ -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<CloudRestoreUiState> {
final CloudRestoreService _restoreService;
final AuthService _authService;
CloudRestoreController({
required CloudRestoreService restoreService,
required AuthService authService,
}) : _restoreService = restoreService,
_authService = authService,
super(const CloudRestoreUiState());
Future<CloudRestoreResult?> 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 = <String>[];
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(', ')}.';
}
}

View File

@ -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,
CloudRideMetadataStore? metadataStore,
}) : _db = db,
_authService = authService,
_metadata = metadata ?? CloudReconciliationService(db: db, authService: authService),
_metadata =
metadata ??
CloudReconciliationService(
db: db,
authService: authService,
metadataStore: metadataStore,
),
_r2DownloadService = r2DownloadService ?? R2DownloadService(),
_firestore = firestore ?? FirebaseFirestore.instance;
_metadataStore = metadataStore ?? FirestoreCloudRideMetadataStore();
Future<CloudRestoreResult> 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<int> _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<int>, 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,12 +208,17 @@ 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(
companions.add(
TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: timestamp.toUtc(),
latitude: _nullableDouble(values[1]),
@ -169,7 +234,8 @@ class CloudRestoreService {
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);
}

View File

@ -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<R2UploadService>((ref) => R2UploadService());
final syncBundleWorkerProvider = Provider<SyncBundleWorker>((ref) => SyncBundleWorker(ref.watch(databaseProvider)));
final r2UploadServiceProvider = Provider<R2UploadService>(
(ref) => R2UploadService(),
);
final syncBundleWorkerProvider = Provider<SyncBundleWorker>(
(ref) => SyncBundleWorker(ref.watch(databaseProvider)),
);
final cloudRestoreServiceProvider = Provider<CloudRestoreService>(
(ref) => CloudRestoreService(
db: ref.watch(databaseProvider),
authService: ref.watch(authServiceProvider),
),
);
final cloudRestoreControllerProvider =
StateNotifierProvider<CloudRestoreController, CloudRestoreUiState>(
(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<SyncOutbox>((ref) {
return SyncOutbox(prefs, storageNamespace: namespace);
});
final firebaseSyncProvider = Provider<FirebaseSyncProvider>((ref) => FirebaseSyncProvider(
final firebaseSyncProvider = Provider<FirebaseSyncProvider>(
(ref) => FirebaseSyncProvider(
db: ref.watch(databaseProvider),
authService: ref.watch(authServiceProvider),
bundleWorker: ref.watch(syncBundleWorkerProvider),
r2UploadService: ref.watch(r2UploadServiceProvider),
));
),
);
final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>((ref) => FirebaseAccountSyncService(
final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>(
(ref) => FirebaseAccountSyncService(
db: ref.watch(databaseProvider),
auth: ref.watch(authServiceProvider),
));
),
);
final githubSyncProvider = Provider<GitHubSyncProvider>((ref) => GitHubSyncProvider(
final githubSyncProvider = Provider<GitHubSyncProvider>(
(ref) => GitHubSyncProvider(
service: ref.watch(githubSyncServiceProvider),
auth: ref.watch(githubAuthServiceProvider),
));
),
);
final syncManagerProvider = ChangeNotifierProvider<SyncManager>((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<SyncManager>((ref) {
ref.listen<AuthState>(authNotifierProvider, (previous, next) {
if (next.isAuthenticated && previous?.isAuthenticated != true) {
manager.retryAuthRequired();
accountSync.syncAccount().then((_) {}, onError: (_) {
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<SyncManagerState>((ref) => ref.watch(syncManagerProvider).currentState);
final pendingSyncCountProvider = Provider<int>((ref) => ref.watch(syncStateProvider).pendingCount);
final isSyncProcessingProvider = Provider<bool>((ref) => ref.watch(syncStateProvider).isProcessing);
final syncStateProvider = Provider<SyncManagerState>(
(ref) => ref.watch(syncManagerProvider).currentState,
);
final pendingSyncCountProvider = Provider<int>(
(ref) => ref.watch(syncStateProvider).pendingCount,
);
final isSyncProcessingProvider = Provider<bool>(
(ref) => ref.watch(syncStateProvider).isProcessing,
);

View File

@ -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<AuthState> 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<String?> getIdToken([bool forceRefresh = false]) async => token;
@override
Future<void> createUserWithEmailAndPassword(
String email,
String password, {
String? displayName,
}) async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {
_user = const AuthUser(uid: 'anon_1', isAnonymous: true);
}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {
_user = AuthUser(uid: 'user_a', email: email);
}
@override
Future<void> signInWithGoogle() async {
_user = const AuthUser(uid: 'user_google', email: 'rider@test.com');
}
@override
Future<void> signOut() async {
_user = null;
}
@override
void dispose() {}
}
class _MemoryRideMetadataStore implements CloudRideMetadataStore {
final Map<String, List<CloudRideMetadataDocument>> byUid;
final List<CloudRideMetadataDocument>? documentsForEveryUser;
_MemoryRideMetadataStore({
Map<String, List<CloudRideMetadataDocument>>? byUid,
this.documentsForEveryUser,
}) : byUid = byUid ?? {};
@override
Future<List<CloudRideMetadataDocument>> 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<StateError>()));
expect(await db.select(db.rides).get(), isEmpty);
auth.setUser(const AuthUser(uid: 'anon_1', isAnonymous: true));
await expectLater(restore.restore(), throwsA(isA<StateError>()));
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<int> 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)),
);
}