feat: add safe manual MotorCloud ride restore
This commit is contained in:
parent
f080410005
commit
cd5f575f9b
|
|
@ -5,6 +5,7 @@ import '../../database/database.dart';
|
||||||
import '../../services/auth/auth_providers.dart';
|
import '../../services/auth/auth_providers.dart';
|
||||||
import '../../services/auth/auth_state.dart';
|
import '../../services/auth/auth_state.dart';
|
||||||
import '../../services/auth/auth_user.dart';
|
import '../../services/auth/auth_user.dart';
|
||||||
|
import '../../services/sync/cloud_restore_controller.dart';
|
||||||
import '../../services/sync/sync_providers.dart';
|
import '../../services/sync/sync_providers.dart';
|
||||||
|
|
||||||
class AccountScreen extends ConsumerStatefulWidget {
|
class AccountScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -39,9 +40,12 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
final authState = ref.watch(authNotifierProvider);
|
final authState = ref.watch(authNotifierProvider);
|
||||||
final themeMode = ref.watch(dashboardThemeModeProvider);
|
final themeMode = ref.watch(dashboardThemeModeProvider);
|
||||||
final isEvolution = themeMode == DashboardThemeMode.evolution;
|
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;
|
final titleFont = isEvolution ? 'Sora' : null;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -65,18 +69,38 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
|
|
||||||
Widget content;
|
Widget content;
|
||||||
if (authState.isAuthenticated) {
|
if (authState.isAuthenticated) {
|
||||||
content = _buildSignedInView(context, authState.user!, isEvolution, isWide);
|
content = _buildSignedInView(
|
||||||
|
context,
|
||||||
|
authState.user!,
|
||||||
|
isEvolution,
|
||||||
|
isWide,
|
||||||
|
);
|
||||||
} else if (authState.isAnonymous) {
|
} else if (authState.isAnonymous) {
|
||||||
content = _buildAnonymousView(context, authState.user!, isEvolution, isWide);
|
content = _buildAnonymousView(
|
||||||
|
context,
|
||||||
|
authState.user!,
|
||||||
|
isEvolution,
|
||||||
|
isWide,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
content = _buildSignedOutView(context, authState, isEvolution, isWide);
|
content = _buildSignedOutView(
|
||||||
|
context,
|
||||||
|
authState,
|
||||||
|
isEvolution,
|
||||||
|
isWide,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
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: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: isWide ? 640 : double.infinity),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: isWide ? 640 : double.infinity,
|
||||||
|
),
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -114,7 +138,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
Text(
|
Text(
|
||||||
_formMode == _AuthFormMode.signIn
|
_formMode == _AuthFormMode.signIn
|
||||||
? 'SIGN IN TO MOTORCLOUD'
|
? 'SIGN IN TO MOTORCLOUD'
|
||||||
: (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'RESET PASSWORD'),
|
: (_formMode == _AuthFormMode.register
|
||||||
|
? 'CREATE ACCOUNT'
|
||||||
|
: 'RESET PASSWORD'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: isEvolution ? 'Sora' : null,
|
fontFamily: isEvolution ? 'Sora' : null,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -142,16 +168,25 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.redAccent.withValues(alpha: 0.12),
|
color: Colors.redAccent.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(8),
|
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(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
authState.errorMessage!,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.greenAccent.withValues(alpha: 0.12),
|
color: Colors.greenAccent.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(8),
|
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(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_statusMessage!,
|
_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,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: Colors.black87,
|
foregroundColor: Colors.black87,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
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(
|
label: const Text(
|
||||||
'Continue with Google',
|
'Continue with Google',
|
||||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
onPressed: authState.isLoading ? null : () => _handleGoogleSignIn(),
|
onPressed: authState.isLoading
|
||||||
|
? null
|
||||||
|
: () => _handleGoogleSignIn(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Row(
|
const Row(
|
||||||
|
|
@ -203,7 +255,14 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
Expanded(child: Divider(color: Colors.white24)),
|
Expanded(child: Divider(color: Colors.white24)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
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)),
|
Expanded(child: Divider(color: Colors.white24)),
|
||||||
],
|
],
|
||||||
|
|
@ -219,10 +278,17 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
controller: _displayNameController,
|
controller: _displayNameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Display Name (Optional)',
|
labelText: 'Display Name (Optional)',
|
||||||
prefixIcon: const Icon(Icons.person_outline, size: 18),
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10,
|
fillColor: isEvolution
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
? const Color(0xFF141414)
|
||||||
|
: Colors.white10,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
|
|
@ -236,8 +302,12 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
labelText: 'Email Address',
|
labelText: 'Email Address',
|
||||||
prefixIcon: const Icon(Icons.email_outlined, size: 18),
|
prefixIcon: const Icon(Icons.email_outlined, size: 18),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10,
|
fillColor: isEvolution
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
? const Color(0xFF141414)
|
||||||
|
: Colors.white10,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
|
|
@ -261,15 +331,23 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
prefixIcon: const Icon(Icons.lock_outline, size: 18),
|
prefixIcon: const Icon(Icons.lock_outline, size: 18),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
_obscurePassword
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Colors.white54,
|
color: Colors.white54,
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
onPressed: () => setState(
|
||||||
|
() => _obscurePassword = !_obscurePassword,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10,
|
fillColor: isEvolution
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
? const Color(0xFF141414)
|
||||||
|
: Colors.white10,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
|
|
@ -277,7 +355,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Please enter your password';
|
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 'Password must be at least 6 characters';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -293,16 +372,30 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
backgroundColor: const Color(0xFF00E5FF),
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: authState.isLoading ? null : () => _submitForm(),
|
onPressed: authState.isLoading ? null : () => _submitForm(),
|
||||||
child: authState.isLoading
|
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(
|
: Text(
|
||||||
_formMode == _AuthFormMode.signIn
|
_formMode == _AuthFormMode.signIn
|
||||||
? 'SIGN IN'
|
? 'SIGN IN'
|
||||||
: (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'SEND RESET LINK'),
|
: (_formMode == _AuthFormMode.register
|
||||||
style: const TextStyle(fontWeight: FontWeight.w900, letterSpacing: 0.8),
|
? 'CREATE ACCOUNT'
|
||||||
|
: 'SEND RESET LINK'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -315,14 +408,24 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
_formMode = _AuthFormMode.forgotPassword;
|
_formMode = _AuthFormMode.forgotPassword;
|
||||||
_statusMessage = null;
|
_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(
|
TextButton(
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_formMode = _AuthFormMode.register;
|
_formMode = _AuthFormMode.register;
|
||||||
_statusMessage = null;
|
_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) ...[
|
] else if (_formMode == _AuthFormMode.register) ...[
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -330,7 +433,13 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
_formMode = _AuthFormMode.signIn;
|
_formMode = _AuthFormMode.signIn;
|
||||||
_statusMessage = null;
|
_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 ...[
|
] else ...[
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -338,7 +447,10 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
_formMode = _AuthFormMode.signIn;
|
_formMode = _AuthFormMode.signIn;
|
||||||
_statusMessage = null;
|
_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,
|
foregroundColor: Colors.white70,
|
||||||
side: const BorderSide(color: Colors.white24),
|
side: const BorderSide(color: Colors.white24),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
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),
|
icon: const Icon(Icons.person_outline, size: 18),
|
||||||
label: const Text('Continue as Guest / Anonymous Rider', style: TextStyle(fontSize: 11)),
|
label: const Text(
|
||||||
onPressed: authState.isLoading ? null : () => ref.read(authNotifierProvider.notifier).signInAnonymously(),
|
'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: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 28,
|
radius: 28,
|
||||||
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2),
|
backgroundColor: const Color(
|
||||||
backgroundImage: user.photoUrl != null ? NetworkImage(user.photoUrl!) : null,
|
0xFF00E5FF,
|
||||||
|
).withValues(alpha: 0.2),
|
||||||
|
backgroundImage: user.photoUrl != null
|
||||||
|
? NetworkImage(user.photoUrl!)
|
||||||
|
: null,
|
||||||
child: 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,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
@ -403,15 +532,26 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
user.email ?? 'No email associated',
|
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),
|
const SizedBox(height: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
color: const Color(
|
||||||
|
0xFF00E5FF,
|
||||||
|
).withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(4),
|
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(
|
child: Text(
|
||||||
'AUTHENTICATED (${user.providerId ?? "cloud"})',
|
'AUTHENTICATED (${user.providerId ?? "cloud"})',
|
||||||
|
|
@ -431,11 +571,18 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
),
|
),
|
||||||
const Divider(height: 32, color: Colors.white12),
|
const Divider(height: 32, color: Colors.white12),
|
||||||
_buildInfoRow('User ID', user.uid, isMonospace: true),
|
_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)
|
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)
|
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),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -444,18 +591,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
foregroundColor: Colors.redAccent,
|
foregroundColor: Colors.redAccent,
|
||||||
side: const BorderSide(color: Colors.redAccent),
|
side: const BorderSide(color: Colors.redAccent),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
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),
|
icon: const Icon(Icons.logout, size: 18),
|
||||||
label: const Text('SIGN OUT', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8)),
|
label: const Text(
|
||||||
onPressed: () => ref.read(authNotifierProvider.notifier).signOut(),
|
'SIGN OUT',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(authNotifierProvider.notifier).signOut(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildCloudSyncSection(context: context, isEvolution: isEvolution, isAuthenticated: true),
|
_buildCloudSyncSection(
|
||||||
|
context: context,
|
||||||
|
isEvolution: isEvolution,
|
||||||
|
isAuthenticated: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildLocalRidingActiveCard(isEvolution),
|
_buildLocalRidingActiveCard(isEvolution),
|
||||||
],
|
],
|
||||||
|
|
@ -481,7 +641,11 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 28,
|
radius: 28,
|
||||||
backgroundColor: Colors.amberAccent.withValues(alpha: 0.2),
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -499,11 +663,17 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.amberAccent.withValues(alpha: 0.15),
|
color: Colors.amberAccent.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(4),
|
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(
|
child: const Text(
|
||||||
'OFFLINE GUEST SESSION',
|
'OFFLINE GUEST SESSION',
|
||||||
|
|
@ -526,7 +696,11 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
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.',
|
'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),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
@ -536,18 +710,28 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
backgroundColor: const Color(0xFF00E5FF),
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
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),
|
icon: const Icon(Icons.link, size: 18),
|
||||||
label: const Text('SIGN IN WITH FULL ACCOUNT', style: TextStyle(fontWeight: FontWeight.bold)),
|
label: const Text(
|
||||||
onPressed: () => ref.read(authNotifierProvider.notifier).signOut(),
|
'SIGN IN WITH FULL ACCOUNT',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(authNotifierProvider.notifier).signOut(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildCloudSyncSection(context: context, isEvolution: isEvolution, isAuthenticated: false),
|
_buildCloudSyncSection(
|
||||||
|
context: context,
|
||||||
|
isEvolution: isEvolution,
|
||||||
|
isAuthenticated: false,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildLocalRidingActiveCard(isEvolution),
|
_buildLocalRidingActiveCard(isEvolution),
|
||||||
],
|
],
|
||||||
|
|
@ -561,6 +745,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
}) {
|
}) {
|
||||||
final syncState = ref.watch(syncStateProvider);
|
final syncState = ref.watch(syncStateProvider);
|
||||||
final syncManager = ref.watch(syncManagerProvider);
|
final syncManager = ref.watch(syncManagerProvider);
|
||||||
|
final restoreState = ref.watch(cloudRestoreControllerProvider);
|
||||||
|
final restoreController = ref.read(cloudRestoreControllerProvider.notifier);
|
||||||
|
|
||||||
return _buildAuthCard(
|
return _buildAuthCard(
|
||||||
isEvolution: isEvolution,
|
isEvolution: isEvolution,
|
||||||
|
|
@ -574,7 +760,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.cloud_sync_outlined,
|
Icons.cloud_sync_outlined,
|
||||||
color: isEvolution ? const Color(0xFF00E5FF) : Colors.blueAccent,
|
color: isEvolution
|
||||||
|
? const Color(0xFF00E5FF)
|
||||||
|
: Colors.blueAccent,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -584,7 +772,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
fontFamily: isEvolution ? 'Sora' : null,
|
fontFamily: isEvolution ? 'Sora' : null,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: isEvolution ? const Color(0xFF00E5FF) : Colors.white,
|
color: isEvolution
|
||||||
|
? const Color(0xFF00E5FF)
|
||||||
|
: Colors.white,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -596,7 +786,10 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF)),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -611,7 +804,11 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
if (!isAuthenticated) ...[
|
if (!isAuthenticated) ...[
|
||||||
const Text(
|
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.',
|
'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 ...[
|
] else ...[
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -624,29 +821,53 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_buildSyncStatItem('Synced', '${syncState.syncedCount}', Colors.greenAccent),
|
_buildSyncStatItem(
|
||||||
_buildSyncStatItem('Pending', '${syncState.pendingCount}', Colors.amberAccent),
|
'Synced',
|
||||||
_buildSyncStatItem('Failed', '${syncState.failedCount}', Colors.redAccent),
|
'${syncState.syncedCount}',
|
||||||
|
Colors.greenAccent,
|
||||||
|
),
|
||||||
|
_buildSyncStatItem(
|
||||||
|
'Pending',
|
||||||
|
'${syncState.pendingCount}',
|
||||||
|
Colors.amberAccent,
|
||||||
|
),
|
||||||
|
_buildSyncStatItem(
|
||||||
|
'Failed',
|
||||||
|
'${syncState.failedCount}',
|
||||||
|
Colors.redAccent,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (syncState.failedCount > 0) ...[
|
if (syncState.failedCount > 0) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.redAccent.withValues(alpha: 0.15),
|
color: Colors.redAccent.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(6),
|
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(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${syncState.failedCount} ride(s) need attention. Tap Retry to process.',
|
'${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(
|
TextButton(
|
||||||
|
|
@ -655,18 +876,29 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
foregroundColor: Colors.redAccent,
|
foregroundColor: Colors.redAccent,
|
||||||
),
|
),
|
||||||
onPressed: () => syncManager.retryAllPending(),
|
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),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
_buildInfoRow('Cloud Provider', 'Firebase (Auth/Firestore) + Cloudflare R2'),
|
_buildInfoRow(
|
||||||
|
'Cloud Provider',
|
||||||
|
'Firebase (Auth/Firestore) + Cloudflare R2',
|
||||||
|
),
|
||||||
if (syncState.lastSuccessfulSync != null)
|
if (syncState.lastSuccessfulSync != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
'Last Successful Sync',
|
'Last Successful Sync',
|
||||||
syncState.lastSuccessfulSync!.toLocal().toString().split('.')[0],
|
syncState.lastSuccessfulSync!.toLocal().toString().split(
|
||||||
|
'.',
|
||||||
|
)[0],
|
||||||
),
|
),
|
||||||
if (syncState.lastError != null && syncState.failedCount > 0)
|
if (syncState.lastError != null && syncState.failedCount > 0)
|
||||||
_buildInfoRow('Last Sync Notice', syncState.lastError!),
|
_buildInfoRow('Last Sync Notice', syncState.lastError!),
|
||||||
|
|
@ -679,20 +911,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
backgroundColor: const Color(0xFF00E5FF),
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
icon: syncState.isProcessing
|
icon: syncState.isProcessing
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.sync, size: 16),
|
: const Icon(Icons.sync, size: 16),
|
||||||
label: Text(
|
label: Text(
|
||||||
syncState.isProcessing ? 'SYNCING...' : 'SYNC NOW',
|
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),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -702,18 +945,111 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
side: const BorderSide(color: Colors.white24),
|
side: const BorderSide(color: Colors.white24),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
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),
|
icon: const Icon(Icons.backup_outlined, size: 16),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'BACK UP ALL',
|
'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(),
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -767,6 +1105,38 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
syncManager.processQueue();
|
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) {
|
Widget _buildOfflineNoticeCard(bool isEvolution, bool isFirebaseConfigured) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
|
|
@ -784,8 +1154,12 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isFirebaseConfigured ? Icons.cloud_done_outlined : Icons.offline_bolt_outlined,
|
isFirebaseConfigured
|
||||||
color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent,
|
? Icons.cloud_done_outlined
|
||||||
|
: Icons.offline_bolt_outlined,
|
||||||
|
color: isFirebaseConfigured
|
||||||
|
? const Color(0xFF00E5FF)
|
||||||
|
: Colors.amberAccent,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -794,12 +1168,16 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
isFirebaseConfigured ? 'OFFLINE-FIRST ARCHITECTURE' : 'STANDALONE OFFLINE MODE',
|
isFirebaseConfigured
|
||||||
|
? 'OFFLINE-FIRST ARCHITECTURE'
|
||||||
|
: 'STANDALONE OFFLINE MODE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: isEvolution ? 'Sora' : null,
|
fontFamily: isEvolution ? 'Sora' : null,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent,
|
color: isFirebaseConfigured
|
||||||
|
? const Color(0xFF00E5FF)
|
||||||
|
: Colors.amberAccent,
|
||||||
letterSpacing: 0.6,
|
letterSpacing: 0.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -808,7 +1186,11 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
isFirebaseConfigured
|
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.'
|
? '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.',
|
: '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(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.white54),
|
||||||
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,64 @@
|
||||||
|
// ignore_for_file: type_init_formals
|
||||||
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:drift/drift.dart' show Variable;
|
import 'package:drift/drift.dart' show Variable;
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../auth/auth_service.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 {
|
class CloudReconciliationResult {
|
||||||
|
final int discovered;
|
||||||
final int added;
|
final int added;
|
||||||
final int unchanged;
|
final int unchanged;
|
||||||
final int conflicts;
|
final int conflicts;
|
||||||
final int skipped;
|
final int skipped;
|
||||||
|
|
||||||
const CloudReconciliationResult({
|
const CloudReconciliationResult({
|
||||||
|
this.discovered = 0,
|
||||||
this.added = 0,
|
this.added = 0,
|
||||||
this.unchanged = 0,
|
this.unchanged = 0,
|
||||||
this.conflicts = 0,
|
this.conflicts = 0,
|
||||||
|
|
@ -18,12 +67,15 @@ class CloudReconciliationResult {
|
||||||
|
|
||||||
CloudReconciliationResult operator +(CloudReconciliationResult other) {
|
CloudReconciliationResult operator +(CloudReconciliationResult other) {
|
||||||
return CloudReconciliationResult(
|
return CloudReconciliationResult(
|
||||||
|
discovered: discovered + other.discovered,
|
||||||
added: added + other.added,
|
added: added + other.added,
|
||||||
unchanged: unchanged + other.unchanged,
|
unchanged: unchanged + other.unchanged,
|
||||||
conflicts: conflicts + other.conflicts,
|
conflicts: conflicts + other.conflicts,
|
||||||
skipped: skipped + other.skipped,
|
skipped: skipped + other.skipped,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int get existing => unchanged + conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconciles cloud ride metadata into the local Drift database without ever
|
/// Reconciles cloud ride metadata into the local Drift database without ever
|
||||||
|
|
@ -32,13 +84,16 @@ class CloudReconciliationResult {
|
||||||
class CloudReconciliationService {
|
class CloudReconciliationService {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AuthService _authService;
|
final AuthService _authService;
|
||||||
final FirebaseFirestore _firestore;
|
final CloudRideMetadataStore _metadataStore;
|
||||||
|
|
||||||
CloudReconciliationService({
|
CloudReconciliationService({
|
||||||
required AppDatabase this._db,
|
required AppDatabase this._db,
|
||||||
required AuthService this._authService,
|
required AuthService this._authService,
|
||||||
FirebaseFirestore? firestore,
|
FirebaseFirestore? firestore,
|
||||||
}) : _firestore = firestore ?? FirebaseFirestore.instance;
|
CloudRideMetadataStore? metadataStore,
|
||||||
|
}) : _metadataStore =
|
||||||
|
metadataStore ??
|
||||||
|
FirestoreCloudRideMetadataStore(firestore: firestore);
|
||||||
|
|
||||||
Future<CloudReconciliationResult> reconcileMetadata() async {
|
Future<CloudReconciliationResult> reconcileMetadata() async {
|
||||||
final user = _authService.currentUser;
|
final user = _authService.currentUser;
|
||||||
|
|
@ -46,27 +101,20 @@ class CloudReconciliationService {
|
||||||
throw StateError('Authentication is required for cloud reconciliation.');
|
throw StateError('Authentication is required for cloud reconciliation.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final snapshot = await _firestore
|
final documents = await _metadataStore.listRideDocuments(user.uid);
|
||||||
.collection('users')
|
|
||||||
.doc(user.uid)
|
|
||||||
.collection('rides')
|
|
||||||
.get();
|
|
||||||
|
|
||||||
var result = const CloudReconciliationResult();
|
var result = CloudReconciliationResult(discovered: documents.length);
|
||||||
for (final document in snapshot.docs) {
|
for (final document in documents) {
|
||||||
result += await _reconcileDocument(document, user.uid);
|
result += await _reconcileDocument(document, user.uid);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CloudReconciliationResult> _reconcileDocument(
|
Future<CloudReconciliationResult> _reconcileDocument(
|
||||||
DocumentSnapshot<Map<String, dynamic>> document,
|
CloudRideMetadataDocument document,
|
||||||
String ownerId,
|
String ownerId,
|
||||||
) async {
|
) async {
|
||||||
final data = document.data();
|
final data = document.data;
|
||||||
if (data == null) {
|
|
||||||
return const CloudReconciliationResult(skipped: 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never trust a client-selected owner. Firestore rules are authoritative,
|
// Never trust a client-selected owner. Firestore rules are authoritative,
|
||||||
// and this client-side guard prevents accidental cross-account materialization.
|
// and this client-side guard prevents accidental cross-account materialization.
|
||||||
|
|
@ -80,11 +128,13 @@ class CloudReconciliationService {
|
||||||
return const CloudReconciliationResult(skipped: 1);
|
return const CloudReconciliationResult(skipped: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
final localRows = await _db.customSelect(
|
final localRows = await _db
|
||||||
'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id '
|
.customSelect(
|
||||||
'FROM rides WHERE cloud_ride_id = ?',
|
'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id '
|
||||||
variables: [Variable<String>(cloudRideId)],
|
'FROM rides WHERE cloud_ride_id = ?',
|
||||||
).get();
|
variables: [Variable<String>(cloudRideId)],
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
if (localRows.isNotEmpty) {
|
if (localRows.isNotEmpty) {
|
||||||
final local = localRows.first.data;
|
final local = localRows.first.data;
|
||||||
|
|
@ -98,10 +148,12 @@ class CloudReconciliationService {
|
||||||
|
|
||||||
final localRideId = _asInt(data['localRideId']);
|
final localRideId = _asInt(data['localRideId']);
|
||||||
if (localRideId != null) {
|
if (localRideId != null) {
|
||||||
final legacyRows = await _db.customSelect(
|
final legacyRows = await _db
|
||||||
'SELECT id, cloud_ride_id FROM rides WHERE id = ?',
|
.customSelect(
|
||||||
variables: [Variable<int>(localRideId)],
|
'SELECT id, cloud_ride_id FROM rides WHERE id = ?',
|
||||||
).get();
|
variables: [Variable<int>(localRideId)],
|
||||||
|
)
|
||||||
|
.get();
|
||||||
if (legacyRows.isNotEmpty) {
|
if (legacyRows.isNotEmpty) {
|
||||||
final existingCloudId = legacyRows.first.data['cloud_ride_id'];
|
final existingCloudId = legacyRows.first.data['cloud_ride_id'];
|
||||||
if (existingCloudId == null || existingCloudId.toString().isEmpty) {
|
if (existingCloudId == null || existingCloudId.toString().isEmpty) {
|
||||||
|
|
@ -140,7 +192,8 @@ class CloudReconciliationService {
|
||||||
required DateTime? endTime,
|
required DateTime? endTime,
|
||||||
required Map<String, dynamic> data,
|
required Map<String, dynamic> data,
|
||||||
}) async {
|
}) async {
|
||||||
await _db.customStatement('''
|
await _db.customStatement(
|
||||||
|
'''
|
||||||
INSERT INTO rides (
|
INSERT INTO rides (
|
||||||
cloud_ride_id,
|
cloud_ride_id,
|
||||||
start_time,
|
start_time,
|
||||||
|
|
@ -154,6 +207,8 @@ class CloudReconciliationService {
|
||||||
distance,
|
distance,
|
||||||
average_speed,
|
average_speed,
|
||||||
top_speed,
|
top_speed,
|
||||||
|
moving_time,
|
||||||
|
stopped_time,
|
||||||
elevation_gain,
|
elevation_gain,
|
||||||
elevation_loss,
|
elevation_loss,
|
||||||
motorcycle_id,
|
motorcycle_id,
|
||||||
|
|
@ -161,31 +216,38 @@ class CloudReconciliationService {
|
||||||
motorcycle_make,
|
motorcycle_make,
|
||||||
motorcycle_model,
|
motorcycle_model,
|
||||||
motorcycle_variant
|
motorcycle_variant
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', [
|
''',
|
||||||
cloudRideId,
|
[
|
||||||
startTime.toUtc().millisecondsSinceEpoch,
|
cloudRideId,
|
||||||
endTime?.toUtc().millisecondsSinceEpoch,
|
startTime.toUtc().millisecondsSinceEpoch,
|
||||||
_asDouble(data['distanceMiles']),
|
endTime?.toUtc().millisecondsSinceEpoch,
|
||||||
_asDouble(data['topSpeedMph']),
|
_asDouble(data['distanceMiles']),
|
||||||
_asDouble(data['maxLeanLeft']),
|
_asDouble(data['topSpeedMph']),
|
||||||
_asDouble(data['maxLeanRight']),
|
_asDouble(data['maxLeanLeft']),
|
||||||
_asDouble(data['maxAccelG']),
|
_asDouble(data['maxLeanRight']),
|
||||||
_asDouble(data['maxBrakingG']),
|
_asDouble(data['maxAccelG']),
|
||||||
_asDouble(data['distanceMiles']),
|
_asDouble(data['maxBrakingG']),
|
||||||
_asDouble(data['averageSpeed'] ?? data['averageSpeedMph']),
|
_asDouble(data['distanceMiles']),
|
||||||
_asDouble(data['topSpeedMph']),
|
_asDouble(data['averageSpeed'] ?? data['averageSpeedMph']),
|
||||||
_asDouble(data['elevationGain']),
|
_asDouble(data['topSpeedMph']),
|
||||||
_asDouble(data['elevationLoss']),
|
_asInt(data['movingTime']) ?? 0,
|
||||||
_asInt(data['motorcycleId']),
|
_asInt(data['stoppedTime']) ?? 0,
|
||||||
_asInt(data['motorcycleYear']),
|
_asDouble(data['elevationGain']),
|
||||||
data['motorcycleMake']?.toString(),
|
_asDouble(data['elevationLoss']),
|
||||||
data['motorcycleModel']?.toString(),
|
_asInt(data['motorcycleId']),
|
||||||
data['motorcycleVariant']?.toString(),
|
_asInt(data['motorcycleYear']),
|
||||||
]);
|
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 localDistance = _asDouble(local['distance_miles']);
|
||||||
final cloudDistance = _asDouble(cloud['distanceMiles']);
|
final cloudDistance = _asDouble(cloud['distanceMiles']);
|
||||||
final localTopSpeed = _asDouble(local['top_speed_mph']);
|
final localTopSpeed = _asDouble(local['top_speed_mph']);
|
||||||
|
|
@ -193,21 +255,35 @@ class CloudReconciliationService {
|
||||||
|
|
||||||
// Only flag meaningful numeric divergence. Missing cloud fields are not
|
// Only flag meaningful numeric divergence. Missing cloud fields are not
|
||||||
// treated as authoritative and therefore cannot overwrite local values.
|
// treated as authoritative and therefore cannot overwrite local values.
|
||||||
if (cloudDistance != null && localDistance != null && (localDistance - cloudDistance).abs() > 0.0001) return true;
|
if (cloudDistance != null &&
|
||||||
if (cloudTopSpeed != null && localTopSpeed != null && (localTopSpeed - cloudTopSpeed).abs() > 0.0001) return true;
|
localDistance != null &&
|
||||||
|
(localDistance - cloudDistance).abs() > 0.0001) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cloudTopSpeed != null &&
|
||||||
|
localTopSpeed != null &&
|
||||||
|
(localTopSpeed - cloudTopSpeed).abs() > 0.0001) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
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) {
|
DateTime? _readDateTime(Object? value) {
|
||||||
if (value is Timestamp) return value.toDate().toUtc();
|
if (value is Timestamp) return value.toDate().toUtc();
|
||||||
if (value is DateTime) return value.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() ?? '');
|
final parsed = DateTime.tryParse(value?.toString() ?? '');
|
||||||
return parsed?.toUtc();
|
return parsed?.toUtc();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(', ')}.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
import 'cloud_reconciliation_service.dart';
|
import 'cloud_reconciliation_service.dart';
|
||||||
|
|
@ -12,18 +11,37 @@ import 'r2_download_service.dart';
|
||||||
|
|
||||||
class CloudRestoreResult {
|
class CloudRestoreResult {
|
||||||
final CloudReconciliationResult metadata;
|
final CloudReconciliationResult metadata;
|
||||||
final int telemetryRestored;
|
final int telemetryRidesRestored;
|
||||||
|
final int telemetrySamplesRestored;
|
||||||
final int telemetrySkipped;
|
final int telemetrySkipped;
|
||||||
|
final int telemetryAlreadyPresent;
|
||||||
final int telemetryFailed;
|
final int telemetryFailed;
|
||||||
|
final int telemetryAuthFailures;
|
||||||
|
|
||||||
const CloudRestoreResult({
|
const CloudRestoreResult({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
this.telemetryRestored = 0,
|
this.telemetryRidesRestored = 0,
|
||||||
|
this.telemetrySamplesRestored = 0,
|
||||||
this.telemetrySkipped = 0,
|
this.telemetrySkipped = 0,
|
||||||
|
this.telemetryAlreadyPresent = 0,
|
||||||
this.telemetryFailed = 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.
|
/// Additive, resumable MotorCloud restore coordinator.
|
||||||
|
|
@ -36,68 +54,102 @@ class CloudRestoreService {
|
||||||
final AuthService _authService;
|
final AuthService _authService;
|
||||||
final CloudReconciliationService _metadata;
|
final CloudReconciliationService _metadata;
|
||||||
final R2DownloadService _r2DownloadService;
|
final R2DownloadService _r2DownloadService;
|
||||||
final FirebaseFirestore _firestore;
|
final CloudRideMetadataStore _metadataStore;
|
||||||
|
|
||||||
CloudRestoreService({
|
CloudRestoreService({
|
||||||
required AppDatabase db,
|
required AppDatabase db,
|
||||||
required AuthService authService,
|
required AuthService authService,
|
||||||
CloudReconciliationService? metadata,
|
CloudReconciliationService? metadata,
|
||||||
R2DownloadService? r2DownloadService,
|
R2DownloadService? r2DownloadService,
|
||||||
FirebaseFirestore? firestore,
|
CloudRideMetadataStore? metadataStore,
|
||||||
}) : _db = db,
|
}) : _db = db,
|
||||||
_authService = authService,
|
_authService = authService,
|
||||||
_metadata = metadata ?? CloudReconciliationService(db: db, authService: authService),
|
_metadata =
|
||||||
_r2DownloadService = r2DownloadService ?? R2DownloadService(),
|
metadata ??
|
||||||
_firestore = firestore ?? FirebaseFirestore.instance;
|
CloudReconciliationService(
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
metadataStore: metadataStore,
|
||||||
|
),
|
||||||
|
_r2DownloadService = r2DownloadService ?? R2DownloadService(),
|
||||||
|
_metadataStore = metadataStore ?? FirestoreCloudRideMetadataStore();
|
||||||
|
|
||||||
Future<CloudRestoreResult> restore() async {
|
Future<CloudRestoreResult> restore() async {
|
||||||
final user = _authService.currentUser;
|
final user = _authService.currentUser;
|
||||||
if (user == null || user.isAnonymous) {
|
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) {
|
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 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 telemetrySkipped = 0;
|
||||||
|
var telemetryAlreadyPresent = 0;
|
||||||
var telemetryFailed = 0;
|
var telemetryFailed = 0;
|
||||||
|
var telemetryAuthFailures = 0;
|
||||||
|
|
||||||
for (final document in snapshot.docs) {
|
for (final document in documents) {
|
||||||
final data = document.data();
|
final data = document.data;
|
||||||
final cloudRideId = document.id;
|
final cloudRideId = document.id;
|
||||||
if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) {
|
if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) {
|
||||||
telemetrySkipped++;
|
telemetrySkipped++;
|
||||||
continue;
|
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) {
|
if (localRide == null) {
|
||||||
telemetryFailed++;
|
telemetryFailed++;
|
||||||
continue;
|
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) {
|
if (telemetryCount > 0) {
|
||||||
telemetrySkipped++;
|
telemetryAlreadyPresent++;
|
||||||
continue;
|
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.success || result.zipBytes == null) {
|
||||||
|
if (result.requiresAuth) {
|
||||||
|
telemetryAuthFailures++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
telemetryFailed++;
|
telemetryFailed++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final restoredCount = await _restoreTelemetryCsv(localRide.id, result.zipBytes!);
|
final restoredCount = await _restoreTelemetryCsv(
|
||||||
|
localRide.id,
|
||||||
|
result.zipBytes!,
|
||||||
|
);
|
||||||
if (restoredCount > 0) {
|
if (restoredCount > 0) {
|
||||||
telemetryRestored++;
|
telemetryRidesRestored++;
|
||||||
|
telemetrySamplesRestored += restoredCount;
|
||||||
} else {
|
} else {
|
||||||
telemetryFailed++;
|
telemetryFailed++;
|
||||||
}
|
}
|
||||||
|
|
@ -108,16 +160,23 @@ class CloudRestoreService {
|
||||||
|
|
||||||
return CloudRestoreResult(
|
return CloudRestoreResult(
|
||||||
metadata: metadataResult,
|
metadata: metadataResult,
|
||||||
telemetryRestored: telemetryRestored,
|
telemetryRidesRestored: telemetryRidesRestored,
|
||||||
|
telemetrySamplesRestored: telemetrySamplesRestored,
|
||||||
telemetrySkipped: telemetrySkipped,
|
telemetrySkipped: telemetrySkipped,
|
||||||
|
telemetryAlreadyPresent: telemetryAlreadyPresent,
|
||||||
telemetryFailed: telemetryFailed,
|
telemetryFailed: telemetryFailed,
|
||||||
|
telemetryAuthFailures: telemetryAuthFailures,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async {
|
Future<int> _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async {
|
||||||
final archive = ZipDecoder().decodeBytes(zipBytes, verify: true);
|
final archive = ZipDecoder().decodeBytes(zipBytes, verify: true);
|
||||||
final file = archive.findFile('telemetry.csv');
|
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 csv = utf8.decode(file.content as List<int>, allowMalformed: false);
|
||||||
final lines = const LineSplitter().convert(csv);
|
final lines = const LineSplitter().convert(csv);
|
||||||
|
|
@ -140,7 +199,8 @@ class CloudRestoreService {
|
||||||
'lateralG',
|
'lateralG',
|
||||||
];
|
];
|
||||||
final header = lines.first.split(',');
|
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.');
|
throw const FormatException('Unsupported telemetry.csv schema.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,28 +208,34 @@ class CloudRestoreService {
|
||||||
for (final line in lines.skip(1)) {
|
for (final line in lines.skip(1)) {
|
||||||
if (line.trim().isEmpty) continue;
|
if (line.trim().isEmpty) continue;
|
||||||
final values = line.split(',');
|
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]);
|
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(
|
||||||
rideId: rideId,
|
TelemetrySamplesCompanion.insert(
|
||||||
timestamp: timestamp.toUtc(),
|
rideId: rideId,
|
||||||
latitude: _nullableDouble(values[1]),
|
timestamp: timestamp.toUtc(),
|
||||||
longitude: _nullableDouble(values[2]),
|
latitude: _nullableDouble(values[1]),
|
||||||
altitude: _nullableDouble(values[3]),
|
longitude: _nullableDouble(values[2]),
|
||||||
speed: _nullableDouble(values[4]),
|
altitude: _nullableDouble(values[3]),
|
||||||
heading: _nullableDouble(values[5]),
|
speed: _nullableDouble(values[4]),
|
||||||
gpsAccuracy: _nullableDouble(values[6]),
|
heading: _nullableDouble(values[5]),
|
||||||
leanAngle: _nullableDouble(values[7]),
|
gpsAccuracy: _nullableDouble(values[6]),
|
||||||
pitchAngle: _nullableDouble(values[8]),
|
leanAngle: _nullableDouble(values[7]),
|
||||||
accelX: _nullableDouble(values[9]),
|
pitchAngle: _nullableDouble(values[8]),
|
||||||
accelY: _nullableDouble(values[10]),
|
accelX: _nullableDouble(values[9]),
|
||||||
accelZ: _nullableDouble(values[11]),
|
accelY: _nullableDouble(values[10]),
|
||||||
longitudinalG: _nullableDouble(values[12]),
|
accelZ: _nullableDouble(values[11]),
|
||||||
lateralG: _nullableDouble(values[13]),
|
longitudinalG: _nullableDouble(values[12]),
|
||||||
));
|
lateralG: _nullableDouble(values[13]),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companions.isEmpty) return 0;
|
if (companions.isEmpty) return 0;
|
||||||
|
|
@ -185,7 +251,11 @@ class CloudRestoreService {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) return const drift.Value.absent();
|
if (trimmed.isEmpty) return const drift.Value.absent();
|
||||||
final parsed = double.tryParse(trimmed);
|
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);
|
return drift.Value(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,5 +267,6 @@ class CloudRestoreService {
|
||||||
return true;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,34 @@ import '../github/github_sync_service.dart';
|
||||||
import 'firebase_sync_provider.dart';
|
import 'firebase_sync_provider.dart';
|
||||||
import 'github_sync_provider.dart';
|
import 'github_sync_provider.dart';
|
||||||
import 'r2_upload_service.dart';
|
import 'r2_upload_service.dart';
|
||||||
|
import 'cloud_restore_controller.dart';
|
||||||
|
import 'cloud_restore_service.dart';
|
||||||
import 'sync_bundle_worker.dart';
|
import 'sync_bundle_worker.dart';
|
||||||
import 'sync_manager.dart';
|
import 'sync_manager.dart';
|
||||||
import 'sync_models.dart';
|
import 'sync_models.dart';
|
||||||
import 'sync_outbox.dart';
|
import 'sync_outbox.dart';
|
||||||
|
|
||||||
final r2UploadServiceProvider = Provider<R2UploadService>((ref) => R2UploadService());
|
final r2UploadServiceProvider = Provider<R2UploadService>(
|
||||||
final syncBundleWorkerProvider = Provider<SyncBundleWorker>((ref) => SyncBundleWorker(ref.watch(databaseProvider)));
|
(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
|
/// Cloud sync state is account-scoped. The Firebase UID is intentionally part
|
||||||
/// of the persisted namespace so signing out and into another account cannot
|
/// 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);
|
return SyncOutbox(prefs, storageNamespace: namespace);
|
||||||
});
|
});
|
||||||
|
|
||||||
final firebaseSyncProvider = Provider<FirebaseSyncProvider>((ref) => FirebaseSyncProvider(
|
final firebaseSyncProvider = Provider<FirebaseSyncProvider>(
|
||||||
db: ref.watch(databaseProvider),
|
(ref) => FirebaseSyncProvider(
|
||||||
authService: ref.watch(authServiceProvider),
|
db: ref.watch(databaseProvider),
|
||||||
bundleWorker: ref.watch(syncBundleWorkerProvider),
|
authService: ref.watch(authServiceProvider),
|
||||||
r2UploadService: ref.watch(r2UploadServiceProvider),
|
bundleWorker: ref.watch(syncBundleWorkerProvider),
|
||||||
));
|
r2UploadService: ref.watch(r2UploadServiceProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>((ref) => FirebaseAccountSyncService(
|
final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>(
|
||||||
db: ref.watch(databaseProvider),
|
(ref) => FirebaseAccountSyncService(
|
||||||
auth: ref.watch(authServiceProvider),
|
db: ref.watch(databaseProvider),
|
||||||
));
|
auth: ref.watch(authServiceProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final githubSyncProvider = Provider<GitHubSyncProvider>((ref) => GitHubSyncProvider(
|
final githubSyncProvider = Provider<GitHubSyncProvider>(
|
||||||
service: ref.watch(githubSyncServiceProvider),
|
(ref) => GitHubSyncProvider(
|
||||||
auth: ref.watch(githubAuthServiceProvider),
|
service: ref.watch(githubSyncServiceProvider),
|
||||||
));
|
auth: ref.watch(githubAuthServiceProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
|
final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
ref.watch(syncOutboxProvider),
|
ref.watch(syncOutboxProvider),
|
||||||
initialProviders: [ref.watch(firebaseSyncProvider), ref.watch(githubSyncProvider)],
|
initialProviders: [
|
||||||
|
ref.watch(firebaseSyncProvider),
|
||||||
|
ref.watch(githubSyncProvider),
|
||||||
|
],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
);
|
);
|
||||||
final accountSync = ref.watch(firebaseAccountSyncServiceProvider);
|
final accountSync = ref.watch(firebaseAccountSyncServiceProvider);
|
||||||
|
|
@ -62,16 +92,25 @@ final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
|
||||||
ref.listen<AuthState>(authNotifierProvider, (previous, next) {
|
ref.listen<AuthState>(authNotifierProvider, (previous, next) {
|
||||||
if (next.isAuthenticated && previous?.isAuthenticated != true) {
|
if (next.isAuthenticated && previous?.isAuthenticated != true) {
|
||||||
manager.retryAuthRequired();
|
manager.retryAuthRequired();
|
||||||
accountSync.syncAccount().then((_) {}, onError: (_) {
|
accountSync.syncAccount().then(
|
||||||
// Profile/garage synchronization is downstream of riding and must
|
(_) {},
|
||||||
// never interrupt telemetry or ride persistence.
|
onError: (_) {
|
||||||
});
|
// Profile/garage synchronization is downstream of riding and must
|
||||||
|
// never interrupt telemetry or ride persistence.
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ref.onDispose(manager.dispose);
|
ref.onDispose(manager.dispose);
|
||||||
return manager;
|
return manager;
|
||||||
});
|
});
|
||||||
|
|
||||||
final syncStateProvider = Provider<SyncManagerState>((ref) => ref.watch(syncManagerProvider).currentState);
|
final syncStateProvider = Provider<SyncManagerState>(
|
||||||
final pendingSyncCountProvider = Provider<int>((ref) => ref.watch(syncStateProvider).pendingCount);
|
(ref) => ref.watch(syncManagerProvider).currentState,
|
||||||
final isSyncProcessingProvider = Provider<bool>((ref) => ref.watch(syncStateProvider).isProcessing);
|
);
|
||||||
|
final pendingSyncCountProvider = Provider<int>(
|
||||||
|
(ref) => ref.watch(syncStateProvider).pendingCount,
|
||||||
|
);
|
||||||
|
final isSyncProcessingProvider = Provider<bool>(
|
||||||
|
(ref) => ref.watch(syncStateProvider).isProcessing,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue