feat(auth): add optional authentication and account foundation
This commit is contained in:
parent
00819ec176
commit
f6ba2e8e9d
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
104
pubspec.lock
104
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue