feat(legacy-settings): DST-aware timezone with phone auto-detect

This commit is contained in:
2026-04-22 01:10:10 +02:00
parent 92e93a2b69
commit 2247833203
14 changed files with 210 additions and 161 deletions

View File

@@ -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<void> initApp(EnvironmentEnum env) async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await initializeDateFormatting();
tz.initializeTimeZones();
final sharedPreferences = await SharedPreferences.getInstance();

View File

@@ -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

View File

@@ -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;
}

View File

@@ -13,7 +13,7 @@ part of 'timezone_selection_provider.dart';
const timezoneSelectionProvider = TimezoneSelectionProvider._();
final class TimezoneSelectionProvider
extends $NotifierProvider<TimezoneSelection, int?> {
extends $NotifierProvider<TimezoneSelection, String?> {
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<int?>(value),
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$timezoneSelectionHash() => r'f108a0d1664e0c7a8423b1ec735745ac1781795b';
String _$timezoneSelectionHash() => r'97150a64513baae3f18edde7da8396b91f4f1e2f';
abstract class _$TimezoneSelection extends $Notifier<int?> {
int? build();
abstract class _$TimezoneSelection extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<int?, int?>;
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<int?, int?>,
int?,
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;

View File

@@ -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<void> 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<void> 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),
],
),
),

View File

@@ -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;

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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';