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); + }); }); }