From 2247833203c8e79f199d2365d310024904aaa6fa Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 01:10:10 +0200 Subject: [PATCH] feat(legacy-settings): DST-aware timezone with phone auto-detect --- apps/mobile_app/lib/core/init_app.dart | 2 + apps/mobile_app/pubspec.yaml | 1 + .../timezone_selection_provider.dart | 6 +- .../timezone_selection_provider.g.dart | 18 +- .../presentation/timezone_screen.dart | 265 +++++++++--------- .../src/features/timezone/timezone_data.dart | 71 +++-- modules/legacy/modules/settings/pubspec.yaml | 1 + packages/sf_localizations/assets/l10n/de.json | 1 + packages/sf_localizations/assets/l10n/en.json | 1 + packages/sf_localizations/assets/l10n/es.json | 1 + packages/sf_localizations/assets/l10n/fr.json | 1 + packages/sf_localizations/assets/l10n/it.json | 1 + packages/sf_localizations/assets/l10n/pt.json | 1 + .../lib/src/generated/i18n.dart | 1 + 14 files changed, 210 insertions(+), 161 deletions(-) diff --git a/apps/mobile_app/lib/core/init_app.dart b/apps/mobile_app/lib/core/init_app.dart index 75b396bc..e124ee22 100644 --- a/apps/mobile_app/lib/core/init_app.dart +++ b/apps/mobile_app/lib/core/init_app.dart @@ -18,11 +18,13 @@ import 'package:navigation/navigation.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_tracking/sf_tracking.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:timezone/data/latest_all.dart' as tz; Future initApp(EnvironmentEnum env) async { WidgetsFlutterBinding.ensureInitialized(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await initializeDateFormatting(); + tz.initializeTimeZones(); final sharedPreferences = await SharedPreferences.getInstance(); diff --git a/apps/mobile_app/pubspec.yaml b/apps/mobile_app/pubspec.yaml index db7e1e00..1100a4a7 100644 --- a/apps/mobile_app/pubspec.yaml +++ b/apps/mobile_app/pubspec.yaml @@ -97,6 +97,7 @@ dependencies: cupertino_icons: ^1.0.8 flutter_svg: ^2.2.2 intl: ^0.20.2 + timezone: ^0.10.1 go_router_builder: ^4.1.1 build_runner: ^2.7.1 diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart index 15894713..c030225d 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart @@ -5,7 +5,9 @@ part 'timezone_selection_provider.g.dart'; @riverpod class TimezoneSelection extends _$TimezoneSelection { @override - int? build() => null; + String? build() => null; - void select(int value) => state = value; + void select(String iana) => state = iana; + + void clear() => state = null; } diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart index 1ff4d278..1e82cb4f 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart @@ -13,7 +13,7 @@ part of 'timezone_selection_provider.dart'; const timezoneSelectionProvider = TimezoneSelectionProvider._(); final class TimezoneSelectionProvider - extends $NotifierProvider { + extends $NotifierProvider { const TimezoneSelectionProvider._() : super( from: null, @@ -33,28 +33,28 @@ final class TimezoneSelectionProvider TimezoneSelection create() => TimezoneSelection(); /// {@macro riverpod.override_with_value} - Override overrideWithValue(int? value) { + Override overrideWithValue(String? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$timezoneSelectionHash() => r'f108a0d1664e0c7a8423b1ec735745ac1781795b'; +String _$timezoneSelectionHash() => r'97150a64513baae3f18edde7da8396b91f4f1e2f'; -abstract class _$TimezoneSelection extends $Notifier { - int? build(); +abstract class _$TimezoneSelection extends $Notifier { + String? build(); @$mustCallSuper @override void runBuild() { final created = build(); - final ref = this.ref as $Ref; + final ref = this.ref as $Ref; final element = ref.element as $ClassProviderElement< - AnyNotifier, - int?, + AnyNotifier, + String?, Object?, Object? >; diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart index 2f8a8af3..231c7bd4 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart @@ -21,185 +21,194 @@ class TimezoneScreen extends ConsumerWidget { prev.isLoading && !next.isLoading && !next.hasError) { + ref.read(timezoneSelectionProvider.notifier).clear(); await showSuccessDialog(context, I18n.deviceUpdatedSuccess); } }); final primaryColor = context.sfColors.legacyPrimary; final device = ref.watch(selectedDeviceProvider).value; - final currentTimezone = device?.settings.timezone ?? 0; - final selectedTimezone = - ref.watch(timezoneSelectionProvider) ?? currentTimezone; + final selectedIana = ref.watch(timezoneSelectionProvider); final isSaving = ref.watch( timezoneControllerProvider.select((s) => s.isLoading), ); - final selected = timezoneEntries - .where((t) => t.$1 == selectedTimezone) - .firstOrNull; - final others = timezoneEntries - .where((t) => t.$1 != selectedTimezone) - .toList(); + Future onPhoneTap() async { + if (device == null) return; + if (!await guardDeviceCommand(context, ref)) return; + if (!context.mounted) return; + ref + .read(timezoneControllerProvider.notifier) + .save(device: device, newTimezone: phoneOffsetHours()); + } + + Future onSaveSelection() async { + if (device == null || selectedIana == null) return; + if (!await guardDeviceCommand(context, ref)) return; + if (!context.mounted) return; + final entry = timezoneEntries.firstWhere((e) => e.iana == selectedIana); + ref.read(timezoneControllerProvider.notifier).save( + device: device, + newTimezone: entry.currentOffsetHours, + ); + } return LegacyPageLayout( title: context.translate(I18n.timezone), body: ListView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), children: [ - if (selected != null) ...[ - _SelectedTimezoneCard( - city: selected.$2, - continent: selected.$3, - label: formatUtcOffset(selected.$1), - primaryColor: primaryColor, - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - context.translate(I18n.timezoneOther), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + _UsePhoneCard( + primaryColor: primaryColor, + onTap: isSaving ? null : onPhoneTap, + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + context.translate(I18n.timezoneOther), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ], - ...others.map( + ), + ...timezoneEntries.map( (tz) => _TimezoneItem( - city: tz.$2, - continent: tz.$3, - label: formatUtcOffset(tz.$1), + entry: tz, + selected: tz.iana == selectedIana, + primaryColor: primaryColor, onTap: () => ref .read(timezoneSelectionProvider.notifier) - .select(tz.$1), + .select(tz.iana), ), ), ], ), - footer: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), - child: PrimaryButton( - onPressed: isSaving - ? null - : () async { - if (device == null) return; - if (!await guardDeviceCommand(context, ref)) return; - if (!context.mounted) return; - ref.read(timezoneControllerProvider.notifier).save( - device: device, - newTimezone: selectedTimezone, - ); - }, - text: isSaving ? '...' : context.translate(I18n.save), - color: primaryColor, + footer: selectedIana == null + ? null + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: PrimaryButton( + onPressed: isSaving ? null : onSaveSelection, + text: isSaving ? '...' : context.translate(I18n.save), + color: primaryColor, + ), + ), + ); + } +} + +class _UsePhoneCard extends StatelessWidget { + final Color primaryColor; + final VoidCallback? onTap; + + const _UsePhoneCard({required this.primaryColor, required this.onTap}); + + @override + Widget build(BuildContext context) { + final offsetLabel = formatUtcOffset(phoneOffsetHours()); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: primaryColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: primaryColor, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon(Icons.phone_iphone, color: Colors.white, size: 28), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.translate(I18n.timezoneUsePhone), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryColor, + ), + ), + const SizedBox(height: 4), + Text( + offsetLabel, + style: TextStyle( + fontSize: 13, + color: primaryColor.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: primaryColor, size: 24), + ], ), ), ); } } -class _SelectedTimezoneCard extends StatelessWidget { - final String city; - final String continent; - final String label; - final Color primaryColor; - - const _SelectedTimezoneCard({ - required this.city, - required this.continent, - required this.label, - required this.primaryColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: primaryColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: primaryColor.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: primaryColor, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon(Icons.access_time, color: Colors.white, size: 28), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: primaryColor, - ), - ), - const SizedBox(height: 4), - Text( - '$city · $continent', - style: TextStyle( - fontSize: 14, - color: primaryColor.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - Icon(Icons.check_circle, color: primaryColor, size: 28), - ], - ), - ); - } -} - class _TimezoneItem extends StatelessWidget { - final String city; - final String continent; - final String label; + final TimezoneEntry entry; + final bool selected; + final Color primaryColor; final VoidCallback onTap; const _TimezoneItem({ - required this.city, - required this.continent, - required this.label, + required this.entry, + required this.selected, + required this.primaryColor, required this.onTap, }); @override Widget build(BuildContext context) { + final offsetLabel = formatUtcOffset(entry.currentOffsetHours); return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), margin: const EdgeInsets.only(bottom: 4), + decoration: selected + ? BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ) + : null, child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.outlineVariant, + color: selected + ? primaryColor + : Theme.of(context).colorScheme.outlineVariant, shape: BoxShape.circle, ), child: Center( child: Icon( Icons.public, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: selected + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, size: 20, ), ), @@ -210,12 +219,16 @@ class _TimezoneItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$label · $city', - style: const TextStyle(fontSize: 15), + '$offsetLabel · ${entry.city}', + style: TextStyle( + fontSize: 15, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? primaryColor : null, + ), ), const SizedBox(height: 2), Text( - continent, + entry.continent, style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -224,6 +237,8 @@ class _TimezoneItem extends StatelessWidget { ], ), ), + if (selected) + Icon(Icons.check_circle, color: primaryColor, size: 24), ], ), ), diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/timezone_data.dart b/modules/legacy/modules/settings/lib/src/features/timezone/timezone_data.dart index 8b926f55..82ba5d1e 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/timezone_data.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/timezone_data.dart @@ -1,32 +1,53 @@ +import 'package:timezone/timezone.dart' as tz; + +class TimezoneEntry { + const TimezoneEntry({ + required this.iana, + required this.city, + required this.continent, + }); + + final String iana; + final String city; + final String continent; + + int get currentOffsetHours { + final location = tz.getLocation(iana); + return tz.TZDateTime.now(location).timeZoneOffset.inHours; + } +} + const timezoneEntries = [ - (-12, 'Baker Island', 'Pacific'), - (-11, 'Pago Pago', 'Pacific'), - (-10, 'Honolulu', 'Pacific'), - (-9, 'Anchorage', 'America'), - (-8, 'Los Angeles', 'America'), - (-7, 'Denver', 'America'), - (-6, 'Mexico City', 'America'), - (-5, 'New York', 'America'), - (-4, 'Santiago', 'America'), - (-3, 'Buenos Aires', 'America'), - (-2, 'South Georgia', 'Atlantic'), - (-1, 'Azores', 'Atlantic'), - (0, 'London', 'Europe'), - (1, 'Madrid', 'Europe'), - (2, 'Cairo', 'Africa'), - (3, 'Moscow', 'Europe'), - (4, 'Dubai', 'Asia'), - (5, 'Karachi', 'Asia'), - (6, 'Dhaka', 'Asia'), - (7, 'Bangkok', 'Asia'), - (8, 'Shanghai', 'Asia'), - (9, 'Tokyo', 'Asia'), - (10, 'Sydney', 'Australia'), - (11, 'Noumea', 'Pacific'), - (12, 'Auckland', 'Pacific'), + TimezoneEntry(iana: 'Pacific/Midway', city: 'Baker Island', continent: 'Pacific'), + TimezoneEntry(iana: 'Pacific/Pago_Pago', city: 'Pago Pago', continent: 'Pacific'), + TimezoneEntry(iana: 'Pacific/Honolulu', city: 'Honolulu', continent: 'Pacific'), + TimezoneEntry(iana: 'America/Anchorage', city: 'Anchorage', continent: 'America'), + TimezoneEntry(iana: 'America/Los_Angeles', city: 'Los Angeles', continent: 'America'), + TimezoneEntry(iana: 'America/Denver', city: 'Denver', continent: 'America'), + TimezoneEntry(iana: 'America/Mexico_City', city: 'Mexico City', continent: 'America'), + TimezoneEntry(iana: 'America/New_York', city: 'New York', continent: 'America'), + TimezoneEntry(iana: 'America/Santiago', city: 'Santiago', continent: 'America'), + TimezoneEntry(iana: 'America/Argentina/Buenos_Aires', city: 'Buenos Aires', continent: 'America'), + TimezoneEntry(iana: 'Atlantic/South_Georgia', city: 'South Georgia', continent: 'Atlantic'), + TimezoneEntry(iana: 'Atlantic/Azores', city: 'Azores', continent: 'Atlantic'), + TimezoneEntry(iana: 'Europe/London', city: 'London', continent: 'Europe'), + TimezoneEntry(iana: 'Europe/Madrid', city: 'Madrid', continent: 'Europe'), + TimezoneEntry(iana: 'Africa/Cairo', city: 'Cairo', continent: 'Africa'), + TimezoneEntry(iana: 'Europe/Moscow', city: 'Moscow', continent: 'Europe'), + TimezoneEntry(iana: 'Asia/Dubai', city: 'Dubai', continent: 'Asia'), + TimezoneEntry(iana: 'Asia/Karachi', city: 'Karachi', continent: 'Asia'), + TimezoneEntry(iana: 'Asia/Dhaka', city: 'Dhaka', continent: 'Asia'), + TimezoneEntry(iana: 'Asia/Bangkok', city: 'Bangkok', continent: 'Asia'), + TimezoneEntry(iana: 'Asia/Shanghai', city: 'Shanghai', continent: 'Asia'), + TimezoneEntry(iana: 'Asia/Tokyo', city: 'Tokyo', continent: 'Asia'), + TimezoneEntry(iana: 'Australia/Sydney', city: 'Sydney', continent: 'Australia'), + TimezoneEntry(iana: 'Pacific/Noumea', city: 'Noumea', continent: 'Pacific'), + TimezoneEntry(iana: 'Pacific/Auckland', city: 'Auckland', continent: 'Pacific'), ]; String formatUtcOffset(int offset) { final sign = offset >= 0 ? '+' : ''; return 'UTC$sign$offset'; } + +int phoneOffsetHours() => DateTime.now().timeZoneOffset.inHours; diff --git a/modules/legacy/modules/settings/pubspec.yaml b/modules/legacy/modules/settings/pubspec.yaml index 82de74c4..f4e5adc5 100644 --- a/modules/legacy/modules/settings/pubspec.yaml +++ b/modules/legacy/modules/settings/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: json_serializable: ^6.11.2 url_launcher: ^6.3.2 flutter_contacts: ^1.1.9+2 + timezone: ^0.10.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/packages/sf_localizations/assets/l10n/de.json b/packages/sf_localizations/assets/l10n/de.json index bf336e35..c1503a35 100644 --- a/packages/sf_localizations/assets/l10n/de.json +++ b/packages/sf_localizations/assets/l10n/de.json @@ -766,6 +766,7 @@ "timezoneUpdated": "Zeitzone aktualisiert", "errorTimezone": "Die Zeitzone konnte nicht aktualisiert werden", "timezoneOther": "Andere Zeitzonen", + "timezoneUsePhone": "Zeitzone dieses Telefons verwenden", "takingPhoto": "Foto wird aufgenommen...", "errorTakePicture": "Fehler beim Fotografieren", "errorFetchPhotos": "Fehler beim Abrufen der Fotos", diff --git a/packages/sf_localizations/assets/l10n/en.json b/packages/sf_localizations/assets/l10n/en.json index a6ae71f4..7b96abb7 100755 --- a/packages/sf_localizations/assets/l10n/en.json +++ b/packages/sf_localizations/assets/l10n/en.json @@ -934,6 +934,7 @@ "timezoneUpdated": "Timezone updated", "errorTimezone": "Could not update timezone", "timezoneOther": "Other timezones", + "timezoneUsePhone": "Use this phone's timezone", "takingPhoto": "Taking photo...", "errorTakePicture": "Error taking photo", "errorFetchPhotos": "Error fetching photos", diff --git a/packages/sf_localizations/assets/l10n/es.json b/packages/sf_localizations/assets/l10n/es.json index cd6841b3..76acfda0 100644 --- a/packages/sf_localizations/assets/l10n/es.json +++ b/packages/sf_localizations/assets/l10n/es.json @@ -935,6 +935,7 @@ "timezoneUpdated": "Zona horaria actualizada", "errorTimezone": "No se pudo actualizar la zona horaria", "timezoneOther": "Otras zonas horarias", + "timezoneUsePhone": "Usar la zona horaria del móvil", "takingPhoto": "Tomando foto...", "errorTakePicture": "Error al tomar la foto", "errorFetchPhotos": "Error al obtener las fotos", diff --git a/packages/sf_localizations/assets/l10n/fr.json b/packages/sf_localizations/assets/l10n/fr.json index 51617cf8..ebbc13e5 100644 --- a/packages/sf_localizations/assets/l10n/fr.json +++ b/packages/sf_localizations/assets/l10n/fr.json @@ -766,6 +766,7 @@ "timezoneUpdated": "Fuseau horaire mis à jour", "errorTimezone": "Impossible de mettre à jour le fuseau horaire", "timezoneOther": "Autres fuseaux horaires", + "timezoneUsePhone": "Utiliser le fuseau horaire du téléphone", "takingPhoto": "Prise de photo...", "errorTakePicture": "Erreur lors de la prise de photo", "errorFetchPhotos": "Erreur lors de la récupération des photos", diff --git a/packages/sf_localizations/assets/l10n/it.json b/packages/sf_localizations/assets/l10n/it.json index 3092366d..4c1f25cb 100644 --- a/packages/sf_localizations/assets/l10n/it.json +++ b/packages/sf_localizations/assets/l10n/it.json @@ -766,6 +766,7 @@ "timezoneUpdated": "Fuso orario aggiornato", "errorTimezone": "Impossibile aggiornare il fuso orario", "timezoneOther": "Altri fusi orari", + "timezoneUsePhone": "Usa il fuso orario del telefono", "takingPhoto": "Scattando foto...", "errorTakePicture": "Errore durante lo scatto della foto", "errorFetchPhotos": "Errore durante il recupero delle foto", diff --git a/packages/sf_localizations/assets/l10n/pt.json b/packages/sf_localizations/assets/l10n/pt.json index 69c9bbc4..6fb810d5 100644 --- a/packages/sf_localizations/assets/l10n/pt.json +++ b/packages/sf_localizations/assets/l10n/pt.json @@ -766,6 +766,7 @@ "timezoneUpdated": "Fuso horário atualizado", "errorTimezone": "Não foi possível atualizar o fuso horário", "timezoneOther": "Outros fusos horários", + "timezoneUsePhone": "Usar o fuso horário do telemóvel", "takingPhoto": "Tirando foto...", "errorTakePicture": "Erro ao tirar foto", "errorFetchPhotos": "Erro ao obter fotos", diff --git a/packages/sf_localizations/lib/src/generated/i18n.dart b/packages/sf_localizations/lib/src/generated/i18n.dart index ee97637a..10eaacf1 100755 --- a/packages/sf_localizations/lib/src/generated/i18n.dart +++ b/packages/sf_localizations/lib/src/generated/i18n.dart @@ -887,6 +887,7 @@ class I18n { static const String timezoneOther = 'timezoneOther'; static const String timezoneSearch = 'timezoneSearch'; static const String timezoneUpdated = 'timezoneUpdated'; + static const String timezoneUsePhone = 'timezoneUsePhone'; static const String today = 'today'; static const String topApps = 'topApps'; static const String totalSteps = 'totalSteps';