From f6ba2e8e9d4faded47cb53667f0f90a83c958325 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 31 Aug 2026 01:27:58 -0400 Subject: [PATCH] feat(auth): add optional authentication and account foundation --- .gitignore | 6 +- lib/core/router.dart | 5 + lib/features/settings/account_screen.dart | 709 ++++++++++++++++++ lib/features/settings/settings_screen.dart | 73 ++ .../widgets/evolution_settings_view.dart | 140 ++++ lib/main.dart | 8 + lib/services/auth/auth_providers.dart | 94 +++ lib/services/auth/auth_service.dart | 358 +++++++++ lib/services/auth/auth_state.dart | 98 +++ lib/services/auth/auth_user.dart | 108 +++ macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 104 +++ pubspec.yaml | 3 + test/auth_test.dart | 333 ++++++++ .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 16 files changed, 2052 insertions(+), 1 deletion(-) create mode 100644 lib/features/settings/account_screen.dart create mode 100644 lib/services/auth/auth_providers.dart create mode 100644 lib/services/auth/auth_service.dart create mode 100644 lib/services/auth/auth_state.dart create mode 100644 lib/services/auth/auth_user.dart create mode 100644 test/auth_test.dart diff --git a/.gitignore b/.gitignore index 20f9ac5..2d55ef8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,12 @@ app.*.map.json /android/app/release *.apk -# Local Agent / IDE cache +# Local Agent / IDE cache & scratch artifacts .agent/ .agents/ +.agent-backup/ node_modules/ **/node_modules/ +scratch/ +flutter-doctor.txt +gemini-mcp-config-search.txt diff --git a/lib/core/router.dart b/lib/core/router.dart index b5fd10d..4ee0b31 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -15,6 +15,7 @@ import '../features/settings/field_test_report_screen.dart'; import '../features/settings/field_test_live_log_screen.dart'; import '../features/field_test/field_test_pre_ride_check_screen.dart'; import '../features/settings/oauth_callback_screen.dart'; +import '../features/settings/account_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -115,6 +116,10 @@ final appRouter = GoRouter( path: '/settings', builder: (context, state) => const SettingsScreen(), routes: [ + GoRoute( + path: 'account', + builder: (context, state) => const AccountScreen(), + ), GoRoute( path: 'diagnostics', builder: (context, state) => const DiagnosticsScreen(), diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart new file mode 100644 index 0000000..d234557 --- /dev/null +++ b/lib/features/settings/account_screen.dart @@ -0,0 +1,709 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/evolution_theme_config.dart'; +import '../../services/auth/auth_providers.dart'; +import '../../services/auth/auth_state.dart'; +import '../../services/auth/auth_user.dart'; + +class AccountScreen extends ConsumerStatefulWidget { + const AccountScreen({super.key}); + + @override + ConsumerState createState() => _AccountScreenState(); +} + +enum _AuthFormMode { signIn, register, forgotPassword } + +class _AccountScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _displayNameController = TextEditingController(); + + _AuthFormMode _formMode = _AuthFormMode.signIn; + bool _obscurePassword = true; + String? _statusMessage; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _displayNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authNotifierProvider); + final themeMode = ref.watch(dashboardThemeModeProvider); + final isEvolution = themeMode == DashboardThemeMode.evolution; + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + + final bgColor = isEvolution ? Colors.black : Theme.of(context).scaffoldBackgroundColor; + final titleFont = isEvolution ? 'Sora' : null; + + return Scaffold( + backgroundColor: bgColor, + appBar: AppBar( + backgroundColor: isEvolution ? Colors.transparent : null, + elevation: 0, + title: Text( + 'Account & Cloud Identity', + style: TextStyle( + fontFamily: titleFont, + fontWeight: FontWeight.w800, + fontSize: isLandscape ? 14 : 18, + ), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 700; + + Widget content; + if (authState.isAuthenticated) { + content = _buildSignedInView(context, authState.user!, isEvolution, isWide); + } else if (authState.isAnonymous) { + content = _buildAnonymousView(context, authState.user!, isEvolution, isWide); + } else { + content = _buildSignedOutView(context, authState, isEvolution, isWide); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: isWide ? 640 : double.infinity), + child: content, + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildSignedOutView( + BuildContext context, + AuthState authState, + bool isEvolution, + bool isWide, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildOfflineNoticeCard(isEvolution, authState.isFirebaseConfigured), + const SizedBox(height: 16), + _buildAuthCard( + isEvolution: isEvolution, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.account_circle_outlined, + color: const Color(0xFF00E5FF), + size: 22, + ), + const SizedBox(width: 10), + Text( + _formMode == _AuthFormMode.signIn + ? 'SIGN IN TO MOTORCLOUD' + : (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'RESET PASSWORD'), + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF00E5FF), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Cloud sign-in is completely optional. All telemetry, HUD instruments, sensor fusion, and ride history are 100% functional without an account.', + style: TextStyle( + fontFamily: isEvolution ? 'Inter' : null, + fontSize: 11, + color: Colors.white70, + height: 1.35, + ), + ), + const SizedBox(height: 16), + if (authState.hasError) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.redAccent.withValues(alpha: 0.6)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + authState.errorMessage!, + style: const TextStyle(fontSize: 11, color: Colors.redAccent), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + if (_statusMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.greenAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.greenAccent.withValues(alpha: 0.6)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle_outline, color: Colors.greenAccent, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage!, + style: const TextStyle(fontSize: 11, color: Colors.greenAccent), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + if (_formMode != _AuthFormMode.forgotPassword) ...[ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.g_mobiledata, size: 24, color: Colors.redAccent), + label: const Text( + 'Continue with Google', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + ), + onPressed: authState.isLoading ? null : () => _handleGoogleSignIn(), + ), + const SizedBox(height: 16), + const Row( + children: [ + Expanded(child: Divider(color: Colors.white24)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text('OR', style: TextStyle(color: Colors.white38, fontSize: 10, fontWeight: FontWeight.bold)), + ), + Expanded(child: Divider(color: Colors.white24)), + ], + ), + const SizedBox(height: 16), + ], + Form( + key: _formKey, + child: Column( + children: [ + if (_formMode == _AuthFormMode.register) ...[ + TextFormField( + controller: _displayNameController, + decoration: InputDecoration( + labelText: 'Display Name (Optional)', + prefixIcon: const Icon(Icons.person_outline, size: 18), + filled: true, + fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + isDense: true, + ), + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 12), + ], + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: 'Email Address', + prefixIcon: const Icon(Icons.email_outlined, size: 18), + filled: true, + fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + isDense: true, + ), + style: const TextStyle(fontSize: 13), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an email address'; + } + if (!value.contains('@') || !value.contains('.')) { + return 'Please enter a valid email address'; + } + return null; + }, + ), + if (_formMode != _AuthFormMode.forgotPassword) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline, size: 18), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + size: 18, + color: Colors.white54, + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + filled: true, + fillColor: isEvolution ? const Color(0xFF141414) : Colors.white10, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + isDense: true, + ), + style: const TextStyle(fontSize: 13), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + if (_formMode == _AuthFormMode.register && value.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: authState.isLoading ? null : () => _submitForm(), + child: authState.isLoading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black)) + : Text( + _formMode == _AuthFormMode.signIn + ? 'SIGN IN' + : (_formMode == _AuthFormMode.register ? 'CREATE ACCOUNT' : 'SEND RESET LINK'), + style: const TextStyle(fontWeight: FontWeight.w900, letterSpacing: 0.8), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (_formMode == _AuthFormMode.signIn) ...[ + TextButton( + onPressed: () => setState(() { + _formMode = _AuthFormMode.forgotPassword; + _statusMessage = null; + }), + child: const Text('Forgot Password?', style: TextStyle(fontSize: 11, color: Colors.white70)), + ), + TextButton( + onPressed: () => setState(() { + _formMode = _AuthFormMode.register; + _statusMessage = null; + }), + child: const Text('Create Account', style: TextStyle(fontSize: 11, color: Color(0xFF00E5FF), fontWeight: FontWeight.bold)), + ), + ] else if (_formMode == _AuthFormMode.register) ...[ + TextButton( + onPressed: () => setState(() { + _formMode = _AuthFormMode.signIn; + _statusMessage = null; + }), + child: const Text('Already have an account? Sign In', style: TextStyle(fontSize: 11, color: Color(0xFF00E5FF))), + ), + ] else ...[ + TextButton( + onPressed: () => setState(() { + _formMode = _AuthFormMode.signIn; + _statusMessage = null; + }), + child: const Text('Back to Sign In', style: TextStyle(fontSize: 11, color: Colors.white70)), + ), + ], + ], + ), + const Divider(color: Colors.white12, height: 24), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Colors.white24), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.person_outline, size: 18), + label: const Text('Continue as Guest / Anonymous Rider', style: TextStyle(fontSize: 11)), + onPressed: authState.isLoading ? null : () => ref.read(authNotifierProvider.notifier).signInAnonymously(), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSignedInView( + BuildContext context, + AuthUser user, + bool isEvolution, + bool isWide, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAuthCard( + isEvolution: isEvolution, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2), + backgroundImage: user.photoUrl != null ? NetworkImage(user.photoUrl!) : null, + child: user.photoUrl == null + ? const Icon(Icons.person, color: Color(0xFF00E5FF), size: 32) + : null, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayLabel, + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 18, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 2), + Text( + user.email ?? 'No email associated', + style: const TextStyle(fontSize: 12, color: Colors.white70), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF00E5FF), width: 0.8), + ), + child: Text( + 'AUTHENTICATED (${user.providerId ?? "cloud"})', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w900, + color: Color(0xFF00E5FF), + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32, color: Colors.white12), + _buildInfoRow('User ID', user.uid, isMonospace: true), + if (user.providerId != null) _buildInfoRow('Provider', user.providerId!), + if (user.createdAt != null) + _buildInfoRow('Account Created', user.createdAt!.toLocal().toString().split('.')[0]), + if (user.lastSignInTime != null) + _buildInfoRow('Last Sign-In', user.lastSignInTime!.toLocal().toString().split('.')[0]), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.logout, size: 18), + label: const Text('SIGN OUT', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8)), + onPressed: () => ref.read(authNotifierProvider.notifier).signOut(), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildLocalRidingActiveCard(isEvolution), + ], + ); + } + + Widget _buildAnonymousView( + BuildContext context, + AuthUser user, + bool isEvolution, + bool isWide, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAuthCard( + isEvolution: isEvolution, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: Colors.amberAccent.withValues(alpha: 0.2), + child: const Icon(Icons.person_outline, color: Colors.amberAccent, size: 32), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Guest / Anonymous Rider', + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 17, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.amberAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amberAccent, width: 0.8), + ), + child: const Text( + 'OFFLINE GUEST SESSION', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w900, + color: Colors.amberAccent, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 24, color: Colors.white12), + _buildInfoRow('Session ID', user.uid, isMonospace: true), + const SizedBox(height: 16), + const Text( + 'You are currently using MotorSignal in local guest mode. All rides are stored locally in your SQLite database. You can link a full cloud account anytime.', + style: TextStyle(fontSize: 11, color: Colors.white70, height: 1.4), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.link, size: 18), + label: const Text('SIGN IN WITH FULL ACCOUNT', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: () => ref.read(authNotifierProvider.notifier).signOut(), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildOfflineNoticeCard(bool isEvolution, bool isFirebaseConfigured) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isEvolution ? const Color(0xFF0D0D0D) : const Color(0xFF1B2838), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isFirebaseConfigured + ? const Color(0xFF00E5FF).withValues(alpha: 0.3) + : Colors.amberAccent.withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isFirebaseConfigured ? Icons.cloud_done_outlined : Icons.offline_bolt_outlined, + color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isFirebaseConfigured ? 'OFFLINE-FIRST ARCHITECTURE' : 'STANDALONE OFFLINE MODE', + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 11, + fontWeight: FontWeight.w800, + color: isFirebaseConfigured ? const Color(0xFF00E5FF) : Colors.amberAccent, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 4), + Text( + isFirebaseConfigured + ? 'Signing in enables optional multi-device account sync. Offline riding, telemetry acquisition, local SQLite persistence, and forensic exports are always available without an account.' + : 'MotorSignal is operating in local mode. All instruments, 20Hz sensor fusion, garage storage, and ride recordings remain 100% operational.', + style: const TextStyle(fontSize: 11, color: Colors.white70, height: 1.35), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLocalRidingActiveCard(bool isEvolution) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isEvolution ? const Color(0xFF0D0D0D) : const Color(0xFF141414), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), + ), + child: const Row( + children: [ + Icon(Icons.shield_outlined, color: Colors.greenAccent, size: 20), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LOCAL DATA ISOLATION ACTIVE', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.greenAccent, + letterSpacing: 0.6, + ), + ), + SizedBox(height: 2), + Text( + 'Your local Schema 5 Drift SQLite database remains the authoritative source of truth for telemetry and rides.', + style: TextStyle(fontSize: 11, color: Colors.white60), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAuthCard({required bool isEvolution, required Widget child}) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: isEvolution ? const Color(0xFF0D0D0D) : null, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isEvolution ? const Color(0xFF1F1F1F) : Colors.white12, + width: 1.2, + ), + ), + child: child, + ); + } + + Widget _buildInfoRow(String label, String value, {bool isMonospace = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: isMonospace ? 'monospace' : null, + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } + + void _handleGoogleSignIn() { + setState(() => _statusMessage = null); + ref.read(authNotifierProvider.notifier).signInWithGoogle(); + } + + void _submitForm() async { + if (_formKey.currentState?.validate() != true) return; + setState(() => _statusMessage = null); + + final email = _emailController.text.trim(); + final password = _passwordController.text; + final displayName = _displayNameController.text.trim(); + final notifier = ref.read(authNotifierProvider.notifier); + + if (_formMode == _AuthFormMode.signIn) { + await notifier.signInWithEmailAndPassword(email, password); + } else if (_formMode == _AuthFormMode.register) { + await notifier.createUserWithEmailAndPassword( + email, + password, + displayName: displayName.isNotEmpty ? displayName : null, + ); + } else if (_formMode == _AuthFormMode.forgotPassword) { + await notifier.sendPasswordResetEmail(email); + if (mounted) { + setState(() { + _statusMessage = 'Password reset instructions sent to $email'; + }); + } + } + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index a96014f..f2de915 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -14,6 +14,7 @@ import '../../core/app_info.dart'; import '../../services/field_test/field_test_logger.dart'; import '../../core/theme/evolution_theme_config.dart'; import '../../services/osm_oauth_service.dart'; +import '../../services/auth/auth_providers.dart'; import 'widgets/evolution_settings_view.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -72,6 +73,78 @@ class _SettingsScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.all(16.0), children: [ + _buildSectionHeader('Account & Cloud Identity'), + const SizedBox(height: 12), + Card( + clipBehavior: Clip.antiAlias, + child: Consumer( + builder: (context, ref, _) { + final authState = ref.watch(authNotifierProvider); + final isAuthed = authState.isAuthenticated; + final user = authState.user; + + return InkWell( + onTap: () => context.go('/settings/account'), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + isAuthed ? Icons.account_circle : Icons.account_circle_outlined, + color: Colors.cyanAccent, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isAuthed ? (user?.displayLabel ?? 'Rider Account') : 'Sign In / Cloud Account', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + isAuthed + ? (user?.email ?? 'Cloud Identity Active • Offline rides protected') + : 'Optional cloud identity • Local riding always active', + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: isAuthed ? Colors.cyanAccent : Colors.white24), + ), + child: Text( + isAuthed ? 'SIGNED IN' : 'LOCAL', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isAuthed ? Colors.cyanAccent : Colors.white54, + ), + ), + ), + const SizedBox(width: 6), + const Icon(Icons.chevron_right, color: Colors.white54, size: 20), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), _buildSectionHeader('Dashboard UI Theme & Design'), const SizedBox(height: 12), Card( diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart index 23fce1e..cac7c3e 100644 --- a/lib/features/settings/widgets/evolution_settings_view.dart +++ b/lib/features/settings/widgets/evolution_settings_view.dart @@ -12,6 +12,7 @@ import '../../../services/sensor_fusion_service.dart'; import '../../../services/field_test/field_test_logger.dart'; import '../../../database/database.dart'; import '../../rides/ride_state_machine.dart'; +import '../../../services/auth/auth_providers.dart'; import '../../../core/theme/evolution_theme_config.dart'; import '../../../core/app_info.dart'; @@ -143,6 +144,7 @@ class _EvolutionSettingsViewState extends ConsumerState { ), ); + final accountCard = _buildAccountCard(context, ref); final osmAccountCard = _buildOsmAccountCard(context, ref, osmAuth); final mapEngineCard = _buildMapEngineCard(context, ref, mapType); final alertConfigCard = _buildAlertConfigCard( @@ -174,6 +176,8 @@ class _EvolutionSettingsViewState extends ConsumerState { Expanded( child: Column( children: [ + accountCard, + const SizedBox(height: 16), sensorsCard, const SizedBox(height: 16), mapEngineCard, @@ -215,6 +219,8 @@ class _EvolutionSettingsViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ themeToggleBar, + accountCard, + const SizedBox(height: 16), sensorsCard, const SizedBox(height: 16), engineeringCard, @@ -241,6 +247,140 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } + Widget _buildAccountCard(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authNotifierProvider); + final isAuthed = authState.isAuthenticated; + final user = authState.user; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isAuthed ? const Color(0xFF00E5FF).withValues(alpha: 0.6) : const Color(0xFF1F1F1F), + width: 1.2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.account_circle, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'MOTORCLOUD ACCOUNT', + style: TextStyle( + fontFamily: 'Sora', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF00E5FF), + letterSpacing: 0.1, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isAuthed + ? const Color(0xFF00E5FF).withValues(alpha: 0.15) + : Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isAuthed ? 'CONNECTED' : 'OFFLINE MODE', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + color: isAuthed ? const Color(0xFF00E5FF) : Colors.white38, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (isAuthed && user != null) ...[ + Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Colors.white12, + backgroundImage: user.photoUrl != null ? NetworkImage(user.photoUrl!) : null, + child: user.photoUrl == null + ? const Icon(Icons.person, color: Color(0xFF00E5FF), size: 20) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayLabel, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + Text( + user.email ?? 'Cloud Identity Active', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white54, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF), width: 0.8), + minimumSize: const Size(double.infinity, 38), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.manage_accounts, size: 16), + label: const Text('Manage Account & Cloud Sync', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + onPressed: () => context.go('/settings/account'), + ), + ] else ...[ + const Text( + 'Sign in for optional multi-device account sync and cloud backup. All telemetry and ride logging work completely offline.', + style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + minimumSize: const Size(double.infinity, 38), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.login, size: 16), + label: const Text( + 'Sign In / Create Account', + style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w800), + ), + onPressed: () => context.go('/settings/account'), + ), + ], + ], + ), + ); + } + Widget _buildOsmAccountCard(BuildContext context, WidgetRef ref, OsmAuthState osmAuth) { final isAuthed = osmAuth.isAuthenticated; final user = osmAuth.user; diff --git a/lib/main.dart b/lib/main.dart index a094a10..b4ea6b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:app_links/app_links.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'core/theme/theme.dart'; import 'core/router.dart'; import 'features/records/records_service.dart'; @@ -11,6 +12,13 @@ import 'services/osm_oauth_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Optional Firebase initialization (gracefully defaults to standalone offline mode if unconfigured) + try { + await Firebase.initializeApp(); + } catch (e) { + debugPrint('[Firebase] Standalone offline mode active: $e'); + } + final sharedPreferences = await SharedPreferences.getInstance(); runApp( diff --git a/lib/services/auth/auth_providers.dart b/lib/services/auth/auth_providers.dart new file mode 100644 index 0000000..a60e134 --- /dev/null +++ b/lib/services/auth/auth_providers.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'auth_service.dart'; +import 'auth_state.dart'; +import 'auth_user.dart'; + +/// Provider for the underlying AuthService instance. +/// Can be overridden in tests with a mock or fake AuthService. +final authServiceProvider = Provider((ref) { + final service = FirebaseAuthService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Reactive StateNotifier managing the authentication state for the UI +class AuthNotifier extends StateNotifier { + final AuthService _authService; + StreamSubscription? _subscription; + + AuthNotifier(this._authService) : super(_authService.currentState) { + _subscription = _authService.authStateChanges.listen((state) { + this.state = state; + }); + } + + Future signInWithEmailAndPassword(String email, String password) async { + await _authService.signInWithEmailAndPassword(email, password); + state = _authService.currentState; + } + + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async { + await _authService.createUserWithEmailAndPassword( + email, + password, + displayName: displayName, + ); + state = _authService.currentState; + } + + Future signInWithGoogle() async { + await _authService.signInWithGoogle(); + state = _authService.currentState; + } + + Future signInAnonymously() async { + await _authService.signInAnonymously(); + state = _authService.currentState; + } + + Future sendPasswordResetEmail(String email) async { + await _authService.sendPasswordResetEmail(email); + state = _authService.currentState; + } + + Future signOut() async { + await _authService.signOut(); + state = _authService.currentState; + } + + void clearError() { + state = state.copyWith(clearError: true); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} + +/// Primary Riverpod provider for UI reactivity on authentication +final authNotifierProvider = StateNotifierProvider((ref) { + final service = ref.watch(authServiceProvider); + return AuthNotifier(service); +}); + +/// Convenience provider returning the active AuthUser or null +final currentUserProvider = Provider((ref) { + return ref.watch(authNotifierProvider).user; +}); + +/// Convenience provider returning whether a non-anonymous user is signed in +final isAuthenticatedProvider = Provider((ref) { + return ref.watch(authNotifierProvider).isAuthenticated; +}); + +/// Convenience provider returning whether the user is in anonymous/local mode +final isAnonymousUserProvider = Provider((ref) { + return ref.watch(authNotifierProvider).isAnonymous; +}); diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart new file mode 100644 index 0000000..bb07319 --- /dev/null +++ b/lib/services/auth/auth_service.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:google_sign_in/google_sign_in.dart'; +import 'auth_state.dart'; +import 'auth_user.dart'; + +/// Clean abstraction interface over authentication. +/// The rest of the application should depend on this interface, +/// keeping Firebase implementation details completely isolated. +abstract class AuthService { + Stream get authStateChanges; + AuthState get currentState; + AuthUser? get currentUser; + bool get isConfigured; + + Future signInWithEmailAndPassword(String email, String password); + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}); + Future signInWithGoogle(); + Future signInAnonymously(); + Future sendPasswordResetEmail(String email); + Future signOut(); + void dispose(); +} + +/// Authoritative Firebase Authentication Service +class FirebaseAuthService implements AuthService { + final fb.FirebaseAuth? _firebaseAuth; + final GoogleSignIn? _googleSignIn; + + final _stateController = StreamController.broadcast(); + AuthState _currentState = const AuthState.initial(); + StreamSubscription? _fbAuthSubscription; + + FirebaseAuthService({ + fb.FirebaseAuth? firebaseAuth, + GoogleSignIn? googleSignIn, + }) : _firebaseAuth = firebaseAuth ?? _resolveFirebaseAuth(), + _googleSignIn = googleSignIn ?? GoogleSignIn() { + _init(); + } + + static fb.FirebaseAuth? _resolveFirebaseAuth() { + try { + if (Firebase.apps.isNotEmpty) { + return fb.FirebaseAuth.instance; + } + } catch (_) {} + return null; + } + + @override + Stream get authStateChanges => _stateController.stream; + + @override + AuthState get currentState => _currentState; + + @override + AuthUser? get currentUser => _currentState.user; + + @override + bool get isConfigured => _firebaseAuth != null; + + void _init() { + if (_firebaseAuth == null) { + // Firebase is not configured or offline during startup + _updateState(const AuthState.unauthenticated(isFirebaseConfigured: false)); + return; + } + + try { + // Listen to native Firebase auth state stream + _fbAuthSubscription = _firebaseAuth.authStateChanges().listen( + (fb.User? user) { + if (user == null) { + _updateState(const AuthState.unauthenticated(isFirebaseConfigured: true)); + } else { + final appUser = _mapFirebaseUser(user); + if (user.isAnonymous) { + _updateState(AuthState.anonymous(appUser, isFirebaseConfigured: true)); + } else { + _updateState(AuthState.authenticated(appUser, isFirebaseConfigured: true)); + } + } + }, + onError: (dynamic error) { + _updateState(AuthState.error( + _formatAuthError(error), + isFirebaseConfigured: true, + )); + }, + ); + } catch (e) { + _updateState(const AuthState.unauthenticated(isFirebaseConfigured: false)); + } + } + + void _updateState(AuthState state) { + _currentState = state; + if (!_stateController.isClosed) { + _stateController.add(state); + } + } + + AuthUser _mapFirebaseUser(fb.User user) { + String? provider; + if (user.providerData.isNotEmpty) { + provider = user.providerData.first.providerId; + } else if (user.isAnonymous) { + provider = 'anonymous'; + } + + return AuthUser( + uid: user.uid, + email: user.email, + displayName: user.displayName, + photoUrl: user.photoURL, + isAnonymous: user.isAnonymous, + providerId: provider, + createdAt: user.metadata.creationTime, + lastSignInTime: user.metadata.lastSignInTime, + ); + } + + @override + Future signInWithEmailAndPassword(String email, String password) async { + if (!isConfigured || _firebaseAuth == null) { + _updateState(AuthState.error( + 'Cloud authentication is unavailable in offline/unconfigured mode. Local ride recording is active.', + user: _currentState.user, + isFirebaseConfigured: false, + )); + return; + } + + _updateState(AuthState.loading( + user: _currentState.user, + isFirebaseConfigured: true, + )); + + try { + final credential = await _firebaseAuth.signInWithEmailAndPassword( + email: email.trim(), + password: password, + ); + if (credential.user != null) { + final user = _mapFirebaseUser(credential.user!); + _updateState(AuthState.authenticated(user, isFirebaseConfigured: true)); + } + } catch (e) { + _updateState(AuthState.error( + _formatAuthError(e), + user: _currentState.user, + isFirebaseConfigured: true, + )); + } + } + + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async { + if (!isConfigured || _firebaseAuth == null) { + _updateState(AuthState.error( + 'Account registration requires cloud configuration. Local riding remains fully functional.', + user: _currentState.user, + isFirebaseConfigured: false, + )); + return; + } + + _updateState(AuthState.loading( + user: _currentState.user, + isFirebaseConfigured: true, + )); + + try { + final credential = await _firebaseAuth.createUserWithEmailAndPassword( + email: email.trim(), + password: password, + ); + if (credential.user != null) { + if (displayName != null && displayName.trim().isNotEmpty) { + await credential.user!.updateDisplayName(displayName.trim()); + } + final user = _mapFirebaseUser(credential.user!); + _updateState(AuthState.authenticated(user, isFirebaseConfigured: true)); + } + } catch (e) { + _updateState(AuthState.error( + _formatAuthError(e), + user: _currentState.user, + isFirebaseConfigured: true, + )); + } + } + + @override + Future signInWithGoogle() async { + if (!isConfigured || _firebaseAuth == null) { + _updateState(AuthState.error( + 'Google Sign-In is unavailable without Firebase credentials. Local ride features remain available.', + user: _currentState.user, + isFirebaseConfigured: false, + )); + return; + } + + _updateState(AuthState.loading( + user: _currentState.user, + isFirebaseConfigured: true, + )); + + try { + final googleSignIn = _googleSignIn ?? GoogleSignIn(); + final GoogleSignInAccount? googleUser = await googleSignIn.signIn(); + + if (googleUser == null) { + // User cancelled the sign-in modal/flow + _updateState(_currentState.copyWith(status: _currentState.user != null ? AuthStatus.authenticated : AuthStatus.unauthenticated, clearError: true)); + return; + } + + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + final credential = fb.GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + + final result = await _firebaseAuth.signInWithCredential(credential); + if (result.user != null) { + final user = _mapFirebaseUser(result.user!); + _updateState(AuthState.authenticated(user, isFirebaseConfigured: true)); + } + } catch (e) { + _updateState(AuthState.error( + _formatAuthError(e), + user: _currentState.user, + isFirebaseConfigured: true, + )); + } + } + + @override + Future signInAnonymously() async { + if (!isConfigured || _firebaseAuth == null) { + // Local fallback anonymous user representation + final localAnon = AuthUser( + uid: 'local_${DateTime.now().millisecondsSinceEpoch}', + displayName: 'Local Guest', + isAnonymous: true, + providerId: 'local', + ); + _updateState(AuthState.anonymous(localAnon, isFirebaseConfigured: false)); + return; + } + + _updateState(AuthState.loading( + user: _currentState.user, + isFirebaseConfigured: true, + )); + + try { + final credential = await _firebaseAuth.signInAnonymously(); + if (credential.user != null) { + final user = _mapFirebaseUser(credential.user!); + _updateState(AuthState.anonymous(user, isFirebaseConfigured: true)); + } + } catch (e) { + _updateState(AuthState.error( + _formatAuthError(e), + user: _currentState.user, + isFirebaseConfigured: true, + )); + } + } + + @override + Future sendPasswordResetEmail(String email) async { + if (!isConfigured || _firebaseAuth == null) { + _updateState(AuthState.error( + 'Password reset is unavailable in offline mode.', + user: _currentState.user, + isFirebaseConfigured: false, + )); + return; + } + + try { + await _firebaseAuth.sendPasswordResetEmail(email: email.trim()); + } catch (e) { + _updateState(AuthState.error( + _formatAuthError(e), + user: _currentState.user, + isFirebaseConfigured: true, + )); + } + } + + @override + Future signOut() async { + _updateState(AuthState.loading( + user: _currentState.user, + isFirebaseConfigured: isConfigured, + )); + + try { + if (_googleSignIn != null) { + try { + await _googleSignIn.signOut(); + } catch (_) {} + } + if (_firebaseAuth != null) { + await _firebaseAuth.signOut(); + } + } catch (_) {} + + _updateState(AuthState.unauthenticated(isFirebaseConfigured: isConfigured)); + } + + String _formatAuthError(dynamic error) { + if (error is fb.FirebaseAuthException) { + switch (error.code) { + case 'user-not-found': + return 'No account found for this email address.'; + case 'wrong-password': + return 'Incorrect password. Please check and try again.'; + case 'invalid-credential': + return 'Invalid credentials. Please verify your email and password.'; + case 'email-already-in-use': + return 'An account already exists with this email address.'; + case 'invalid-email': + return 'The email address format is invalid.'; + case 'weak-password': + return 'Password must be at least 6 characters.'; + case 'user-disabled': + return 'This account has been disabled.'; + case 'operation-not-allowed': + return 'Sign-in method is currently disabled in configuration.'; + case 'network-request-failed': + return 'Network unavailable. You can continue recording rides offline.'; + case 'too-many-requests': + return 'Too many attempts. Please try again in a few moments.'; + default: + return error.message ?? 'Authentication error (${error.code}).'; + } + } + return error?.toString() ?? 'An unexpected authentication error occurred.'; + } + + @override + void dispose() { + _fbAuthSubscription?.cancel(); + _stateController.close(); + } +} diff --git a/lib/services/auth/auth_state.dart b/lib/services/auth/auth_state.dart new file mode 100644 index 0000000..7553194 --- /dev/null +++ b/lib/services/auth/auth_state.dart @@ -0,0 +1,98 @@ +import 'package:flutter/foundation.dart'; +import 'auth_user.dart'; + +enum AuthStatus { + initial, + loading, + authenticated, + unauthenticated, + anonymous, + error, +} + +/// Immutable authentication state representing the user session +/// across both online and offline/unconfigured environments. +@immutable +class AuthState { + final AuthStatus status; + final AuthUser? user; + final String? errorMessage; + final bool isFirebaseConfigured; + + const AuthState({ + this.status = AuthStatus.initial, + this.user, + this.errorMessage, + this.isFirebaseConfigured = true, + }); + + const AuthState.initial() + : status = AuthStatus.initial, + user = null, + errorMessage = null, + isFirebaseConfigured = true; + + const AuthState.loading({this.user, this.isFirebaseConfigured = true}) + : status = AuthStatus.loading, + errorMessage = null; + + const AuthState.authenticated(this.user, {this.isFirebaseConfigured = true}) + : status = AuthStatus.authenticated, + errorMessage = null; + + const AuthState.anonymous(this.user, {this.isFirebaseConfigured = true}) + : status = AuthStatus.anonymous, + errorMessage = null; + + const AuthState.unauthenticated({this.isFirebaseConfigured = true}) + : status = AuthStatus.unauthenticated, + user = null, + errorMessage = null; + + const AuthState.error(String message, {this.user, this.isFirebaseConfigured = true}) + : status = AuthStatus.error, + errorMessage = message; + + /// Convenience getters for UI presentation + bool get isAuthenticated => status == AuthStatus.authenticated && user != null; + bool get isAnonymous => status == AuthStatus.anonymous || (user?.isAnonymous == true); + bool get isUnauthenticated => status == AuthStatus.unauthenticated || status == AuthStatus.initial; + bool get isLoading => status == AuthStatus.loading; + bool get hasError => status == AuthStatus.error && errorMessage != null; + + AuthState copyWith({ + AuthStatus? status, + AuthUser? user, + String? errorMessage, + bool? isFirebaseConfigured, + bool clearError = false, + }) { + return AuthState( + status: status ?? this.status, + user: user ?? this.user, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + isFirebaseConfigured: isFirebaseConfigured ?? this.isFirebaseConfigured, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AuthState && + runtimeType == other.runtimeType && + status == other.status && + user == other.user && + errorMessage == other.errorMessage && + isFirebaseConfigured == other.isFirebaseConfigured; + + @override + int get hashCode => + status.hashCode ^ + user.hashCode ^ + errorMessage.hashCode ^ + isFirebaseConfigured.hashCode; + + @override + String toString() => + 'AuthState(status: $status, user: $user, error: $errorMessage, configured: $isFirebaseConfigured)'; +} diff --git a/lib/services/auth/auth_user.dart b/lib/services/auth/auth_user.dart new file mode 100644 index 0000000..c0ddf34 --- /dev/null +++ b/lib/services/auth/auth_user.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; + +/// Immutable application-level user identity representation. +/// Keeps Firebase types out of the presentation and telemetry layers. +@immutable +class AuthUser { + final String uid; + final String? email; + final String? displayName; + final String? photoUrl; + final bool isAnonymous; + final String? providerId; + final DateTime? createdAt; + final DateTime? lastSignInTime; + + const AuthUser({ + required this.uid, + this.email, + this.displayName, + this.photoUrl, + this.isAnonymous = false, + this.providerId, + this.createdAt, + this.lastSignInTime, + }); + + /// True if the user has an authenticated email or external identity + bool get hasIdentifiedAccount => !isAnonymous && (email != null || displayName != null); + + /// Formatted display name fallback + String get displayLabel { + if (displayName != null && displayName!.isNotEmpty) return displayName!; + if (email != null && email!.isNotEmpty) return email!; + if (isAnonymous) return 'Anonymous Rider'; + return 'Rider ($uid)'; + } + + Map toJson() => { + 'uid': uid, + 'email': email, + 'displayName': displayName, + 'photoUrl': photoUrl, + 'isAnonymous': isAnonymous, + 'providerId': providerId, + 'createdAt': createdAt?.toIso8601String(), + 'lastSignInTime': lastSignInTime?.toIso8601String(), + }; + + factory AuthUser.fromJson(Map json) => AuthUser( + uid: json['uid'] as String? ?? '', + email: json['email'] as String?, + displayName: json['displayName'] as String?, + photoUrl: json['photoUrl'] as String?, + isAnonymous: json['isAnonymous'] as bool? ?? false, + providerId: json['providerId'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'] as String) + : null, + lastSignInTime: json['lastSignInTime'] != null + ? DateTime.tryParse(json['lastSignInTime'] as String) + : null, + ); + + AuthUser copyWith({ + String? uid, + String? email, + String? displayName, + String? photoUrl, + bool? isAnonymous, + String? providerId, + DateTime? createdAt, + DateTime? lastSignInTime, + }) { + return AuthUser( + uid: uid ?? this.uid, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + photoUrl: photoUrl ?? this.photoUrl, + isAnonymous: isAnonymous ?? this.isAnonymous, + providerId: providerId ?? this.providerId, + createdAt: createdAt ?? this.createdAt, + lastSignInTime: lastSignInTime ?? this.lastSignInTime, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AuthUser && + runtimeType == other.runtimeType && + uid == other.uid && + email == other.email && + displayName == other.displayName && + isAnonymous == other.isAnonymous && + providerId == other.providerId; + + @override + int get hashCode => + uid.hashCode ^ + email.hashCode ^ + displayName.hashCode ^ + isAnonymous.hashCode ^ + providerId.hashCode; + + @override + String toString() => + 'AuthUser(uid: $uid, email: $email, name: $displayName, anon: $isAnonymous, provider: $providerId)'; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9114d26..f7c8640 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,8 +6,11 @@ import FlutterMacOS import Foundation import app_links +import firebase_auth +import firebase_core import flutter_tts import geolocator_apple +import google_sign_in_ios import package_info_plus import share_plus import shared_preferences_foundation @@ -16,8 +19,11 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 66b705f..4969371 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "100.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" analyzer: dependency: transitive description: @@ -313,6 +321,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" + url: "https://pub.dev" + source: hosted + version: "7.7.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e + url: "https://pub.dev" + source: hosted + version: "5.15.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" fixnum: dependency: transitive description: @@ -485,6 +541,14 @@ packages: url: "https://pub.dev" source: hosted version: "18.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" google_maps: dependency: transitive description: @@ -533,6 +597,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.3" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ec7d30..73e1988 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,9 @@ dependencies: url_launcher: ^6.3.1 crypto: ^3.0.6 app_links: ^6.3.2 + firebase_core: ^3.12.1 + firebase_auth: ^5.5.1 + google_sign_in: ^6.2.2 dev_dependencies: flutter_test: diff --git a/test/auth_test.dart b/test/auth_test.dart new file mode 100644 index 0000000..7fa9e22 --- /dev/null +++ b/test/auth_test.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; + +/// In-memory Mock AuthService for unit testing without live network / Firebase credentials +class FakeAuthService implements AuthService { + final _stateController = StreamController.broadcast(); + AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true); + bool _isConfigured = true; + + @override + Stream get authStateChanges => _stateController.stream; + + @override + AuthState get currentState => _currentState; + + @override + AuthUser? get currentUser => _currentState.user; + + @override + bool get isConfigured => _isConfigured; + + void setConfigured(bool val) { + _isConfigured = val; + if (!val) { + _emit(const AuthState.unauthenticated(isFirebaseConfigured: false)); + } + } + + void _emit(AuthState state) { + _currentState = state; + _stateController.add(state); + } + + @override + Future signInWithEmailAndPassword(String email, String password) async { + if (!_isConfigured) { + _emit(const AuthState.error( + 'Cloud authentication unavailable in offline mode.', + isFirebaseConfigured: false, + )); + return; + } + + _emit(const AuthState.loading(isFirebaseConfigured: true)); + if (email == 'rider@motorsignal.app' && password == 'superSecret123') { + final user = AuthUser( + uid: 'user_12345', + email: email, + displayName: 'Track Rider', + providerId: 'password', + createdAt: DateTime(2026, 1, 1), + ); + _emit(AuthState.authenticated(user, isFirebaseConfigured: true)); + } else { + _emit(const AuthState.error( + 'Invalid credentials. Please verify your email and password.', + isFirebaseConfigured: true, + )); + } + } + + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async { + if (!_isConfigured) { + _emit(const AuthState.error( + 'Registration unavailable offline.', + isFirebaseConfigured: false, + )); + return; + } + + _emit(const AuthState.loading(isFirebaseConfigured: true)); + if (email.contains('@')) { + final user = AuthUser( + uid: 'new_user_${DateTime.now().millisecondsSinceEpoch}', + email: email, + displayName: displayName ?? 'New Rider', + providerId: 'password', + createdAt: DateTime.now(), + ); + _emit(AuthState.authenticated(user, isFirebaseConfigured: true)); + } else { + _emit(const AuthState.error('The email address format is invalid.', isFirebaseConfigured: true)); + } + } + + @override + Future signInWithGoogle() async { + if (!_isConfigured) { + _emit(const AuthState.error( + 'Google Sign-In unavailable without configuration.', + isFirebaseConfigured: false, + )); + return; + } + + _emit(const AuthState.loading(isFirebaseConfigured: true)); + final user = AuthUser( + uid: 'google_rider_99', + email: 'pilot@gmail.com', + displayName: 'Apex Pilot', + photoUrl: 'https://lh3.googleusercontent.com/a/avatar.png', + providerId: 'google.com', + createdAt: DateTime(2026, 3, 15), + ); + _emit(AuthState.authenticated(user, isFirebaseConfigured: true)); + } + + @override + Future signInAnonymously() async { + _emit(const AuthState.loading(isFirebaseConfigured: true)); + final guest = AuthUser( + uid: 'anon_guest_777', + isAnonymous: true, + providerId: 'anonymous', + ); + _emit(AuthState.anonymous(guest, isFirebaseConfigured: _isConfigured)); + } + + @override + Future sendPasswordResetEmail(String email) async { + if (!email.contains('@')) { + _emit(const AuthState.error('The email address format is invalid.')); + } + } + + @override + Future signOut() async { + _emit(AuthState.unauthenticated(isFirebaseConfigured: _isConfigured)); + } + + @override + void dispose() { + _stateController.close(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MotorSignal Phase 1 — Authentication & Identity Abstraction Tests', () { + test('1. AuthUser Model Serialization & Attributes', () { + final now = DateTime(2026, 8, 31, 10, 0, 0); + final user = AuthUser( + uid: 'usr_8821', + email: 'ducati_rider@motorsignal.app', + displayName: 'Desmo Pilot', + photoUrl: 'https://motorsignal.app/avatar.png', + isAnonymous: false, + providerId: 'password', + createdAt: now, + lastSignInTime: now, + ); + + expect(user.uid, equals('usr_8821')); + expect(user.hasIdentifiedAccount, isTrue); + expect(user.displayLabel, equals('Desmo Pilot')); + + final json = user.toJson(); + final reconstructed = AuthUser.fromJson(json); + + expect(reconstructed.uid, equals(user.uid)); + expect(reconstructed.email, equals(user.email)); + expect(reconstructed.displayName, equals(user.displayName)); + expect(reconstructed.isAnonymous, isFalse); + expect(reconstructed.providerId, equals('password')); + }); + + test('2. AuthUser Anonymous & Fallback Display Labels', () { + const anonUser = AuthUser( + uid: 'anon_3312', + isAnonymous: true, + ); + expect(anonUser.hasIdentifiedAccount, isFalse); + expect(anonUser.displayLabel, equals('Anonymous Rider')); + + const emailOnlyUser = AuthUser( + uid: 'uid_554', + email: 'r6@track.org', + ); + expect(emailOnlyUser.displayLabel, equals('r6@track.org')); + + const rawUser = AuthUser(uid: 'uid_999'); + expect(rawUser.displayLabel, equals('Rider (uid_999)')); + }); + + test('3. AuthState Status Flags & Immutability', () { + const initialState = AuthState.initial(); + expect(initialState.isUnauthenticated, isTrue); + expect(initialState.isAuthenticated, isFalse); + expect(initialState.isLoading, isFalse); + + final user = AuthUser(uid: 'u1', email: 'test@motor.com'); + final authedState = AuthState.authenticated(user); + expect(authedState.isAuthenticated, isTrue); + expect(authedState.isAnonymous, isFalse); + expect(authedState.user?.email, equals('test@motor.com')); + + final errorState = AuthState.error('Network failure'); + expect(errorState.hasError, isTrue); + expect(errorState.errorMessage, equals('Network failure')); + + final clearedError = errorState.copyWith(clearError: true); + expect(clearedError.hasError, isFalse); + expect(clearedError.errorMessage, isNull); + }); + + test('4. Riverpod AuthNotifier & State Transitions', () async { + final fakeAuth = FakeAuthService(); + final container = ProviderContainer( + overrides: [ + authServiceProvider.overrideWithValue(fakeAuth), + ], + ); + addTearDown(container.dispose); + + // Initial state + expect(container.read(isAuthenticatedProvider), isFalse); + expect(container.read(currentUserProvider), isNull); + + // Successful sign in + final notifier = container.read(authNotifierProvider.notifier); + await notifier.signInWithEmailAndPassword('rider@motorsignal.app', 'superSecret123'); + + expect(container.read(isAuthenticatedProvider), isTrue); + expect(container.read(currentUserProvider)?.email, equals('rider@motorsignal.app')); + expect(container.read(currentUserProvider)?.displayName, equals('Track Rider')); + + // Sign out + await notifier.signOut(); + expect(container.read(isAuthenticatedProvider), isFalse); + expect(container.read(currentUserProvider), isNull); + + // Google sign-in + await notifier.signInWithGoogle(); + expect(container.read(isAuthenticatedProvider), isTrue); + expect(container.read(currentUserProvider)?.providerId, equals('google.com')); + + // Guest / Anonymous mode + await notifier.signInAnonymously(); + expect(container.read(isAnonymousUserProvider), isTrue); + expect(container.read(isAuthenticatedProvider), isFalse); + + // Failed sign in + await notifier.signInWithEmailAndPassword('wrong@email.com', 'badpass'); + expect(container.read(authNotifierProvider).hasError, isTrue); + expect( + container.read(authNotifierProvider).errorMessage, + contains('Invalid credentials'), + ); + }); + + test('5. Unconfigured Offline Fallback Mode Handling', () async { + final fakeAuth = FakeAuthService(); + fakeAuth.setConfigured(false); + + final container = ProviderContainer( + overrides: [ + authServiceProvider.overrideWithValue(fakeAuth), + ], + ); + addTearDown(container.dispose); + + final state = container.read(authNotifierProvider); + expect(state.isFirebaseConfigured, isFalse); + expect(state.isUnauthenticated, isTrue); + + final notifier = container.read(authNotifierProvider.notifier); + await notifier.signInWithEmailAndPassword('pilot@motorsignal.app', 'pass123'); + + expect(container.read(authNotifierProvider).hasError, isTrue); + expect( + container.read(authNotifierProvider).errorMessage, + contains('offline mode'), + ); + }); + + test('6. Authoritative Telemetry & Drift SQLite Isolation Guarantee', () async { + // Confirm that local Drift database operates seamlessly with zero auth + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + + final startTime = DateTime(2026, 8, 31, 8, 0, 0); + final rideId = await db.into(db.rides).insert(RidesCompanion.insert( + startTime: startTime, + distanceMiles: const drift.Value(12.4), + topSpeedMph: const drift.Value(68.5), + maxLeanLeft: const drift.Value(24.2), + maxLeanRight: const drift.Value(28.7), + maxAccelG: const drift.Value(0.55), + maxBrakingG: const drift.Value(-0.85), + )); + + expect(rideId, isPositive); + + // Verify unauthenticated ride was persisted locally to Drift Schema 5 + final rides = await db.select(db.rides).get(); + expect(rides.length, equals(1)); + expect(rides.first.id, equals(rideId)); + expect(rides.first.topSpeedMph, equals(68.5)); + expect(rides.first.maxLeanRight, equals(28.7)); + + // Verify 20 Hz sub-second samples persist locally without any auth checks + final sampleTime = startTime.add(const Duration(milliseconds: 50)); + await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert( + rideId: rideId, + timestamp: sampleTime, + latitude: const drift.Value(37.7749), + longitude: const drift.Value(-122.4194), + speed: const drift.Value(68.5), + leanAngle: const drift.Value(28.7), + longitudinalG: const drift.Value(0.2), + lateralG: const drift.Value(0.4), + )); + + final samples = await db.select(db.telemetrySamples).get(); + expect(samples.length, equals(1)); + expect(samples.first.speed, equals(68.5)); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index fadbe2a..7f01f5b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,8 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include #include @@ -16,6 +18,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FirebaseAuthPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterTtsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterTtsPlugin")); GeolocatorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 21753c4..bc23697 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + firebase_auth + firebase_core flutter_tts geolocator_windows permission_handler_windows