diff --git a/.gitignore b/.gitignore
index 2d55ef8..057e874 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,9 @@ node_modules/
scratch/
flutter-doctor.txt
gemini-mcp-config-search.txt
+
+# Android signing credentials & local properties
+*.jks
+*.keystore
+android/key.properties
+android/local.properties
diff --git a/android/app/build.gradle.kts.backup b/android/app/build.gradle.kts.backup
new file mode 100644
index 0000000..f1435a6
--- /dev/null
+++ b/android/app/build.gradle.kts.backup
@@ -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 = "../.."
+}
diff --git a/android/app/google-services.json b/android/app/google-services.json
new file mode 100644
index 0000000..aee7617
--- /dev/null
+++ b/android/app/google-services.json
@@ -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"
+}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0552e3c..f844493 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,7 +10,7 @@
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index e96108c..6d564ec 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,6 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+
android.useAndroidX=true
-# This newDsl flag was added by the Flutter template
-android.newDsl=false
-# This builtInKotlin flag was added by the Flutter template
+
+# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index c21f0c5..6ade8d3 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" 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")
diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart
index 6d7b807..7f8bed2 100644
--- a/lib/features/dashboard/dashboard_screen.dart
+++ b/lib/features/dashboard/dashboard_screen.dart
@@ -52,7 +52,7 @@ class DashboardScreen extends ConsumerWidget {
child: Column(
children: [
if (!hasLocPermission) _buildPermissionBanner(context, ref),
- if (closestAlert != null) _buildAlertBanner(closestAlert),
+ if (closestAlert != null) _buildAlertBanner(context, closestAlert, ref),
Expanded(
child: LayoutBuilder(
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(
width: double.infinity,
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2),
border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
@@ -120,15 +120,36 @@ class DashboardScreen extends ConsumerWidget {
children: [
Text(
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(
- '${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead',
- style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
+ '${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead • ${alert.roadName ?? 'Reported'}',
+ 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);
+ },
+ ),
],
),
);
diff --git a/lib/features/settings/oauth_callback_screen.dart b/lib/features/settings/oauth_callback_screen.dart
index 290ee71..7ef2576 100644
--- a/lib/features/settings/oauth_callback_screen.dart
+++ b/lib/features/settings/oauth_callback_screen.dart
@@ -15,12 +15,16 @@ class OAuthCallbackScreen extends ConsumerStatefulWidget {
class _OAuthCallbackScreenState extends ConsumerState {
String _status = 'Connecting to OpenStreetMap...';
bool _isSuccess = false;
+ bool _hasProcessed = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
- _processAuth();
+ if (!_hasProcessed) {
+ _hasProcessed = true;
+ _processAuth();
+ }
});
}
diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart
index f2de915..4d0f514 100644
--- a/lib/features/settings/settings_screen.dart
+++ b/lib/features/settings/settings_screen.dart
@@ -289,10 +289,14 @@ class _SettingsScreenState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Text(
- 'OpenStreetMap Account',
- style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
+ const Expanded(
+ child: Text(
+ 'OpenStreetMap Account',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
+ ),
),
+ const SizedBox(width: 8),
Chip(
label: Text(
isAuthed ? 'Connected' : 'Disconnected',
diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart
index cac7c3e..7fde8f8 100644
--- a/lib/features/settings/widgets/evolution_settings_view.dart
+++ b/lib/features/settings/widgets/evolution_settings_view.dart
@@ -106,24 +106,32 @@ class _EvolutionSettingsViewState extends ConsumerState {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Row(
- children: [
- Icon(Icons.palette_outlined, color: Color(0xFF00E5FF), size: 18),
- SizedBox(width: 8),
- Text(
- 'ACTIVE THEME:',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 11,
- fontWeight: FontWeight.w800,
- color: Colors.white70,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.palette_outlined, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'ACTIVE THEME:',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 11,
+ fontWeight: FontWeight.w800,
+ color: Colors.white70,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
SegmentedButton(
+ showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
segments: const [
ButtonSegment(
@@ -268,22 +276,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
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,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.account_circle, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'MOTORCLOUD ACCOUNT',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Color(0xFF00E5FF),
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
@@ -398,22 +412,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Row(
- children: [
- Icon(Icons.public, color: Color(0xFF00E5FF), size: 18),
- SizedBox(width: 8),
- Text(
- 'OPENSTREETMAP ACCOUNT',
- style: TextStyle(
- fontFamily: 'Sora',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Color(0xFF00E5FF),
- letterSpacing: 0.1,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.public, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'OPENSTREETMAP ACCOUNT',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Color(0xFF00E5FF),
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
@@ -775,14 +795,17 @@ class _EvolutionSettingsViewState extends ConsumerState {
children: [
Icon(Icons.screen_rotation, color: Color(0xFF00E5FF), size: 18),
SizedBox(width: 8),
- Text(
- 'SENSORS & MOUNT CALIBRATION',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Colors.white,
- letterSpacing: 0.1,
+ Expanded(
+ child: Text(
+ 'SENSORS & MOUNT CALIBRATION',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ letterSpacing: 0.1,
+ ),
),
),
],
@@ -983,22 +1006,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Row(
- children: [
- Icon(Icons.biotech, color: isEnabled ? Colors.greenAccent : const Color(0xFF00E5FF), size: 18),
- const SizedBox(width: 8),
- const Text(
- 'ENGINEERING & DIAGNOSTICS',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Colors.white,
- letterSpacing: 0.1,
+ Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.biotech, color: isEnabled ? Colors.greenAccent : const Color(0xFF00E5FF), size: 18),
+ const SizedBox(width: 8),
+ const Flexible(
+ child: Text(
+ 'ENGINEERING & DIAGNOSTICS',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
Switch(
value: isEnabled,
activeThumbColor: Colors.greenAccent,
@@ -1118,22 +1147,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Row(
- children: [
- Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18),
- SizedBox(width: 8),
- Text(
- 'GITHUB RIDE LOG SYNC',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Colors.white,
- letterSpacing: 0.1,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'GITHUB RIDE LOG SYNC',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
@@ -1255,22 +1290,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Row(
- children: [
- Icon(Icons.traffic, color: Color(0xFF00E5FF), size: 18),
- SizedBox(width: 8),
- Text(
- 'TOMTOM TRAFFIC PROVIDER',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Colors.white,
- letterSpacing: 0.1,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.traffic, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'TOMTOM TRAFFIC PROVIDER',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
_buildStatusBadge(tomtomStatus),
],
),
@@ -1352,22 +1393,28 @@ class _EvolutionSettingsViewState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Row(
- children: [
- Icon(Icons.local_police, color: Color(0xFF00E5FF), size: 18),
- SizedBox(width: 8),
- Text(
- 'COMMUNITY TRAFFIC PROVIDER',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 12,
- fontWeight: FontWeight.w800,
- color: Colors.white,
- letterSpacing: 0.1,
+ const Expanded(
+ child: Row(
+ children: [
+ Icon(Icons.local_police, color: Color(0xFF00E5FF), size: 18),
+ SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ 'COMMUNITY TRAFFIC PROVIDER',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ letterSpacing: 0.1,
+ ),
+ ),
),
- ),
- ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
_buildStatusBadge(communityStatus),
],
),
diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart
new file mode 100644
index 0000000..45faf2c
--- /dev/null
+++ b/lib/firebase_options.dart
@@ -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',
+ );
+}
diff --git a/lib/main.dart b/lib/main.dart
index b4ea6b2..a48f390 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -4,17 +4,19 @@ 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 'firebase_options.dart';
import 'core/theme/theme.dart';
import 'core/router.dart';
import 'features/records/records_service.dart';
-import 'services/osm_oauth_service.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
- // Optional Firebase initialization (gracefully defaults to standalone offline mode if unconfigured)
+ // Authoritative Firebase initialization
try {
- await Firebase.initializeApp();
+ await Firebase.initializeApp(
+ options: DefaultFirebaseOptions.currentPlatform,
+ );
} catch (e) {
debugPrint('[Firebase] Standalone offline mode active: $e');
}
@@ -67,7 +69,6 @@ class _MotorcycleDashAppState extends ConsumerState {
void _handleDeepLink(Uri uri) {
final uriStr = uri.toString();
if (uriStr.contains('callback') || uri.host == 'oauth' || uri.scheme == 'motorsignal') {
- ref.read(osmOAuthServiceProvider.notifier).handleAuthCallback(uri);
try {
appRouter.go('/oauth/callback?${uri.query}');
} catch (_) {}
diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart
index 0dc246c..8b3e7a0 100644
--- a/lib/services/alerts/road_alert_service.dart
+++ b/lib/services/alerts/road_alert_service.dart
@@ -34,12 +34,17 @@ class RoadAlertService {
List _cachedAlerts = [];
final Set _announcedAlertIds = {};
+ final Set _dismissedAlertIds = {};
+ Timer? _cleanupTimer;
Stream> get alertsStream => _alertsController.stream;
RoadAlertService(this._ref) {
_initTts();
_startListening();
+ _cleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
+ _pruneExpiredAlerts();
+ });
}
void _initTts() {
@@ -308,7 +313,7 @@ class RoadAlertService {
description: 'TEST ALERT: ${category.displayName} reported ahead (Diagnostic Verification)',
roadName: 'Test Way / Simulation',
reportedAt: now,
- expiresAt: now.add(const Duration(minutes: 5)),
+ expiresAt: now.add(const Duration(seconds: 12)),
lastUpdated: now,
distanceFromUser: 150.0,
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) {
if (!_ref.read(roadAlertsEnabledProvider)) return;
_cachedAlerts = [alert, ..._cachedAlerts];
@@ -350,6 +399,7 @@ class RoadAlertService {
if (kDebugMode) {
debugPrint('[RoadAlerts] Disposing service resources');
}
+ _cleanupTimer?.cancel();
_locationSub?.cancel();
_alertsController.close();
try {
diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart
index 6e2108c..632805f 100644
--- a/lib/services/auth/auth_service.dart
+++ b/lib/services/auth/auth_service.dart
@@ -26,7 +26,7 @@ abstract class AuthService {
/// Authoritative Firebase Authentication Service
class FirebaseAuthService implements AuthService {
- final fb.FirebaseAuth? _firebaseAuth;
+ fb.FirebaseAuth? _firebaseAuth;
final GoogleSignIn? _googleSignIn;
final _stateController = StreamController.broadcast();
@@ -60,10 +60,18 @@ class FirebaseAuthService implements AuthService {
AuthUser? get currentUser => _currentState.user;
@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() {
- if (_firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (auth == null) {
// Firebase is not configured or offline during startup
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: false));
return;
@@ -71,7 +79,7 @@ class FirebaseAuthService implements AuthService {
try {
// Listen to native Firebase auth state stream
- _fbAuthSubscription = _firebaseAuth.authStateChanges().listen(
+ _fbAuthSubscription = auth.authStateChanges().listen(
(fb.User? user) {
if (user == null) {
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
@@ -125,7 +133,8 @@ class FirebaseAuthService implements AuthService {
@override
Future signInWithEmailAndPassword(String email, String password) async {
- if (!isConfigured || _firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (!isConfigured || auth == null) {
_updateState(AuthState.error(
'Cloud authentication is unavailable in offline/unconfigured mode. Local ride recording is active.',
user: _currentState.user,
@@ -140,7 +149,7 @@ class FirebaseAuthService implements AuthService {
));
try {
- final credential = await _firebaseAuth.signInWithEmailAndPassword(
+ final credential = await auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
@@ -163,7 +172,8 @@ class FirebaseAuthService implements AuthService {
String password, {
String? displayName,
}) async {
- if (!isConfigured || _firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (!isConfigured || auth == null) {
_updateState(AuthState.error(
'Account registration requires cloud configuration. Local riding remains fully functional.',
user: _currentState.user,
@@ -178,7 +188,7 @@ class FirebaseAuthService implements AuthService {
));
try {
- final credential = await _firebaseAuth.createUserWithEmailAndPassword(
+ final credential = await auth.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
);
@@ -200,7 +210,8 @@ class FirebaseAuthService implements AuthService {
@override
Future signInWithGoogle() async {
- if (!isConfigured || _firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (!isConfigured || auth == null) {
_updateState(AuthState.error(
'Google Sign-In is unavailable without Firebase credentials. Local ride features remain available.',
user: _currentState.user,
@@ -230,7 +241,7 @@ class FirebaseAuthService implements AuthService {
idToken: googleAuth.idToken,
);
- final result = await _firebaseAuth.signInWithCredential(credential);
+ final result = await auth.signInWithCredential(credential);
if (result.user != null) {
final user = _mapFirebaseUser(result.user!);
_updateState(AuthState.authenticated(user, isFirebaseConfigured: true));
@@ -246,7 +257,8 @@ class FirebaseAuthService implements AuthService {
@override
Future signInAnonymously() async {
- if (!isConfigured || _firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (!isConfigured || auth == null) {
// Local fallback anonymous user representation
final localAnon = AuthUser(
uid: 'local_${DateTime.now().millisecondsSinceEpoch}',
@@ -264,7 +276,7 @@ class FirebaseAuthService implements AuthService {
));
try {
- final credential = await _firebaseAuth.signInAnonymously();
+ final credential = await auth.signInAnonymously();
if (credential.user != null) {
final user = _mapFirebaseUser(credential.user!);
_updateState(AuthState.anonymous(user, isFirebaseConfigured: true));
@@ -280,7 +292,8 @@ class FirebaseAuthService implements AuthService {
@override
Future sendPasswordResetEmail(String email) async {
- if (!isConfigured || _firebaseAuth == null) {
+ final auth = _firebaseAuth;
+ if (!isConfigured || auth == null) {
_updateState(AuthState.error(
'Password reset is unavailable in offline mode.',
user: _currentState.user,
@@ -290,7 +303,7 @@ class FirebaseAuthService implements AuthService {
}
try {
- await _firebaseAuth.sendPasswordResetEmail(email: email.trim());
+ await auth.sendPasswordResetEmail(email: email.trim());
} catch (e) {
_updateState(AuthState.error(
_formatAuthError(e),
@@ -302,8 +315,9 @@ class FirebaseAuthService implements AuthService {
@override
Future getIdToken([bool forceRefresh = false]) async {
- if (_firebaseAuth == null) return null;
- final user = _firebaseAuth.currentUser;
+ final auth = _firebaseAuth;
+ if (auth == null) return null;
+ final user = auth.currentUser;
if (user == null) return null;
try {
return await user.getIdToken(forceRefresh);
@@ -325,8 +339,9 @@ class FirebaseAuthService implements AuthService {
await _googleSignIn.signOut();
} catch (_) {}
}
- if (_firebaseAuth != null) {
- await _firebaseAuth.signOut();
+ final auth = _firebaseAuth;
+ if (auth != null) {
+ await auth.signOut();
}
} catch (_) {}
diff --git a/lib/services/map/open_street_map_provider.dart b/lib/services/map/open_street_map_provider.dart
index 1c65df1..44583e2 100644
--- a/lib/services/map/open_street_map_provider.dart
+++ b/lib/services/map/open_street_map_provider.dart
@@ -76,7 +76,7 @@ class OpenStreetMapProvider implements AppMapProvider {
TileLayer(
urlTemplate: config.tileUrlTemplate,
subdomains: config.subdomains,
- userAgentPackageName: 'com.example.motorcycle_dash',
+ userAgentPackageName: 'app.motorsignal.motorcycle',
maxZoom: 20,
maxNativeZoom: 19,
),
diff --git a/lib/services/osm_oauth_service.dart b/lib/services/osm_oauth_service.dart
index 0b59d00..e7f699d 100644
--- a/lib/services/osm_oauth_service.dart
+++ b/lib/services/osm_oauth_service.dart
@@ -80,6 +80,11 @@ class OsmOAuthService extends StateNotifier {
static const String _prefUserKey = 'osm_user_profile';
static const String _prefVerifierKey = 'osm_pkce_code_verifier';
+ String? _currentCodeVerifier;
+ String? _inFlightCode;
+ Future? _inFlightExchange;
+ String? _lastExchangedCode;
+
OsmOAuthService() : super(const OsmAuthState(isLoading: true)) {
_loadStoredSession();
}
@@ -132,6 +137,7 @@ class OsmOAuthService extends StateNotifier {
final codeChallenge = _generateCodeChallenge(codeVerifier);
final stateParam = _generateRandomString(16);
+ _currentCodeVerifier = codeVerifier;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefVerifierKey, codeVerifier);
@@ -162,17 +168,62 @@ class OsmOAuthService extends StateNotifier {
/// Complete OAuth2 token exchange with auth code from redirect callback
Future 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'];
if (code == null || code.isEmpty) {
state = state.copyWith(errorMessage: 'No authorization code received.');
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 _executeTokenExchange(String code) async {
state = state.copyWith(isLoading: true, errorMessage: null);
try {
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');
final response = await http.post(
@@ -197,6 +248,7 @@ class OsmOAuthService extends StateNotifier {
await prefs.setString(_prefTokenKey, accessToken);
await prefs.remove(_prefVerifierKey);
+ _currentCodeVerifier = null;
state = OsmAuthState(
isAuthenticated: true,
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
index 5eea393..8be3f12 100644
--- a/linux/CMakeLists.txt
+++ b/linux/CMakeLists.txt
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# 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:
# 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
# versions of CMake.
diff --git a/test/road_alert_test.dart b/test/road_alert_test.dart
index 1430899..a1bd114 100644
--- a/test/road_alert_test.dart
+++ b/test/road_alert_test.dart
@@ -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/local_road_alert_provider.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
+import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -59,5 +60,34 @@ void main() {
expect(container.read(tomtomApiKeyProvider), isEmpty);
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);
+ });
});
}