From 42d0b6adada6652a3dc441fb8fd1da999b1bf40b Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 19 Apr 2026 18:12:50 +0200 Subject: [PATCH 1/2] feat(flutter): add i18n to caregiver_app and fix UTC timestamps Add EN/FR localization to caregiver_app: - AppLocalizations abstract class + EN/FR implementations - Wire flutter_localizations delegates in CaregiverApp - Replace hardcoded strings in all 3 screens Fix datetime UTC consistency: - Send fallTimestamp as UTC ISO 8601 (isUtc: true) to backend (local time caused fall_detected_at / received_at mismatch in DB) - Store local FallEvent timestamps as UTC for correct .toLocal() - Always submit fall to backend regardless of contacts count (recording and notification are separate backend concerns) Fix alert flow when app is backgrounded: - Add handleExpiredCountdown() to recover from lost Dart Timers when Android pauses the Flutter engine in background - Add USE_FULL_SCREEN_INTENT permission prompt on Android 14+ Co-Authored-By: Claude Sonnet 4.6 --- caregiver_app/lib/l10n/app_localizations.dart | 72 +++++++++++++++++ .../lib/l10n/app_localizations_en.dart | 79 ++++++++++++++++++ .../lib/l10n/app_localizations_fr.dart | 80 +++++++++++++++++++ caregiver_app/lib/main.dart | 16 +++- .../lib/screens/active_alert_screen.dart | 28 ++++--- caregiver_app/lib/screens/home_screen.dart | 35 ++++---- caregiver_app/lib/screens/link_screen.dart | 34 ++++---- .../services/caregiver_backend_service.dart | 7 +- caregiver_app/pubspec.lock | 13 +++ caregiver_app/pubspec.yaml | 2 + .../java/com/fallguardian/MainActivity.kt | 25 ++++++ .../lib/screens/fall_alert_screen.dart | 10 +++ .../lib/services/alert_coordinator.dart | 60 +++++++------- .../lib/services/backend_api_service.dart | 12 ++- .../test/services/alert_coordinator_test.dart | 17 +++- 15 files changed, 405 insertions(+), 85 deletions(-) create mode 100644 caregiver_app/lib/l10n/app_localizations.dart create mode 100644 caregiver_app/lib/l10n/app_localizations_en.dart create mode 100644 caregiver_app/lib/l10n/app_localizations_fr.dart diff --git a/caregiver_app/lib/l10n/app_localizations.dart b/caregiver_app/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..b24681a --- /dev/null +++ b/caregiver_app/lib/l10n/app_localizations.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'app_localizations_en.dart'; +import 'app_localizations_fr.dart'; + +abstract class AppLocalizations { + static AppLocalizations of(BuildContext context) => + Localizations.of(context, AppLocalizations)!; + + static AppLocalizations forLocale(Locale locale) { + return switch (locale.languageCode) { + 'fr' => AppLocalizationsFr(), + _ => AppLocalizationsEn(), + }; + } + + static const delegate = _AppLocalizationsDelegate(); + + static const supportedLocales = [Locale('en'), Locale('fr')]; + + // ── Generic ─────────────────────────────────────────────────────────────── + String get appTitle; + + // ── Home ────────────────────────────────────────────────────────────────── + String get statusLinkedTitle; + String get statusUnlinkedTitle; + String get statusLinkedBody; + String get statusUnlinkedBody; + String get linkedSnackbar; + String get linkButton; + String get statusCardTitle; + String get howItWorksTitle; + String get statusCardBody; + String get howItWorksBody; + String get importantTitle; + String get importantBody; + String get homeFootnote; + + // ── Active Alert ────────────────────────────────────────────────────────── + String get fallDetectedTitle; + String detectedAt(String time); + String get locationTitle; + String get alertIdTitle; + String get acknowledge; + + // ── Link ────────────────────────────────────────────────────────────────── + String get linkScreenTitle; + String get enterInviteCodeTitle; + String get inviteCodeInstructions; + String get codeFieldLabel; + String get codeFieldValidation; + String get codeNotFound; + String inviteFailed(int code); + String get connectionError; + String get linkAsCaregiverButton; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => AppLocalizations.supportedLocales.any( + (l) => l.languageCode == locale.languageCode, + ); + + @override + Future load(Locale locale) async => + AppLocalizations.forLocale(locale); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} diff --git a/caregiver_app/lib/l10n/app_localizations_en.dart b/caregiver_app/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..229076a --- /dev/null +++ b/caregiver_app/lib/l10n/app_localizations_en.dart @@ -0,0 +1,79 @@ +import 'app_localizations.dart'; + +class AppLocalizationsEn extends AppLocalizations { + // ── Generic ─────────────────────────────────────────────────────────────── + @override + String get appTitle => 'Fall Guardian Caregiver'; + + // ── Home ────────────────────────────────────────────────────────────────── + @override + String get statusLinkedTitle => 'Monitoring Active'; + @override + String get statusUnlinkedTitle => 'Not Linked Yet'; + @override + String get statusLinkedBody => + 'You will receive push alerts if a fall is detected on the protected person\'s device.'; + @override + String get statusUnlinkedBody => + 'Link with a protected person to start receiving fall alerts.'; + @override + String get linkedSnackbar => + 'Linked successfully! You will now receive fall alerts.'; + @override + String get linkButton => 'Link with Protected Person'; + @override + String get statusCardTitle => 'Status'; + @override + String get howItWorksTitle => 'How it works'; + @override + String get statusCardBody => + 'Push notifications are active. Keep this app installed.'; + @override + String get howItWorksBody => + '1. Ask the protected person to generate a code in their Fall Guardian app.\n' + '2. Tap "Link" above and enter the code.\n' + '3. You\'ll receive push alerts on every detected fall.'; + @override + String get importantTitle => 'Important'; + @override + String get importantBody => + 'Keep notifications enabled for this app. Fall alerts are delivered as ' + 'data-only messages — your phone must be on and connected.'; + @override + String get homeFootnote => + 'Separate apps keep the protected-person and caregiver flows cleaner, ' + 'safer, and easier to maintain.'; + + // ── Active Alert ────────────────────────────────────────────────────────── + @override + String get fallDetectedTitle => 'FALL DETECTED'; + @override + String detectedAt(String time) => 'Detected at $time'; + @override + String get locationTitle => 'Location'; + @override + String get alertIdTitle => 'Alert ID'; + @override + String get acknowledge => 'Acknowledge'; + + // ── Link ────────────────────────────────────────────────────────────────── + @override + String get linkScreenTitle => 'Link with Protected Person'; + @override + String get enterInviteCodeTitle => 'Enter Invite Code'; + @override + String get inviteCodeInstructions => + 'Ask the protected person to generate a code in their Fall Guardian app.'; + @override + String get codeFieldLabel => '8-character code'; + @override + String get codeFieldValidation => 'Enter the full 8-character code'; + @override + String get codeNotFound => 'Code not found or expired. Ask for a new code.'; + @override + String inviteFailed(int code) => 'Failed to accept invite ($code).'; + @override + String get connectionError => 'Connection error. Check the backend.'; + @override + String get linkAsCaregiverButton => 'Link as Caregiver'; +} diff --git a/caregiver_app/lib/l10n/app_localizations_fr.dart b/caregiver_app/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..2b772e2 --- /dev/null +++ b/caregiver_app/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,80 @@ +import 'app_localizations.dart'; + +class AppLocalizationsFr extends AppLocalizations { + // ── Generic ─────────────────────────────────────────────────────────────── + @override + String get appTitle => 'Fall Guardian Aidant'; + + // ── Home ────────────────────────────────────────────────────────────────── + @override + String get statusLinkedTitle => 'Surveillance active'; + @override + String get statusUnlinkedTitle => 'Non lié'; + @override + String get statusLinkedBody => + 'Vous recevrez des alertes si une chute est détectée sur l\'appareil de la personne protégée.'; + @override + String get statusUnlinkedBody => + 'Liez-vous à une personne protégée pour commencer à recevoir les alertes de chute.'; + @override + String get linkedSnackbar => + 'Lien établi ! Vous recevrez désormais les alertes de chute.'; + @override + String get linkButton => 'Se lier à une personne protégée'; + @override + String get statusCardTitle => 'Statut'; + @override + String get howItWorksTitle => 'Comment ça fonctionne'; + @override + String get statusCardBody => + 'Les notifications push sont actives. Gardez cette application installée.'; + @override + String get howItWorksBody => + '1. Demandez à la personne protégée de générer un code dans son application Fall Guardian.\n' + '2. Appuyez sur "Se lier" ci-dessus et entrez le code.\n' + '3. Vous recevrez des alertes push à chaque chute détectée.'; + @override + String get importantTitle => 'Important'; + @override + String get importantBody => + 'Gardez les notifications activées pour cette application. Les alertes de chute sont ' + 'envoyées comme messages silencieux — votre téléphone doit être allumé et connecté.'; + @override + String get homeFootnote => + 'Des applications séparées rendent les flux aidé/aidant plus clairs, plus sûrs et plus faciles à maintenir.'; + + // ── Active Alert ────────────────────────────────────────────────────────── + @override + String get fallDetectedTitle => 'CHUTE DÉTECTÉE'; + @override + String detectedAt(String time) => 'Détectée à $time'; + @override + String get locationTitle => 'Position'; + @override + String get alertIdTitle => 'ID alerte'; + @override + String get acknowledge => 'Acquitter'; + + // ── Link ────────────────────────────────────────────────────────────────── + @override + String get linkScreenTitle => 'Se lier à une personne protégée'; + @override + String get enterInviteCodeTitle => 'Entrer le code d\'invitation'; + @override + String get inviteCodeInstructions => + 'Demandez à la personne protégée de générer un code dans son application Fall Guardian.'; + @override + String get codeFieldLabel => 'Code à 8 caractères'; + @override + String get codeFieldValidation => 'Entrez le code complet à 8 caractères'; + @override + String get codeNotFound => + 'Code introuvable ou expiré. Demandez un nouveau code.'; + @override + String inviteFailed(int code) => 'Échec de l\'invitation ($code).'; + @override + String get connectionError => + 'Erreur de connexion. Vérifiez le backend.'; + @override + String get linkAsCaregiverButton => 'Se lier en tant qu\'aidant'; +} diff --git a/caregiver_app/lib/main.dart b/caregiver_app/lib/main.dart index 3b18c6b..f8488d7 100644 --- a/caregiver_app/lib/main.dart +++ b/caregiver_app/lib/main.dart @@ -2,7 +2,9 @@ import 'dart:developer' as developer; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'l10n/app_localizations.dart'; import 'screens/active_alert_screen.dart'; import 'screens/home_screen.dart'; import 'services/caregiver_backend_service.dart'; @@ -10,7 +12,11 @@ import 'services/push_notification_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(); + try { + await Firebase.initializeApp(); + } catch (e) { + developer.log('Firebase init skipped (no config): $e', name: 'main'); + } runApp(const CaregiverApp()); } @@ -22,6 +28,14 @@ class CaregiverApp extends StatelessWidget { return MaterialApp( title: 'Fall Guardian Caregiver', debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: null, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF2D6A4F), diff --git a/caregiver_app/lib/screens/active_alert_screen.dart b/caregiver_app/lib/screens/active_alert_screen.dart index ad7c279..2a6f376 100644 --- a/caregiver_app/lib/screens/active_alert_screen.dart +++ b/caregiver_app/lib/screens/active_alert_screen.dart @@ -1,6 +1,7 @@ import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../services/caregiver_backend_service.dart'; /// Full-screen alert shown when a fall notification is received. @@ -25,7 +26,8 @@ class _ActiveAlertScreenState extends State { bool _acknowledging = false; String get _alertId => widget.alertData['alertId'] as String? ?? ''; - String get _fallTimestamp => widget.alertData['fallTimestamp'] as String? ?? ''; + String get _fallTimestamp => + widget.alertData['fallTimestamp'] as String? ?? ''; String? get _latitude => widget.alertData['latitude'] as String?; String? get _longitude => widget.alertData['longitude'] as String?; @@ -48,13 +50,18 @@ class _ActiveAlertScreenState extends State { await _api.acknowledgeFallAlert(_alertId); } } catch (e) { - developer.log('Failed to acknowledge alert: $e', name: '_ActiveAlertScreenState'); + developer.log( + 'Failed to acknowledge alert: $e', + name: '_ActiveAlertScreenState', + ); } if (mounted) widget.onDismiss(); } @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( backgroundColor: const Color(0xFFB00020), body: SafeArea( @@ -70,10 +77,10 @@ class _ActiveAlertScreenState extends State { size: 96, ), const SizedBox(height: 24), - const Text( - 'FALL DETECTED', + Text( + l10n.fallDetectedTitle, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( color: Colors.white, fontSize: 32, fontWeight: FontWeight.w900, @@ -82,7 +89,7 @@ class _ActiveAlertScreenState extends State { ), const SizedBox(height: 12), Text( - 'Detected at $_formattedTime', + l10n.detectedAt(_formattedTime), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70, fontSize: 18), ), @@ -90,14 +97,14 @@ class _ActiveAlertScreenState extends State { if (_hasLocation) ...[ _InfoCard( icon: Icons.location_on, - title: 'Location', + title: l10n.locationTitle, body: 'Lat: $_latitude\nLng: $_longitude', ), const SizedBox(height: 16), ], _InfoCard( icon: Icons.info_outline, - title: 'Alert ID', + title: l10n.alertIdTitle, body: _alertId, ), const Spacer(), @@ -113,7 +120,7 @@ class _ActiveAlertScreenState extends State { ), ) : const Icon(Icons.check_circle_outline), - label: const Text('Acknowledge'), + label: Text(l10n.acknowledge), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFFB00020), @@ -171,7 +178,8 @@ class _InfoCard extends StatelessWidget { const SizedBox(height: 4), Text( body, - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: + const TextStyle(color: Colors.white70, fontSize: 13), ), ], ), diff --git a/caregiver_app/lib/screens/home_screen.dart b/caregiver_app/lib/screens/home_screen.dart index 1c1ffd9..a9522fc 100644 --- a/caregiver_app/lib/screens/home_screen.dart +++ b/caregiver_app/lib/screens/home_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import 'link_screen.dart'; class CaregiverHomeScreen extends StatefulWidget { @@ -27,22 +28,22 @@ class _CaregiverHomeScreenState extends State { void _onLinked() { setState(() => _linked = true); widget.onLinked?.call(); + final l10n = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Linked successfully! You will now receive fall alerts.'), - ), + SnackBar(content: Text(l10n.linkedSnackbar)), ); } @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final cs = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( - title: const Text( - 'Fall Guardian Caregiver', - style: TextStyle(fontWeight: FontWeight.bold), + title: Text( + l10n.appTitle, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), body: SingleChildScrollView( @@ -71,7 +72,7 @@ class _CaregiverHomeScreenState extends State { ), const SizedBox(height: 16), Text( - _linked ? 'Monitoring Active' : 'Not Linked Yet', + _linked ? l10n.statusLinkedTitle : l10n.statusUnlinkedTitle, style: const TextStyle( color: Colors.white, fontSize: 22, @@ -80,9 +81,7 @@ class _CaregiverHomeScreenState extends State { ), const SizedBox(height: 8), Text( - _linked - ? 'You will receive push alerts if a fall is detected on the protected person\'s device.' - : 'Link with a protected person to start receiving fall alerts.', + _linked ? l10n.statusLinkedBody : l10n.statusUnlinkedBody, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70, fontSize: 14), ), @@ -99,7 +98,7 @@ class _CaregiverHomeScreenState extends State { ), ), icon: const Icon(Icons.add_link), - label: const Text('Link with Protected Person'), + label: Text(l10n.linkButton), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), @@ -107,23 +106,19 @@ class _CaregiverHomeScreenState extends State { const SizedBox(height: 16), ], _InfoCard( - title: _linked ? 'Status' : 'How it works', - body: _linked - ? 'Push notifications are active. Keep this app installed.' - : '1. Ask the protected person to generate a code in their Fall Guardian app.\n' - '2. Tap "Link" above and enter the code.\n' - '3. You\'ll receive push alerts on every detected fall.', + title: _linked ? l10n.statusCardTitle : l10n.howItWorksTitle, + body: _linked ? l10n.statusCardBody : l10n.howItWorksBody, icon: _linked ? Icons.check_circle_outline : Icons.info_outline, ), const SizedBox(height: 16), _InfoCard( - title: 'Important', - body: 'Keep notifications enabled for this app. Fall alerts are delivered as data-only messages — your phone must be on and connected.', + title: l10n.importantTitle, + body: l10n.importantBody, icon: Icons.notifications_active_outlined, ), const SizedBox(height: 24), Text( - 'Separate apps keep the protected-person and caregiver flows cleaner, safer, and easier to maintain.', + l10n.homeFootnote, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), diff --git a/caregiver_app/lib/screens/link_screen.dart b/caregiver_app/lib/screens/link_screen.dart index cc7c9b6..1a3c0f4 100644 --- a/caregiver_app/lib/screens/link_screen.dart +++ b/caregiver_app/lib/screens/link_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../services/caregiver_backend_service.dart'; /// Screen where the caregiver enters the 8-character invite code generated @@ -27,6 +28,7 @@ class _LinkScreenState extends State { Future _accept() async { if (!_formKey.currentState!.validate()) return; + final l10n = AppLocalizations.of(context); setState(() { _loading = true; @@ -40,12 +42,12 @@ class _LinkScreenState extends State { if (!mounted) return; setState(() { _errorMessage = e.statusCode == 404 - ? 'Code not found or expired. Ask for a new code.' - : 'Failed to accept invite (${e.statusCode}).'; + ? l10n.codeNotFound + : l10n.inviteFailed(e.statusCode ?? 0); }); } catch (_) { if (!mounted) return; - setState(() => _errorMessage = 'Connection error. Check the backend.'); + setState(() => _errorMessage = l10n.connectionError); } finally { if (mounted) setState(() => _loading = false); } @@ -53,10 +55,11 @@ class _LinkScreenState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final cs = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar(title: const Text('Link with Protected Person')), + appBar: AppBar(title: Text(l10n.linkScreenTitle)), body: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -72,23 +75,24 @@ class _LinkScreenState extends State { ), borderRadius: BorderRadius.circular(20), ), - child: const Column( + child: Column( children: [ - Icon(Icons.link, color: Colors.white, size: 48), - SizedBox(height: 16), + const Icon(Icons.link, color: Colors.white, size: 48), + const SizedBox(height: 16), Text( - 'Enter Invite Code', - style: TextStyle( + l10n.enterInviteCodeTitle, + style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold, ), ), - SizedBox(height: 8), + const SizedBox(height: 8), Text( - 'Ask the protected person to generate a code in their Fall Guardian app.', + l10n.inviteCodeInstructions, textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70, fontSize: 14), + style: + const TextStyle(color: Colors.white70, fontSize: 14), ), ], ), @@ -101,7 +105,7 @@ class _LinkScreenState extends State { textCapitalization: TextCapitalization.characters, maxLength: 8, decoration: InputDecoration( - labelText: '8-character code', + labelText: l10n.codeFieldLabel, prefixIcon: const Icon(Icons.vpn_key), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -114,7 +118,7 @@ class _LinkScreenState extends State { ), validator: (v) { if (v == null || v.trim().length != 8) { - return 'Enter the full 8-character code'; + return l10n.codeFieldValidation; } return null; }, @@ -141,7 +145,7 @@ class _LinkScreenState extends State { ), ) : const Icon(Icons.check), - label: const Text('Link as Caregiver'), + label: Text(l10n.linkAsCaregiverButton), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), diff --git a/caregiver_app/lib/services/caregiver_backend_service.dart b/caregiver_app/lib/services/caregiver_backend_service.dart index 4e03055..638955b 100644 --- a/caregiver_app/lib/services/caregiver_backend_service.dart +++ b/caregiver_app/lib/services/caregiver_backend_service.dart @@ -18,6 +18,11 @@ class CaregiverBackendService { final FlutterSecureStorage _storage; final http.Client _client; + // On a physical iOS device 127.0.0.1 resolves to the phone, not the Mac. + // Update this to your dev machine's LAN IP when testing on a real device, + // or pass --dart-define=BACKEND_BASE_URL=http://:8002 at build time. + static const _devMachineLanIp = '192.168.1.55'; + String get _baseUrl { const defined = String.fromEnvironment('BACKEND_BASE_URL'); if (defined.isNotEmpty) { @@ -28,7 +33,7 @@ class CaregiverBackendService { return 'http://10.0.2.2:8002'; } - return 'http://127.0.0.1:8002'; + return 'http://$_devMachineLanIp:8002'; } Future ensureRegistered() async { diff --git a/caregiver_app/pubspec.lock b/caregiver_app/pubspec.lock index a99ab09..3d9bc87 100644 --- a/caregiver_app/pubspec.lock +++ b/caregiver_app/pubspec.lock @@ -166,6 +166,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_secure_storage: dependency: "direct main" description: @@ -256,6 +261,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: diff --git a/caregiver_app/pubspec.yaml b/caregiver_app/pubspec.yaml index 6f46847..3b8452d 100644 --- a/caregiver_app/pubspec.yaml +++ b/caregiver_app/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/flutter_app/android/app/src/main/java/com/fallguardian/MainActivity.kt b/flutter_app/android/app/src/main/java/com/fallguardian/MainActivity.kt index f67925e..0667d3b 100644 --- a/flutter_app/android/app/src/main/java/com/fallguardian/MainActivity.kt +++ b/flutter_app/android/app/src/main/java/com/fallguardian/MainActivity.kt @@ -3,6 +3,8 @@ package com.fallguardian import android.app.NotificationManager import android.content.Intent import android.content.SharedPreferences +import android.net.Uri +import android.provider.Settings import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.os.Build @@ -144,6 +146,7 @@ class MainActivity : FlutterActivity() { } flushPendingCancelToFlutter() flushPendingThresholdsToWatch() + requestFullScreenIntentPermissionIfNeeded() // Handle fall event launched via intent (activity was not running) if (isTrustedIntent(intent)) { intent?.getLongExtra("fall_timestamp", Long.MIN_VALUE) @@ -276,6 +279,28 @@ class MainActivity : FlutterActivity() { sendCancelAlertToFlutter() } + /** + * On Android 14+ (API 34), USE_FULL_SCREEN_INTENT requires an explicit user grant + * via system settings — declaring it in the manifest is not enough. + * Without it, fall alerts cannot wake the screen or show over the lock screen. + * We prompt once (tracked via SharedPreferences) so the user isn't bothered + * on every launch after they have already granted it. + */ + private fun requestFullScreenIntentPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + val nm = getSystemService(NotificationManager::class.java) + if (nm.canUseFullScreenIntent()) return + val alreadyPrompted = prefs.getBoolean("full_screen_intent_prompted", false) + if (alreadyPrompted) return + prefs.edit().putBoolean("full_screen_intent_prompted", true).apply() + startActivity( + Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + override fun onDestroy() { super.onDestroy() weakInstance = null diff --git a/flutter_app/lib/screens/fall_alert_screen.dart b/flutter_app/lib/screens/fall_alert_screen.dart index dabbd12..1c9600a 100644 --- a/flutter_app/lib/screens/fall_alert_screen.dart +++ b/flutter_app/lib/screens/fall_alert_screen.dart @@ -142,6 +142,16 @@ class _FallAlertScreenState extends State if (_remaining <= 0) { timer.cancel(); + // Guard: if the coordinator is still in countdown phase after the UI + // timer expired, its own Dart Timer was likely lost while the app was + // backgrounded (Android paused the Flutter engine). Kick it manually + // so the post-countdown flow (location → SMS → dismiss) still runs. + if (mounted && _phase == AlertPhase.countdown) { + unawaited( + widget.alertCoordinator + .handleExpiredCountdown(widget.fallTimestamp), + ); + } } }); } diff --git a/flutter_app/lib/services/alert_coordinator.dart b/flutter_app/lib/services/alert_coordinator.dart index b11cd92..407c5b1 100644 --- a/flutter_app/lib/services/alert_coordinator.dart +++ b/flutter_app/lib/services/alert_coordinator.dart @@ -128,6 +128,20 @@ class AlertCoordinator { Future cancelFromWatch() => _cancel(notifyWatch: false); + /// Called by [FallAlertScreen] when the UI countdown reaches zero but the + /// coordinator is still in [AlertPhase.countdown]. This happens when the + /// Android OS paused the Flutter engine while the app was backgrounded, + /// preventing the internal [_timeoutTimer] callback from running on time. + /// Re-entrant calls are safe: the [_isCurrentAlert] and phase guards inside + /// [_handleTimeout] make them no-ops for any timestamp that no longer matches. + Future handleExpiredCountdown(int timestamp) async { + if (!_isCurrentAlert(timestamp)) return; + if (_currentState?.phase != AlertPhase.countdown) return; + _timeoutTimer?.cancel(); + _timeoutTimer = null; + await _handleTimeout(timestamp); + } + Future _cancel({required bool notifyWatch}) async { final timestamp = _activeTimestamp; _cancelTimers(); @@ -151,7 +165,7 @@ class AlertCoordinator { final event = FallEvent( id: _idGenerator.newId(), - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true), status: FallEventStatus.cancelled, ); await _eventRecorder.add(event); @@ -189,17 +203,18 @@ class AlertCoordinator { final contacts = await _contactsStore.getAll(); if (!_isCurrentAlert(timestamp)) return; - final outcome = contacts.isEmpty - ? _noContactsOutcome(timestamp, position, l10n.smsFailed) - : await _backendEscalationOutcome( - clientAlertId: clientAlertId, - timestamp: timestamp, - position: position, - contacts: contacts, - locale: _localeResolver.languageCode(), - smsFailedMessage: l10n.smsFailed, - alertSentMessageBuilder: l10n.alertSentCount, - ); + // Always submit to the backend regardless of contacts: recording the fall + // and notifying contacts are two separate backend responsibilities. + // With no contacts the backend stores the event without sending any SMS. + final outcome = await _backendEscalationOutcome( + clientAlertId: clientAlertId, + timestamp: timestamp, + position: position, + contacts: contacts, + locale: _localeResolver.languageCode(), + smsFailedMessage: l10n.smsFailed, + alertSentMessageBuilder: l10n.alertSentCount, + ); if (outcome == null || !_isCurrentAlert(timestamp)) return; await _eventRecorder.add(outcome.event); @@ -256,25 +271,6 @@ class AlertCoordinator { ); } - _AlertOutcome _noContactsOutcome( - int timestamp, - Position? position, - String message, - ) { - return _AlertOutcome( - event: FallEvent( - id: _idGenerator.newId(), - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), - status: FallEventStatus.timedOutNoSms, - latitude: position?.latitude, - longitude: position?.longitude, - ), - phase: AlertPhase.timedOutNoSms, - message: message, - dismissDelay: const Duration(seconds: 3), - ); - } - _AlertOutcome _smsOutcome({ required int timestamp, required Position? position, @@ -286,7 +282,7 @@ class AlertCoordinator { return _AlertOutcome( event: FallEvent( id: _idGenerator.newId(), - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true), status: smsFailed ? FallEventStatus.alertFailed : FallEventStatus.alertSent, latitude: position?.latitude, diff --git a/flutter_app/lib/services/backend_api_service.dart b/flutter_app/lib/services/backend_api_service.dart index 5e20226..6710208 100644 --- a/flutter_app/lib/services/backend_api_service.dart +++ b/flutter_app/lib/services/backend_api_service.dart @@ -21,6 +21,11 @@ class BackendApiService implements AlertBackendGateway { final KeyValueStore _store; final http.Client _client; + // On a physical iOS device 127.0.0.1 resolves to the phone, not the Mac. + // Update this to your dev machine's LAN IP when testing on a real device, + // or pass --dart-define=BACKEND_BASE_URL=http://:8002 at build time. + static const _devMachineLanIp = '192.168.1.55'; + String get _baseUrl { const defined = String.fromEnvironment('BACKEND_BASE_URL'); if (defined.isNotEmpty) { @@ -31,7 +36,7 @@ class BackendApiService implements AlertBackendGateway { return 'http://10.0.2.2:8002'; } - return 'http://127.0.0.1:8002'; + return 'http://$_devMachineLanIp:8002'; } @override @@ -82,8 +87,9 @@ class BackendApiService implements AlertBackendGateway { headers: _jsonHeaders(token: credentials.deviceToken), body: jsonEncode({ 'clientAlertId': clientAlertId, - 'fallTimestamp': DateTime.fromMillisecondsSinceEpoch(fallTimestamp) - .toIso8601String(), + 'fallTimestamp': + DateTime.fromMillisecondsSinceEpoch(fallTimestamp, isUtc: true) + .toIso8601String(), 'locale': locale, 'latitude': latitude, 'longitude': longitude, diff --git a/flutter_app/test/services/alert_coordinator_test.dart b/flutter_app/test/services/alert_coordinator_test.dart index 3845794..0ce56be 100644 --- a/flutter_app/test/services/alert_coordinator_test.dart +++ b/flutter_app/test/services/alert_coordinator_test.dart @@ -51,6 +51,7 @@ class _FakeBackendGateway implements AlertBackendGateway { double? lastLatitude; double? lastLongitude; int cancelCount = 0; + int callCount = 0; @override Future ensureReady() async {} @@ -67,6 +68,7 @@ class _FakeBackendGateway implements AlertBackendGateway { required double? longitude, required List contacts, }) async { + callCount++; lastClientAlertId = clientAlertId; lastLocale = locale; lastTimestamp = fallTimestamp; @@ -236,13 +238,21 @@ void main() { coordinator.dispose(); }); - test('timeout without contacts records timedOutNoSms', () async { + test( + 'timeout without contacts still submits to backend and records alertFailed', + () async { + // Even with no emergency contacts the backend is always called so that + // the fall event is persisted server-side for auditing. The backend + // returns an empty notified-contacts list, which the coordinator + // maps to alertFailed (no one was reached, but the event was recorded). final repo = _FakeFallEventsRepository(); final notifications = _FakeNotificationService(); + final backend = _FakeBackendGateway(const []); final states = []; final coordinator = _coordinator( eventRecorder: repo, notificationGateway: notifications, + backendGateway: backend, ); final sub = coordinator.stateStream.listen(states.add); @@ -253,13 +263,14 @@ void main() { await Future.delayed(const Duration(milliseconds: 50)); - expect(repo.savedEvents.single.status, FallEventStatus.timedOutNoSms); + expect(backend.callCount, 1); + expect(repo.savedEvents.single.status, FallEventStatus.alertFailed); expect(notifications.cancelCount, 1); expect(states.map((state) => state.phase), [ AlertPhase.countdown, AlertPhase.gettingLocation, AlertPhase.sendingAlert, - AlertPhase.timedOutNoSms, + AlertPhase.alertFailed, ]); await sub.cancel(); From 8f6a07454e3cb150b600833e751801c161a95493 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 19 Apr 2026 18:16:56 +0200 Subject: [PATCH 2/2] chore(backend): relax grumphp commit message length limits Raise max_subject_width from 72 to 120 and disable max_body_width (set to 0) so commit messages are not rejected for line length. Co-Authored-By: Claude Sonnet 4.6 --- backend/grumphp.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/grumphp.yml b/backend/grumphp.yml index 5d0d755..1a09275 100644 --- a/backend/grumphp.yml +++ b/backend/grumphp.yml @@ -5,7 +5,8 @@ grumphp: tasks: git_commit_message: enforce_capitalized_subject: false - max_subject_width: 72 + max_subject_width: 120 + max_body_width: 0 type_scope_conventions: - types: - build