CHECKPOINT: Working signed release build with Google authentication

This commit is contained in:
Developer 2026-08-31 06:43:21 -04:00
parent 78d3d45570
commit 81064da7f4
20 changed files with 1255 additions and 147 deletions

6
.gitignore vendored
View File

@ -54,3 +54,9 @@ node_modules/
scratch/ scratch/
flutter-doctor.txt flutter-doctor.txt
gemini-mcp-config-search.txt gemini-mcp-config-search.txt
# Android signing credentials & local properties
*.jks
*.keystore
android/key.properties
android/local.properties

View File

@ -0,0 +1,74 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
true
} else {
false
}
android {
namespace = "app.motorsignal.motorcycle"
compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "app.motorsignal.motorcycle"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (hasReleaseKeystore) {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
val storeFilePath = keystoreProperties.getProperty("storeFile")
storeFile = rootProject.file(storeFilePath)
storePassword = keystoreProperties.getProperty("storePassword")
storeType = "PKCS12"
}
// If hasReleaseKeystore is false, properties are intentionally left null.
// The release buildType below will throw before the signing step is reached.
}
}
buildTypes {
release {
if (!hasReleaseKeystore) {
throw GradleException(
"Release build requires android/key.properties with production keystore credentials. " +
"Copy android/key.properties.template to android/key.properties and fill in your keystore details."
)
}
signingConfig = signingConfigs.getByName("release")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,92 @@
{
"project_info": {
"project_number": "809431485362",
"project_id": "motoapp-d22e0",
"storage_bucket": "motoapp-d22e0.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:809431485362:android:06fdcd9ca12ff9342472ab",
"android_client_info": {
"package_name": "app.motorsignal.motorcycle"
}
},
"oauth_client": [
{
"client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "app.motorsignal.motorcycle",
"certificate_hash": "251eff25a99d0687e10e3f021f5d3c6e21d9bb7d"
}
},
{
"client_id": "809431485362-g7tpuiersfc47e8qfbk1pe8bihu01pbk.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "app.motorsignal.motorcycle",
"certificate_hash": "07105037193d1bed3f53924d0f50b37cab435f0e"
}
},
{
"client_id": "809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:809431485362:android:27df3f15fc35c1152472ab",
"android_client_info": {
"package_name": "com.example.motorcycle_dash"
}
},
"oauth_client": [
{
"client_id": "809431485362-s1k8u0brkm6mqf9a4fgfj345rdscifd9.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.motorcycle_dash",
"certificate_hash": "251eff25a99d0687e10e3f021f5d3c6e21d9bb7d"
}
},
{
"client_id": "809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@ -10,7 +10,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="motorcycle_dash" android:label="MotorSignal"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/launcher_icon"> android:icon="@mipmap/launcher_icon">
<activity <activity

View File

@ -1,4 +1,4 @@
package com.example.motorcycle_dash package app.motorsignal.motorcycle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false # This builtInKotlin flag was added automatically by Flutter migrator
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
} }
include(":app") include(":app")

View File

@ -52,7 +52,7 @@ class DashboardScreen extends ConsumerWidget {
child: Column( child: Column(
children: [ children: [
if (!hasLocPermission) _buildPermissionBanner(context, ref), if (!hasLocPermission) _buildPermissionBanner(context, ref),
if (closestAlert != null) _buildAlertBanner(closestAlert), if (closestAlert != null) _buildAlertBanner(context, closestAlert, ref),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -102,10 +102,10 @@ class DashboardScreen extends ConsumerWidget {
); );
} }
Widget _buildAlertBanner(RoadAlert alert) { Widget _buildAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2), color: alert.category.color.withValues(alpha: 0.2),
border: Border(bottom: BorderSide(color: alert.category.color, width: 2)), border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
@ -120,15 +120,36 @@ class DashboardScreen extends ConsumerWidget {
children: [ children: [
Text( Text(
alert.category.displayName.toUpperCase(), alert.category.displayName.toUpperCase(),
style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.2), overflow: TextOverflow.ellipsis,
style: TextStyle(
color: alert.category.color,
fontWeight: FontWeight.bold,
fontSize: 15,
letterSpacing: 1.1,
),
), ),
Text( Text(
'${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead', '${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead • ${alert.roadName ?? 'Reported'}',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
), ),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Dismiss alert',
onPressed: () {
ref.read(roadAlertServiceProvider).dismissAlert(alert.id);
},
),
], ],
), ),
); );

View File

@ -15,12 +15,16 @@ class OAuthCallbackScreen extends ConsumerStatefulWidget {
class _OAuthCallbackScreenState extends ConsumerState<OAuthCallbackScreen> { class _OAuthCallbackScreenState extends ConsumerState<OAuthCallbackScreen> {
String _status = 'Connecting to OpenStreetMap...'; String _status = 'Connecting to OpenStreetMap...';
bool _isSuccess = false; bool _isSuccess = false;
bool _hasProcessed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_processAuth(); if (!_hasProcessed) {
_hasProcessed = true;
_processAuth();
}
}); });
} }

View File

@ -289,10 +289,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( const Expanded(
'OpenStreetMap Account', child: Text(
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13), 'OpenStreetMap Account',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
), ),
const SizedBox(width: 8),
Chip( Chip(
label: Text( label: Text(
isAuthed ? 'Connected' : 'Disconnected', isAuthed ? 'Connected' : 'Disconnected',

View File

@ -106,24 +106,32 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.palette_outlined, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.palette_outlined, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'ACTIVE THEME:', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Inter', 'ACTIVE THEME:',
fontSize: 11, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Colors.white70, fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w800,
color: Colors.white70,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
SegmentedButton<DashboardThemeMode>( SegmentedButton<DashboardThemeMode>(
showSelectedIcon: false,
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
@ -268,22 +276,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.account_circle, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.account_circle, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'MOTORCLOUD ACCOUNT', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Sora', 'MOTORCLOUD ACCOUNT',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Color(0xFF00E5FF), fontFamily: 'Sora',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -398,22 +412,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.public, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.public, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'OPENSTREETMAP ACCOUNT', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Sora', 'OPENSTREETMAP ACCOUNT',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Color(0xFF00E5FF), fontFamily: 'Sora',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -775,14 +795,17 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
children: [ children: [
Icon(Icons.screen_rotation, color: Color(0xFF00E5FF), size: 18), Icon(Icons.screen_rotation, color: Color(0xFF00E5FF), size: 18),
SizedBox(width: 8), SizedBox(width: 8),
Text( Expanded(
'SENSORS & MOUNT CALIBRATION', child: Text(
style: TextStyle( 'SENSORS & MOUNT CALIBRATION',
fontFamily: 'Inter', overflow: TextOverflow.ellipsis,
fontSize: 12, style: TextStyle(
fontWeight: FontWeight.w800, fontFamily: 'Inter',
color: Colors.white, fontSize: 12,
letterSpacing: 0.1, fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
), ),
), ),
], ],
@ -983,22 +1006,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Expanded(
children: [ child: Row(
Icon(Icons.biotech, color: isEnabled ? Colors.greenAccent : const Color(0xFF00E5FF), size: 18), children: [
const SizedBox(width: 8), Icon(Icons.biotech, color: isEnabled ? Colors.greenAccent : const Color(0xFF00E5FF), size: 18),
const Text( const SizedBox(width: 8),
'ENGINEERING & DIAGNOSTICS', const Flexible(
style: TextStyle( child: Text(
fontFamily: 'Inter', 'ENGINEERING & DIAGNOSTICS',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Colors.white, fontFamily: 'Inter',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
Switch( Switch(
value: isEnabled, value: isEnabled,
activeThumbColor: Colors.greenAccent, activeThumbColor: Colors.greenAccent,
@ -1118,22 +1147,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'GITHUB RIDE LOG SYNC', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Inter', 'GITHUB RIDE LOG SYNC',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Colors.white, fontFamily: 'Inter',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -1255,22 +1290,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.traffic, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.traffic, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'TOMTOM TRAFFIC PROVIDER', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Inter', 'TOMTOM TRAFFIC PROVIDER',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Colors.white, fontFamily: 'Inter',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
_buildStatusBadge(tomtomStatus), _buildStatusBadge(tomtomStatus),
], ],
), ),
@ -1352,22 +1393,28 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const Expanded(
children: [ child: Row(
Icon(Icons.local_police, color: Color(0xFF00E5FF), size: 18), children: [
SizedBox(width: 8), Icon(Icons.local_police, color: Color(0xFF00E5FF), size: 18),
Text( SizedBox(width: 8),
'COMMUNITY TRAFFIC PROVIDER', Flexible(
style: TextStyle( child: Text(
fontFamily: 'Inter', 'COMMUNITY TRAFFIC PROVIDER',
fontSize: 12, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w800, style: TextStyle(
color: Colors.white, fontFamily: 'Inter',
letterSpacing: 0.1, fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
),
), ),
), ],
], ),
), ),
const SizedBox(width: 8),
_buildStatusBadge(communityStatus), _buildStatusBadge(communityStatus),
], ],
), ),

46
lib/firebase_options.dart Normal file
View File

@ -0,0 +1,46 @@
// File generated for MotorSignal Firebase Configuration.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
default:
return android;
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
appId: '1:809431485362:android:06fdcd9ca12ff9342472ab',
messagingSenderId: '809431485362',
projectId: 'motoapp-d22e0',
storageBucket: 'motoapp-d22e0.firebasestorage.app',
);
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
appId: '1:809431485362:web:06fdcd9ca12ff9342472ab',
messagingSenderId: '809431485362',
projectId: 'motoapp-d22e0',
storageBucket: 'motoapp-d22e0.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
appId: '1:809431485362:ios:06fdcd9ca12ff9342472ab',
messagingSenderId: '809431485362',
projectId: 'motoapp-d22e0',
storageBucket: 'motoapp-d22e0.firebasestorage.app',
);
}

View File

@ -4,17 +4,19 @@ 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 'package:firebase_core/firebase_core.dart';
import 'firebase_options.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';
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) // Authoritative Firebase initialization
try { try {
await Firebase.initializeApp(); await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
} catch (e) { } catch (e) {
debugPrint('[Firebase] Standalone offline mode active: $e'); debugPrint('[Firebase] Standalone offline mode active: $e');
} }
@ -67,7 +69,6 @@ class _MotorcycleDashAppState extends ConsumerState<MotorcycleDashApp> {
void _handleDeepLink(Uri uri) { void _handleDeepLink(Uri uri) {
final uriStr = uri.toString(); final uriStr = uri.toString();
if (uriStr.contains('callback') || uri.host == 'oauth' || uri.scheme == 'motorsignal') { if (uriStr.contains('callback') || uri.host == 'oauth' || uri.scheme == 'motorsignal') {
ref.read(osmOAuthServiceProvider.notifier).handleAuthCallback(uri);
try { try {
appRouter.go('/oauth/callback?${uri.query}'); appRouter.go('/oauth/callback?${uri.query}');
} catch (_) {} } catch (_) {}

View File

@ -34,12 +34,17 @@ class RoadAlertService {
List<RoadAlert> _cachedAlerts = []; List<RoadAlert> _cachedAlerts = [];
final Set<String> _announcedAlertIds = {}; final Set<String> _announcedAlertIds = {};
final Set<String> _dismissedAlertIds = {};
Timer? _cleanupTimer;
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream; Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
RoadAlertService(this._ref) { RoadAlertService(this._ref) {
_initTts(); _initTts();
_startListening(); _startListening();
_cleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
_pruneExpiredAlerts();
});
} }
void _initTts() { void _initTts() {
@ -308,7 +313,7 @@ class RoadAlertService {
description: 'TEST ALERT: ${category.displayName} reported ahead (Diagnostic Verification)', description: 'TEST ALERT: ${category.displayName} reported ahead (Diagnostic Verification)',
roadName: 'Test Way / Simulation', roadName: 'Test Way / Simulation',
reportedAt: now, reportedAt: now,
expiresAt: now.add(const Duration(minutes: 5)), expiresAt: now.add(const Duration(seconds: 12)),
lastUpdated: now, lastUpdated: now,
distanceFromUser: 150.0, distanceFromUser: 150.0,
source: 'Internal Pipeline Test', source: 'Internal Pipeline Test',
@ -337,6 +342,50 @@ class RoadAlertService {
} }
} }
/// Dismisses a specific alert by ID and prevents it from re-appearing
void dismissAlert(String alertId) {
_dismissedAlertIds.add(alertId);
_cachedAlerts = _cachedAlerts.where((a) => a.id != alertId && '${a.providerId}_${a.id}' != alertId).toList();
_alertsController.add(_cachedAlerts);
if (kDebugMode) {
debugPrint('[RoadAlerts] Manually dismissed alert $alertId');
}
}
/// Clears all cached alerts immediately
void clearAllAlerts() {
_cachedAlerts = [];
_alertsController.add([]);
if (kDebugMode) {
debugPrint('[RoadAlerts] Cleared all cached alerts');
}
}
/// Periodically prunes expired or dismissed alerts even when stationary
void _pruneExpiredAlerts() {
if (_cachedAlerts.isEmpty) return;
final now = DateTime.now();
final initialCount = _cachedAlerts.length;
_cachedAlerts = _cachedAlerts.where((alert) {
final key = '${alert.providerId}_${alert.id}';
if (_dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) {
return false;
}
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
return false;
}
return true;
}).toList();
if (_cachedAlerts.length != initialCount) {
if (kDebugMode) {
debugPrint('[RoadAlerts] Pruned expired/dismissed alerts (remaining: ${_cachedAlerts.length})');
}
_alertsController.add(_cachedAlerts);
}
}
void simulateAlert(RoadAlert alert) { void simulateAlert(RoadAlert alert) {
if (!_ref.read(roadAlertsEnabledProvider)) return; if (!_ref.read(roadAlertsEnabledProvider)) return;
_cachedAlerts = [alert, ..._cachedAlerts]; _cachedAlerts = [alert, ..._cachedAlerts];
@ -350,6 +399,7 @@ class RoadAlertService {
if (kDebugMode) { if (kDebugMode) {
debugPrint('[RoadAlerts] Disposing service resources'); debugPrint('[RoadAlerts] Disposing service resources');
} }
_cleanupTimer?.cancel();
_locationSub?.cancel(); _locationSub?.cancel();
_alertsController.close(); _alertsController.close();
try { try {

View File

@ -26,7 +26,7 @@ abstract class AuthService {
/// Authoritative Firebase Authentication Service /// Authoritative Firebase Authentication Service
class FirebaseAuthService implements AuthService { class FirebaseAuthService implements AuthService {
final fb.FirebaseAuth? _firebaseAuth; fb.FirebaseAuth? _firebaseAuth;
final GoogleSignIn? _googleSignIn; final GoogleSignIn? _googleSignIn;
final _stateController = StreamController<AuthState>.broadcast(); final _stateController = StreamController<AuthState>.broadcast();
@ -60,10 +60,18 @@ class FirebaseAuthService implements AuthService {
AuthUser? get currentUser => _currentState.user; AuthUser? get currentUser => _currentState.user;
@override @override
bool get isConfigured => _firebaseAuth != null; bool get isConfigured {
if (_firebaseAuth != null) return true;
_firebaseAuth = _resolveFirebaseAuth();
if (_firebaseAuth != null && _fbAuthSubscription == null) {
_init();
}
return _firebaseAuth != null;
}
void _init() { void _init() {
if (_firebaseAuth == null) { final auth = _firebaseAuth;
if (auth == null) {
// Firebase is not configured or offline during startup // Firebase is not configured or offline during startup
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: false)); _updateState(const AuthState.unauthenticated(isFirebaseConfigured: false));
return; return;
@ -71,7 +79,7 @@ class FirebaseAuthService implements AuthService {
try { try {
// Listen to native Firebase auth state stream // Listen to native Firebase auth state stream
_fbAuthSubscription = _firebaseAuth.authStateChanges().listen( _fbAuthSubscription = auth.authStateChanges().listen(
(fb.User? user) { (fb.User? user) {
if (user == null) { if (user == null) {
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true)); _updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
@ -125,7 +133,8 @@ class FirebaseAuthService implements AuthService {
@override @override
Future<void> signInWithEmailAndPassword(String email, String password) async { Future<void> signInWithEmailAndPassword(String email, String password) async {
if (!isConfigured || _firebaseAuth == null) { final auth = _firebaseAuth;
if (!isConfigured || auth == null) {
_updateState(AuthState.error( _updateState(AuthState.error(
'Cloud authentication is unavailable in offline/unconfigured mode. Local ride recording is active.', 'Cloud authentication is unavailable in offline/unconfigured mode. Local ride recording is active.',
user: _currentState.user, user: _currentState.user,
@ -140,7 +149,7 @@ class FirebaseAuthService implements AuthService {
)); ));
try { try {
final credential = await _firebaseAuth.signInWithEmailAndPassword( final credential = await auth.signInWithEmailAndPassword(
email: email.trim(), email: email.trim(),
password: password, password: password,
); );
@ -163,7 +172,8 @@ class FirebaseAuthService implements AuthService {
String password, { String password, {
String? displayName, String? displayName,
}) async { }) async {
if (!isConfigured || _firebaseAuth == null) { final auth = _firebaseAuth;
if (!isConfigured || auth == null) {
_updateState(AuthState.error( _updateState(AuthState.error(
'Account registration requires cloud configuration. Local riding remains fully functional.', 'Account registration requires cloud configuration. Local riding remains fully functional.',
user: _currentState.user, user: _currentState.user,
@ -178,7 +188,7 @@ class FirebaseAuthService implements AuthService {
)); ));
try { try {
final credential = await _firebaseAuth.createUserWithEmailAndPassword( final credential = await auth.createUserWithEmailAndPassword(
email: email.trim(), email: email.trim(),
password: password, password: password,
); );
@ -200,7 +210,8 @@ class FirebaseAuthService implements AuthService {
@override @override
Future<void> signInWithGoogle() async { Future<void> signInWithGoogle() async {
if (!isConfigured || _firebaseAuth == null) { final auth = _firebaseAuth;
if (!isConfigured || auth == null) {
_updateState(AuthState.error( _updateState(AuthState.error(
'Google Sign-In is unavailable without Firebase credentials. Local ride features remain available.', 'Google Sign-In is unavailable without Firebase credentials. Local ride features remain available.',
user: _currentState.user, user: _currentState.user,
@ -230,7 +241,7 @@ class FirebaseAuthService implements AuthService {
idToken: googleAuth.idToken, idToken: googleAuth.idToken,
); );
final result = await _firebaseAuth.signInWithCredential(credential); final result = await auth.signInWithCredential(credential);
if (result.user != null) { if (result.user != null) {
final user = _mapFirebaseUser(result.user!); final user = _mapFirebaseUser(result.user!);
_updateState(AuthState.authenticated(user, isFirebaseConfigured: true)); _updateState(AuthState.authenticated(user, isFirebaseConfigured: true));
@ -246,7 +257,8 @@ class FirebaseAuthService implements AuthService {
@override @override
Future<void> signInAnonymously() async { Future<void> signInAnonymously() async {
if (!isConfigured || _firebaseAuth == null) { final auth = _firebaseAuth;
if (!isConfigured || auth == null) {
// Local fallback anonymous user representation // Local fallback anonymous user representation
final localAnon = AuthUser( final localAnon = AuthUser(
uid: 'local_${DateTime.now().millisecondsSinceEpoch}', uid: 'local_${DateTime.now().millisecondsSinceEpoch}',
@ -264,7 +276,7 @@ class FirebaseAuthService implements AuthService {
)); ));
try { try {
final credential = await _firebaseAuth.signInAnonymously(); final credential = await auth.signInAnonymously();
if (credential.user != null) { if (credential.user != null) {
final user = _mapFirebaseUser(credential.user!); final user = _mapFirebaseUser(credential.user!);
_updateState(AuthState.anonymous(user, isFirebaseConfigured: true)); _updateState(AuthState.anonymous(user, isFirebaseConfigured: true));
@ -280,7 +292,8 @@ class FirebaseAuthService implements AuthService {
@override @override
Future<void> sendPasswordResetEmail(String email) async { Future<void> sendPasswordResetEmail(String email) async {
if (!isConfigured || _firebaseAuth == null) { final auth = _firebaseAuth;
if (!isConfigured || auth == null) {
_updateState(AuthState.error( _updateState(AuthState.error(
'Password reset is unavailable in offline mode.', 'Password reset is unavailable in offline mode.',
user: _currentState.user, user: _currentState.user,
@ -290,7 +303,7 @@ class FirebaseAuthService implements AuthService {
} }
try { try {
await _firebaseAuth.sendPasswordResetEmail(email: email.trim()); await auth.sendPasswordResetEmail(email: email.trim());
} catch (e) { } catch (e) {
_updateState(AuthState.error( _updateState(AuthState.error(
_formatAuthError(e), _formatAuthError(e),
@ -302,8 +315,9 @@ class FirebaseAuthService implements AuthService {
@override @override
Future<String?> getIdToken([bool forceRefresh = false]) async { Future<String?> getIdToken([bool forceRefresh = false]) async {
if (_firebaseAuth == null) return null; final auth = _firebaseAuth;
final user = _firebaseAuth.currentUser; if (auth == null) return null;
final user = auth.currentUser;
if (user == null) return null; if (user == null) return null;
try { try {
return await user.getIdToken(forceRefresh); return await user.getIdToken(forceRefresh);
@ -325,8 +339,9 @@ class FirebaseAuthService implements AuthService {
await _googleSignIn.signOut(); await _googleSignIn.signOut();
} catch (_) {} } catch (_) {}
} }
if (_firebaseAuth != null) { final auth = _firebaseAuth;
await _firebaseAuth.signOut(); if (auth != null) {
await auth.signOut();
} }
} catch (_) {} } catch (_) {}

View File

@ -76,7 +76,7 @@ class OpenStreetMapProvider implements AppMapProvider {
TileLayer( TileLayer(
urlTemplate: config.tileUrlTemplate, urlTemplate: config.tileUrlTemplate,
subdomains: config.subdomains, subdomains: config.subdomains,
userAgentPackageName: 'com.example.motorcycle_dash', userAgentPackageName: 'app.motorsignal.motorcycle',
maxZoom: 20, maxZoom: 20,
maxNativeZoom: 19, maxNativeZoom: 19,
), ),

View File

@ -80,6 +80,11 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
static const String _prefUserKey = 'osm_user_profile'; static const String _prefUserKey = 'osm_user_profile';
static const String _prefVerifierKey = 'osm_pkce_code_verifier'; static const String _prefVerifierKey = 'osm_pkce_code_verifier';
String? _currentCodeVerifier;
String? _inFlightCode;
Future<bool>? _inFlightExchange;
String? _lastExchangedCode;
OsmOAuthService() : super(const OsmAuthState(isLoading: true)) { OsmOAuthService() : super(const OsmAuthState(isLoading: true)) {
_loadStoredSession(); _loadStoredSession();
} }
@ -132,6 +137,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
final codeChallenge = _generateCodeChallenge(codeVerifier); final codeChallenge = _generateCodeChallenge(codeVerifier);
final stateParam = _generateRandomString(16); final stateParam = _generateRandomString(16);
_currentCodeVerifier = codeVerifier;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefVerifierKey, codeVerifier); await prefs.setString(_prefVerifierKey, codeVerifier);
@ -162,17 +168,62 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
/// Complete OAuth2 token exchange with auth code from redirect callback /// Complete OAuth2 token exchange with auth code from redirect callback
Future<bool> handleAuthCallback(Uri uri) async { Future<bool> handleAuthCallback(Uri uri) async {
final error = uri.queryParameters['error'];
if (error != null && error.isNotEmpty) {
final desc = uri.queryParameters['error_description'] ?? error;
state = state.copyWith(isLoading: false, errorMessage: 'OpenStreetMap authorization error: $desc');
return false;
}
final code = uri.queryParameters['code']; final code = uri.queryParameters['code'];
if (code == null || code.isEmpty) { if (code == null || code.isEmpty) {
state = state.copyWith(errorMessage: 'No authorization code received.'); state = state.copyWith(errorMessage: 'No authorization code received.');
return false; return false;
} }
if (state.isAuthenticated && code == _lastExchangedCode) {
return true;
}
if (_inFlightCode == code && _inFlightExchange != null) {
return await _inFlightExchange!;
}
_inFlightCode = code;
final exchangeFuture = _executeTokenExchange(code);
_inFlightExchange = exchangeFuture;
try {
final result = await exchangeFuture;
if (result) {
_lastExchangedCode = code;
}
return result;
} finally {
if (_inFlightCode == code) {
_inFlightCode = null;
_inFlightExchange = null;
}
}
}
Future<bool> _executeTokenExchange(String code) async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final codeVerifier = prefs.getString(_prefVerifierKey) ?? ''; var codeVerifier = _currentCodeVerifier;
if (codeVerifier == null || codeVerifier.isEmpty) {
codeVerifier = prefs.getString(_prefVerifierKey) ?? '';
}
if (codeVerifier.isEmpty) {
state = state.copyWith(
isLoading: false,
errorMessage: 'PKCE code verifier expired or missing. Please try logging in again.',
);
return false;
}
debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri'); debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri');
final response = await http.post( final response = await http.post(
@ -197,6 +248,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
await prefs.setString(_prefTokenKey, accessToken); await prefs.setString(_prefTokenKey, accessToken);
await prefs.remove(_prefVerifierKey); await prefs.remove(_prefVerifierKey);
_currentCodeVerifier = null;
state = OsmAuthState( state = OsmAuthState(
isAuthenticated: true, isAuthenticated: true,

View File

@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "motorcycle_dash") set(BINARY_NAME "motorsignal")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.motorcycle_dash") set(APPLICATION_ID "app.motorsignal.motorcycle")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@ -5,6 +5,7 @@ import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/tomtom_road_alert_provider.dart'; import 'package:motorcycle_dash/services/alerts/tomtom_road_alert_provider.dart';
import 'package:motorcycle_dash/services/alerts/local_road_alert_provider.dart'; import 'package:motorcycle_dash/services/alerts/local_road_alert_provider.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@ -59,5 +60,34 @@ void main() {
expect(container.read(tomtomApiKeyProvider), isEmpty); expect(container.read(tomtomApiKeyProvider), isEmpty);
expect(container.read(tomtomStatusProvider), equals(RoadAlertProviderStatus.notConfigured)); expect(container.read(tomtomStatusProvider), equals(RoadAlertProviderStatus.notConfigured));
}); });
test('RoadAlertService dismissAlert and clearAllAlerts function correctly', () async {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
addTearDown(container.dispose);
final service = container.read(roadAlertServiceProvider);
final future1 = service.alertsStream.first;
service.triggerTestAlert(category: RoadAlertCategory.police);
final alerts1 = await future1;
expect(alerts1.isNotEmpty, isTrue);
final alertId = alerts1.first.id;
final future2 = service.alertsStream.first;
service.dismissAlert(alertId);
final alerts2 = await future2;
expect(alerts2.any((a) => a.id == alertId), isFalse);
final future3 = service.alertsStream.first;
service.triggerTestAlert(category: RoadAlertCategory.hazard);
final alerts3 = await future3;
expect(alerts3.isNotEmpty, isTrue);
final future4 = service.alertsStream.first;
service.clearAllAlerts();
final alerts4 = await future4;
expect(alerts4.isEmpty, isTrue);
});
}); });
} }