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
|
||||
*.apk
|
||||
|
||||
# Local Agent / IDE cache
|
||||
# Local Agent / IDE cache & scratch artifacts
|
||||
.agent/
|
||||
.agents/
|
||||
.agent-backup/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
scratch/
|
||||
flutter-doctor.txt
|
||||
gemini-mcp-config-search.txt
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import '../features/settings/field_test_report_screen.dart';
|
|||
import '../features/settings/field_test_live_log_screen.dart';
|
||||
import '../features/field_test/field_test_pre_ride_check_screen.dart';
|
||||
import '../features/settings/oauth_callback_screen.dart';
|
||||
import '../features/settings/account_screen.dart';
|
||||
|
||||
// ignore: unused_element
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -115,6 +116,10 @@ final appRouter = GoRouter(
|
|||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'account',
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'diagnostics',
|
||||
builder: (context, state) => const DiagnosticsScreen(),
|
||||
|
|
|
|||
|
|
@ -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 '../../core/theme/evolution_theme_config.dart';
|
||||
import '../../services/osm_oauth_service.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import 'widgets/evolution_settings_view.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -72,6 +73,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildSectionHeader('Account & Cloud Identity'),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final authState = ref.watch(authNotifierProvider);
|
||||
final isAuthed = authState.isAuthenticated;
|
||||
final user = authState.user;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.go('/settings/account'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isAuthed ? Icons.account_circle : Icons.account_circle_outlined,
|
||||
color: Colors.cyanAccent,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isAuthed ? (user?.displayLabel ?? 'Rider Account') : 'Sign In / Cloud Account',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isAuthed
|
||||
? (user?.email ?? 'Cloud Identity Active • Offline rides protected')
|
||||
: 'Optional cloud identity • Local riding always active',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: isAuthed ? Colors.cyanAccent : Colors.white24),
|
||||
),
|
||||
child: Text(
|
||||
isAuthed ? 'SIGNED IN' : 'LOCAL',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isAuthed ? Colors.cyanAccent : Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right, color: Colors.white54, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('Dashboard UI Theme & Design'),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../../services/sensor_fusion_service.dart';
|
|||
import '../../../services/field_test/field_test_logger.dart';
|
||||
import '../../../database/database.dart';
|
||||
import '../../rides/ride_state_machine.dart';
|
||||
import '../../../services/auth/auth_providers.dart';
|
||||
import '../../../core/theme/evolution_theme_config.dart';
|
||||
import '../../../core/app_info.dart';
|
||||
|
||||
|
|
@ -143,6 +144,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
),
|
||||
);
|
||||
|
||||
final accountCard = _buildAccountCard(context, ref);
|
||||
final osmAccountCard = _buildOsmAccountCard(context, ref, osmAuth);
|
||||
final mapEngineCard = _buildMapEngineCard(context, ref, mapType);
|
||||
final alertConfigCard = _buildAlertConfigCard(
|
||||
|
|
@ -174,6 +176,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
accountCard,
|
||||
const SizedBox(height: 16),
|
||||
sensorsCard,
|
||||
const SizedBox(height: 16),
|
||||
mapEngineCard,
|
||||
|
|
@ -215,6 +219,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
themeToggleBar,
|
||||
accountCard,
|
||||
const SizedBox(height: 16),
|
||||
sensorsCard,
|
||||
const SizedBox(height: 16),
|
||||
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) {
|
||||
final isAuthed = osmAuth.isAuthenticated;
|
||||
final user = osmAuth.user;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'core/theme/theme.dart';
|
||||
import 'core/router.dart';
|
||||
import 'features/records/records_service.dart';
|
||||
|
|
@ -11,6 +12,13 @@ import 'services/osm_oauth_service.dart';
|
|||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Optional Firebase initialization (gracefully defaults to standalone offline mode if unconfigured)
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (e) {
|
||||
debugPrint('[Firebase] Standalone offline mode active: $e');
|
||||
}
|
||||
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
|
||||
runApp(
|
||||
|
|
|
|||
|
|
@ -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 app_links
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import flutter_tts
|
||||
import geolocator_apple
|
||||
import google_sign_in_ios
|
||||
import package_info_plus
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
|
|
@ -16,8 +19,11 @@ import wakelock_plus
|
|||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
|
|
|||
104
pubspec.lock
104
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "100.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -313,6 +321,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_auth
|
||||
sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.0"
|
||||
firebase_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_auth_platform_interface
|
||||
sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.7.3"
|
||||
firebase_auth_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_auth_web
|
||||
sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.3"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.15.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -485,6 +541,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.0"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -533,6 +597,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.3"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4+4"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ dependencies:
|
|||
url_launcher: ^6.3.1
|
||||
crypto: ^3.0.6
|
||||
app_links: ^6.3.2
|
||||
firebase_core: ^3.12.1
|
||||
firebase_auth: ^5.5.1
|
||||
google_sign_in: ^6.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -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 <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 <geolocator_windows/geolocator_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
|
|
@ -16,6 +18,10 @@
|
|||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterTtsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
firebase_auth
|
||||
firebase_core
|
||||
flutter_tts
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
|
|
|
|||
Loading…
Reference in New Issue