feat(auth): add optional authentication and account foundation

This commit is contained in:
Developer 2026-08-31 01:27:58 -04:00
parent 00819ec176
commit f6ba2e8e9d
16 changed files with 2052 additions and 1 deletions

6
.gitignore vendored
View File

@ -45,8 +45,12 @@ app.*.map.json
/android/app/release /android/app/release
*.apk *.apk
# Local Agent / IDE cache # Local Agent / IDE cache & scratch artifacts
.agent/ .agent/
.agents/ .agents/
.agent-backup/
node_modules/ node_modules/
**/node_modules/ **/node_modules/
scratch/
flutter-doctor.txt
gemini-mcp-config-search.txt

View File

@ -15,6 +15,7 @@ import '../features/settings/field_test_report_screen.dart';
import '../features/settings/field_test_live_log_screen.dart'; import '../features/settings/field_test_live_log_screen.dart';
import '../features/field_test/field_test_pre_ride_check_screen.dart'; import '../features/field_test/field_test_pre_ride_check_screen.dart';
import '../features/settings/oauth_callback_screen.dart'; import '../features/settings/oauth_callback_screen.dart';
import '../features/settings/account_screen.dart';
// ignore: unused_element // ignore: unused_element
final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _rootNavigatorKey = GlobalKey<NavigatorState>();
@ -115,6 +116,10 @@ final appRouter = GoRouter(
path: '/settings', path: '/settings',
builder: (context, state) => const SettingsScreen(), builder: (context, state) => const SettingsScreen(),
routes: [ routes: [
GoRoute(
path: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute( GoRoute(
path: 'diagnostics', path: 'diagnostics',
builder: (context, state) => const DiagnosticsScreen(), builder: (context, state) => const DiagnosticsScreen(),

View File

@ -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<AccountScreen> createState() => _AccountScreenState();
}
enum _AuthFormMode { signIn, register, forgotPassword }
class _AccountScreenState extends ConsumerState<AccountScreen> {
final _formKey = GlobalKey<FormState>();
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';
});
}
}
}
}

View File

@ -14,6 +14,7 @@ import '../../core/app_info.dart';
import '../../services/field_test/field_test_logger.dart'; import '../../services/field_test/field_test_logger.dart';
import '../../core/theme/evolution_theme_config.dart'; import '../../core/theme/evolution_theme_config.dart';
import '../../services/osm_oauth_service.dart'; import '../../services/osm_oauth_service.dart';
import '../../services/auth/auth_providers.dart';
import 'widgets/evolution_settings_view.dart'; import 'widgets/evolution_settings_view.dart';
class SettingsScreen extends ConsumerStatefulWidget { class SettingsScreen extends ConsumerStatefulWidget {
@ -72,6 +73,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ 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'), _buildSectionHeader('Dashboard UI Theme & Design'),
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(

View File

@ -12,6 +12,7 @@ import '../../../services/sensor_fusion_service.dart';
import '../../../services/field_test/field_test_logger.dart'; import '../../../services/field_test/field_test_logger.dart';
import '../../../database/database.dart'; import '../../../database/database.dart';
import '../../rides/ride_state_machine.dart'; import '../../rides/ride_state_machine.dart';
import '../../../services/auth/auth_providers.dart';
import '../../../core/theme/evolution_theme_config.dart'; import '../../../core/theme/evolution_theme_config.dart';
import '../../../core/app_info.dart'; import '../../../core/app_info.dart';
@ -143,6 +144,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
), ),
); );
final accountCard = _buildAccountCard(context, ref);
final osmAccountCard = _buildOsmAccountCard(context, ref, osmAuth); final osmAccountCard = _buildOsmAccountCard(context, ref, osmAuth);
final mapEngineCard = _buildMapEngineCard(context, ref, mapType); final mapEngineCard = _buildMapEngineCard(context, ref, mapType);
final alertConfigCard = _buildAlertConfigCard( final alertConfigCard = _buildAlertConfigCard(
@ -174,6 +176,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
accountCard,
const SizedBox(height: 16),
sensorsCard, sensorsCard,
const SizedBox(height: 16), const SizedBox(height: 16),
mapEngineCard, mapEngineCard,
@ -215,6 +219,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
themeToggleBar, themeToggleBar,
accountCard,
const SizedBox(height: 16),
sensorsCard, sensorsCard,
const SizedBox(height: 16), const SizedBox(height: 16),
engineeringCard, engineeringCard,
@ -241,6 +247,140 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
); );
} }
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) { Widget _buildOsmAccountCard(BuildContext context, WidgetRef ref, OsmAuthState osmAuth) {
final isAuthed = osmAuth.isAuthenticated; final isAuthed = osmAuth.isAuthenticated;
final user = osmAuth.user; final user = osmAuth.user;

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:firebase_core/firebase_core.dart';
import 'core/theme/theme.dart'; import 'core/theme/theme.dart';
import 'core/router.dart'; import 'core/router.dart';
import 'features/records/records_service.dart'; import 'features/records/records_service.dart';
@ -11,6 +12,13 @@ import 'services/osm_oauth_service.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); 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(); final sharedPreferences = await SharedPreferences.getInstance();
runApp( runApp(

View File

@ -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<AuthService>((ref) {
final service = FirebaseAuthService();
ref.onDispose(() => service.dispose());
return service;
});
/// Reactive StateNotifier managing the authentication state for the UI
class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService;
StreamSubscription<AuthState>? _subscription;
AuthNotifier(this._authService) : super(_authService.currentState) {
_subscription = _authService.authStateChanges.listen((state) {
this.state = state;
});
}
Future<void> signInWithEmailAndPassword(String email, String password) async {
await _authService.signInWithEmailAndPassword(email, password);
state = _authService.currentState;
}
Future<void> createUserWithEmailAndPassword(
String email,
String password, {
String? displayName,
}) async {
await _authService.createUserWithEmailAndPassword(
email,
password,
displayName: displayName,
);
state = _authService.currentState;
}
Future<void> signInWithGoogle() async {
await _authService.signInWithGoogle();
state = _authService.currentState;
}
Future<void> signInAnonymously() async {
await _authService.signInAnonymously();
state = _authService.currentState;
}
Future<void> sendPasswordResetEmail(String email) async {
await _authService.sendPasswordResetEmail(email);
state = _authService.currentState;
}
Future<void> 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<AuthNotifier, AuthState>((ref) {
final service = ref.watch(authServiceProvider);
return AuthNotifier(service);
});
/// Convenience provider returning the active AuthUser or null
final currentUserProvider = Provider<AuthUser?>((ref) {
return ref.watch(authNotifierProvider).user;
});
/// Convenience provider returning whether a non-anonymous user is signed in
final isAuthenticatedProvider = Provider<bool>((ref) {
return ref.watch(authNotifierProvider).isAuthenticated;
});
/// Convenience provider returning whether the user is in anonymous/local mode
final isAnonymousUserProvider = Provider<bool>((ref) {
return ref.watch(authNotifierProvider).isAnonymous;
});

View File

@ -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<AuthState> get authStateChanges;
AuthState get currentState;
AuthUser? get currentUser;
bool get isConfigured;
Future<void> signInWithEmailAndPassword(String email, String password);
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
Future<void> signInWithGoogle();
Future<void> signInAnonymously();
Future<void> sendPasswordResetEmail(String email);
Future<void> signOut();
void dispose();
}
/// Authoritative Firebase Authentication Service
class FirebaseAuthService implements AuthService {
final fb.FirebaseAuth? _firebaseAuth;
final GoogleSignIn? _googleSignIn;
final _stateController = StreamController<AuthState>.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<AuthState> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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();
}
}

View File

@ -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)';
}

View File

@ -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<String, dynamic> toJson() => {
'uid': uid,
'email': email,
'displayName': displayName,
'photoUrl': photoUrl,
'isAnonymous': isAnonymous,
'providerId': providerId,
'createdAt': createdAt?.toIso8601String(),
'lastSignInTime': lastSignInTime?.toIso8601String(),
};
factory AuthUser.fromJson(Map<String, dynamic> 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)';
}

View File

@ -6,8 +6,11 @@ import FlutterMacOS
import Foundation import Foundation
import app_links import app_links
import firebase_auth
import firebase_core
import flutter_tts import flutter_tts
import geolocator_apple import geolocator_apple
import google_sign_in_ios
import package_info_plus import package_info_plus
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@ -16,8 +19,11 @@ import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) 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")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "100.0.0" 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: analyzer:
dependency: transitive dependency: transitive
description: description:
@ -313,6 +321,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -485,6 +541,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "18.0.0" 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: google_maps:
dependency: transitive dependency: transitive
description: description:
@ -533,6 +597,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.3" 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: graphs:
dependency: transitive dependency: transitive
description: description:

View File

@ -59,6 +59,9 @@ dependencies:
url_launcher: ^6.3.1 url_launcher: ^6.3.1
crypto: ^3.0.6 crypto: ^3.0.6
app_links: ^6.3.2 app_links: ^6.3.2
firebase_core: ^3.12.1
firebase_auth: ^5.5.1
google_sign_in: ^6.2.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

333
test/auth_test.dart Normal file
View File

@ -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<AuthState>.broadcast();
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
bool _isConfigured = true;
@override
Stream<AuthState> 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<void> 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<void> 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<void> 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<void> 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<void> sendPasswordResetEmail(String email) async {
if (!email.contains('@')) {
_emit(const AuthState.error('The email address format is invalid.'));
}
}
@override
Future<void> 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));
});
});
}

View File

@ -7,6 +7,8 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h> #include <app_links/app_links_plugin_c_api.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_tts/flutter_tts_plugin.h> #include <flutter_tts/flutter_tts_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
@ -16,6 +18,10 @@
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar( AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi")); registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterTtsPluginRegisterWithRegistrar( FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin")); registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(

View File

@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
firebase_auth
firebase_core
flutter_tts flutter_tts
geolocator_windows geolocator_windows
permission_handler_windows permission_handler_windows