refactor(legacy): make guardDeviceCommand async with stale TTL refetch

Convert the shared command guard to an async check that refetches
/devices when the cached state is older than 30s, so the isDisconnect
flag reflects reality before a command runs. A TopSnackBar explains the
check only if the fetch takes longer than 400ms, avoiding noise on fast
responses. Update all 44 call sites to await the guard.
This commit is contained in:
2026-04-17 09:43:52 +02:00
parent ecbb6d1e76
commit 0b160758e2
35 changed files with 163 additions and 90 deletions

View File

@@ -39,7 +39,7 @@ class PedometerToggle extends ConsumerWidget {
value: enabled, value: enabled,
activeTrackColor: theme.getColorFor(ThemeCode.legacyPrimary), activeTrackColor: theme.getColorFor(ThemeCode.legacyPrimary),
onChanged: (value) async { onChanged: (value) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final success = await ref final success = await ref
.read(activityMeterViewModelProvider.notifier) .read(activityMeterViewModelProvider.notifier)
.togglePedometer(enabled: value); .togglePedometer(enabled: value);

View File

@@ -97,8 +97,8 @@ class BackgroundImageScreen extends ConsumerWidget {
child: IconButton( child: IconButton(
onPressed: state.isSaving onPressed: state.isSaving
? null ? null
: () { : () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.uploadPhoto(); vm.uploadPhoto();
}, },
icon: Icon( icon: Icon(
@@ -119,16 +119,16 @@ class BackgroundImageScreen extends ConsumerWidget {
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: state.photos.isEmpty : state.photos.isEmpty
? _EmptyState( ? _EmptyState(
onUpload: () { onUpload: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.uploadPhoto(); vm.uploadPhoto();
}, },
primaryColor: primaryColor, primaryColor: primaryColor,
) )
: _PhotoGrid( : _PhotoGrid(
state: state, state: state,
onPhotoTap: (id) { onPhotoTap: (id) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.setAsBackground(id); vm.setAsBackground(id);
}, },
primaryColor: primaryColor, primaryColor: primaryColor,

View File

@@ -58,8 +58,9 @@ class ContactsScreen extends ConsumerWidget {
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: () { onTap: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
if (state.contacts.length >= state.maxContacts) { if (state.contacts.length >= state.maxContacts) {
showTopSnackbar( showTopSnackbar(
context, context,

View File

@@ -155,8 +155,8 @@ class DoNotDisturbScreen extends ConsumerWidget {
child: isSaving child: isSaving
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: PrimaryButton( : PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.save(); vm.save();
}, },
text: context.translate(I18n.doNotDisturbSave), text: context.translate(I18n.doNotDisturbSave),

View File

@@ -110,7 +110,7 @@ class _HealthScreenState extends ConsumerState<HealthScreen>
options: device.capabilities!.heartbeats!.options, options: device.capabilities!.heartbeats!.options,
theme: theme, theme: theme,
onChanged: (frequency) async { onChanged: (frequency) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final success = await vm.updateHeartRateFrequency( final success = await vm.updateHeartRateFrequency(
frequency: frequency, frequency: frequency,
); );
@@ -199,8 +199,8 @@ class _SaveSection extends ConsumerWidget {
child: PrimaryButton( child: PrimaryButton(
onPressed: isMeasuring onPressed: isMeasuring
? null ? null
: () { : () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.measure(); vm.measure();
}, },
text: isMeasuring ? '...' : context.translate(I18n.measure), text: isMeasuring ? '...' : context.translate(I18n.measure),

View File

@@ -90,8 +90,8 @@ class LocateDeviceDialog extends ConsumerWidget {
SizedBox(width: SizeUtils.getByScreen(small: 8, big: 16)), SizedBox(width: SizeUtils.getByScreen(small: 8, big: 16)),
Expanded( Expanded(
child: PrimaryButton( child: PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.locateDevice(); vm.locateDevice();
}, },
text: context.translate(I18n.accept), text: context.translate(I18n.accept),

View File

@@ -152,8 +152,8 @@ class _TakePictureSection extends ConsumerWidget {
big: EdgeInsets.symmetric(vertical: 10, horizontal: 25), big: EdgeInsets.symmetric(vertical: 10, horizontal: 25),
), ),
child: PrimaryButton( child: PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.takePicture(); vm.takePicture();
}, },
text: context.translate(I18n.takePicture), text: context.translate(I18n.takePicture),

View File

@@ -33,8 +33,9 @@ class RemoteConnectionScreen extends ConsumerWidget {
children: [ children: [
if (cameraEnabled) ...[ if (cameraEnabled) ...[
_SectionButton( _SectionButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -50,8 +51,9 @@ class RemoteConnectionScreen extends ConsumerWidget {
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 15)), SizedBox(height: SizeUtils.getByScreen(small: 16, big: 15)),
], ],
_SectionButton( _SectionButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => Dialog(child: SpyCallDialog()), builder: (context) => Dialog(child: SpyCallDialog()),

View File

@@ -77,13 +77,13 @@ class SpyCallDialog extends ConsumerWidget {
), ),
), ),
SizedBox(height: SizeUtils.getByScreen(small: 12, big: 10)), SizedBox(height: SizeUtils.getByScreen(small: 12, big: 10)),
_PhoneSection(onSubmit: () { _PhoneSection(onSubmit: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.call(); vm.call();
}), }),
SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)), SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)),
_CallSection(onPressed: () { _CallSection(onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.call(); vm.call();
}), }),
], ],

View File

@@ -104,8 +104,9 @@ class _ScheduledActivitiesScreenState
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: () { onTap: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showActivityFormSheet(context, weekDay: _selectedWeekDay); showActivityFormSheet(context, weekDay: _selectedWeekDay);
}, },
child: SizedBox( child: SizedBox(

View File

@@ -97,9 +97,10 @@ class _ActivityFormSheetState extends ConsumerState<ActivityFormSheet> {
}); });
} }
void _submit() { Future<void> _submit() async {
if (!_isFormValid) return; if (!_isFormValid) return;
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
final name = _nameController.text.trim(); final name = _nameController.text.trim();
final period = _buildPeriod(); final period = _buildPeriod();

View File

@@ -188,8 +188,9 @@ class _ActivityTimelineCard extends ConsumerWidget {
), ),
), ),
IconButton( IconButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showActivityFormSheet(context, activity: activity); showActivityFormSheet(context, activity: activity);
}, },
icon: Icon( icon: Icon(
@@ -201,8 +202,9 @@ class _ActivityTimelineCard extends ConsumerWidget {
padding: EdgeInsets.all(SizeUtils.getByScreen(small: 8, big: 6)), padding: EdgeInsets.all(SizeUtils.getByScreen(small: 8, big: 6)),
), ),
IconButton( IconButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(

View File

@@ -81,8 +81,8 @@ class VolumeControlScreen extends ConsumerWidget {
child: state.isLoading child: state.isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: PrimaryButton( : PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.submit(); vm.submit();
}, },
text: context.translate(I18n.volumeSend), text: context.translate(I18n.volumeSend),

View File

@@ -75,7 +75,7 @@ class _FrequentPlaceSheetState extends ConsumerState<_FrequentPlaceSheet> {
Future<void> _submit() async { Future<void> _submit() async {
if (!_canSave) return; if (!_canSave) return;
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final vm = ref.read(locationViewModelProvider.notifier); final vm = ref.read(locationViewModelProvider.notifier);
final bool success; final bool success;

View File

@@ -91,7 +91,7 @@ class _GeofenceSheetState extends ConsumerState<_GeofenceSheet> {
Future<void> _submit() async { Future<void> _submit() async {
if (!_canSave) return; if (!_canSave) return;
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final vm = ref.read(locationViewModelProvider.notifier); final vm = ref.read(locationViewModelProvider.notifier);
final description = _descriptionController.text.trim(); final description = _descriptionController.text.trim();

View File

@@ -237,7 +237,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
} }
Future<void> _updateFrequency(int frequency) async { Future<void> _updateFrequency(int frequency) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final success = await ref final success = await ref
.read(locationViewModelProvider.notifier) .read(locationViewModelProvider.notifier)
.updateLocationFrequency(frequency: frequency); .updateLocationFrequency(frequency: frequency);
@@ -264,8 +264,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
} }
} }
void _confirmPlacement() { Future<void> _confirmPlacement() async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
final center = _mapController.camera.center; final center = _mapController.camera.center;
final mapState = ref.read(locationMapViewModelProvider); final mapState = ref.read(locationMapViewModelProvider);
@@ -289,8 +290,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
} }
} }
void _confirmRadius() { Future<void> _confirmRadius() async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
final mapState = ref.read(locationMapViewModelProvider); final mapState = ref.read(locationMapViewModelProvider);
final point = mapState.previewPoint!; final point = mapState.previewPoint!;
final radius = mapState.previewRadius; final radius = mapState.previewRadius;
@@ -414,8 +416,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
} }
} }
void _onEditFrequentPlace(FrequentPlaceEntity fp) { Future<void> _onEditFrequentPlace(FrequentPlaceEntity fp) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
_vm.clearSelectedFrequentPlace(); _vm.clearSelectedFrequentPlace();
showNameInputSheet( showNameInputSheet(
context, context,
@@ -673,12 +676,13 @@ class _LocationMapState extends ConsumerState<LocationMap>
onAddGeofence: () => _vm.startPlacing(PlacingMode.geofence), onAddGeofence: () => _vm.startPlacing(PlacingMode.geofence),
onAddFrequentPlace: () => _vm.startPlacing(PlacingMode.frequentPlace), onAddFrequentPlace: () => _vm.startPlacing(PlacingMode.frequentPlace),
onShareTap: _shareLocation, onShareTap: _shareLocation,
onRefreshTap: () { onRefreshTap: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
unawaited( unawaited(
ref.read(sfTrackingProvider).legacyLocationMapRefreshTapped(), ref.read(sfTrackingProvider).legacyLocationMapRefreshTapped(),
); );
widget.onRefreshPosition(); widget.onRefreshPosition();
if (!mounted) return;
showTopSnackbar( showTopSnackbar(
context, context,
message: context.translate(I18n.locationMapRefreshRequested), message: context.translate(I18n.locationMapRefreshRequested),
@@ -686,9 +690,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
); );
}, },
onCenterTap: _centerOnDevice, onCenterTap: _centerOnDevice,
onToggleFollow: () { onToggleFollow: () async {
final willActivate = !mapState.isFollowing; final willActivate = !mapState.isFollowing;
if (willActivate && !guardDeviceCommand(context, ref)) return; if (willActivate && !await guardDeviceCommand(context, ref)) return;
_vm.toggleFollowing(); _vm.toggleFollowing();
unawaited( unawaited(
ref ref
@@ -698,6 +702,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
if (willActivate && widget.selectedPosition != null) { if (willActivate && widget.selectedPosition != null) {
_centerOnDevice(); _centerOnDevice();
} }
if (!mounted) return;
showTopSnackbar( showTopSnackbar(
context, context,
message: context.translate( message: context.translate(
@@ -721,12 +726,12 @@ class _LocationMapState extends ConsumerState<LocationMap>
child: GeofenceInfoCard( child: GeofenceInfoCard(
geofence: mapState.selectedGeofence!, geofence: mapState.selectedGeofence!,
onClose: _vm.clearSelectedGeofence, onClose: _vm.clearSelectedGeofence,
onEdit: () { onEdit: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
_vm.startEditingGeofence(mapState.selectedGeofence!); _vm.startEditingGeofence(mapState.selectedGeofence!);
}, },
onDelete: () { onDelete: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final id = mapState.selectedGeofence!.id; final id = mapState.selectedGeofence!.id;
_vm.clearSelectedGeofence(); _vm.clearSelectedGeofence();
ref ref
@@ -742,8 +747,8 @@ class _LocationMapState extends ConsumerState<LocationMap>
frequentPlace: mapState.selectedFrequentPlace!, frequentPlace: mapState.selectedFrequentPlace!,
onClose: _vm.clearSelectedFrequentPlace, onClose: _vm.clearSelectedFrequentPlace,
onEdit: () => _onEditFrequentPlace(mapState.selectedFrequentPlace!), onEdit: () => _onEditFrequentPlace(mapState.selectedFrequentPlace!),
onDelete: () { onDelete: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
final id = mapState.selectedFrequentPlace!.id; final id = mapState.selectedFrequentPlace!.id;
_vm.clearSelectedFrequentPlace(); _vm.clearSelectedFrequentPlace();
ref ref

View File

@@ -92,8 +92,9 @@ class AlarmScreen extends ConsumerWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
_openForm(context, vm); _openForm(context, vm);
}, },
icon: Icon( icon: Icon(
@@ -126,8 +127,8 @@ class AlarmScreen extends ConsumerWidget {
child: isSaving child: isSaving
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: PrimaryButton( : PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.save(); vm.save();
}, },
text: context.translate(I18n.alarmSave), text: context.translate(I18n.alarmSave),

View File

@@ -81,8 +81,8 @@ class AlertsScreen extends ConsumerWidget {
child: PrimaryButton( child: PrimaryButton(
onPressed: state.isSaving onPressed: state.isSaving
? null ? null
: () { : () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.save(); vm.save();
}, },
text: state.isSaving ? '...' : context.translate(I18n.save), text: state.isSaving ? '...' : context.translate(I18n.save),

View File

@@ -99,8 +99,8 @@ class BatteryScreen extends ConsumerWidget {
activeTrackColor: primaryColor, activeTrackColor: primaryColor,
onChanged: state.isSaving onChanged: state.isSaving
? null ? null
: (value) { : (value) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.toggleNightMode(value); vm.toggleNightMode(value);
}, },
), ),

View File

@@ -84,8 +84,9 @@ class BlockPhoneScreen extends ConsumerWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showAddContactSheet(context); showAddContactSheet(context);
}, },
icon: Icon( icon: Icon(
@@ -216,13 +217,14 @@ class _ContactList extends ConsumerWidget {
); );
} }
void _confirmDelete( Future<void> _confirmDelete(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
int index, int index,
String name, String name,
) { ) async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
final theme = ref.read(themePortProvider); final theme = ref.read(themePortProvider);
final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary);

View File

@@ -78,8 +78,8 @@ class DisableFunctionsScreen extends ConsumerWidget {
child: PrimaryButton( child: PrimaryButton(
onPressed: state.isSaving onPressed: state.isSaving
? null ? null
: () { : () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.save(); vm.save();
}, },
text: state.isSaving ? '...' : context.translate(I18n.save), text: state.isSaving ? '...' : context.translate(I18n.save),

View File

@@ -110,8 +110,8 @@ class _SaveSection extends ConsumerWidget {
child: isLoading child: isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: PrimaryButton( : PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.submit(); vm.submit();
}, },
text: context.translate(I18n.save), text: context.translate(I18n.save),

View File

@@ -51,8 +51,9 @@ class _OptionsSection extends ConsumerWidget {
title: context.translate(I18n.remoteTurnOff), title: context.translate(I18n.remoteTurnOff),
subtitle: context.translate(I18n.remoteTurnOffMessage), subtitle: context.translate(I18n.remoteTurnOffMessage),
icon: Icons.settings_power_outlined, icon: Icons.settings_power_outlined,
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => Dialog( builder: (context) => Dialog(
@@ -95,8 +96,9 @@ class _OptionsSection extends ConsumerWidget {
title: context.translate(I18n.remoteRestart), title: context.translate(I18n.remoteRestart),
subtitle: context.translate(I18n.remoteRestartMessage), subtitle: context.translate(I18n.remoteRestartMessage),
icon: Icons.refresh_outlined, icon: Icons.refresh_outlined,
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => Dialog( builder: (context) => Dialog(
@@ -117,8 +119,9 @@ class _OptionsSection extends ConsumerWidget {
title: context.translate(I18n.remoteFactoryReset), title: context.translate(I18n.remoteFactoryReset),
subtitle: context.translate(I18n.remoteFactoryResetMessage), subtitle: context.translate(I18n.remoteFactoryResetMessage),
icon: Icons.restart_alt_outlined, icon: Icons.restart_alt_outlined,
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => Dialog( builder: (context) => Dialog(

View File

@@ -177,8 +177,8 @@ class _SaveSection extends ConsumerWidget {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: PrimaryButton( child: PrimaryButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.submit(); vm.submit();
}, },
text: context.translate(I18n.save), text: context.translate(I18n.save),

View File

@@ -96,8 +96,8 @@ class TimezoneScreen extends ConsumerWidget {
child: PrimaryButton( child: PrimaryButton(
onPressed: state.isSaving onPressed: state.isSaving
? null ? null
: () { : () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.save(); vm.save();
}, },
text: state.isSaving ? '...' : context.translate(I18n.save), text: state.isSaving ? '...' : context.translate(I18n.save),

View File

@@ -94,8 +94,8 @@ class WifiSettingsScreen extends ConsumerWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
onPressed: () { onPressed: () async {
if (!guardDeviceCommand(context, ref)) return; if (!await guardDeviceCommand(context, ref)) return;
vm.scanNetworks(); vm.scanNetworks();
}, },
icon: Icon( icon: Icon(
@@ -275,8 +275,13 @@ class _Body extends ConsumerWidget {
); );
} }
void _confirmDelete(BuildContext context, WidgetRef ref, dynamic network) { Future<void> _confirmDelete(
if (!guardDeviceCommand(context, ref)) return; BuildContext context,
WidgetRef ref,
dynamic network,
) async {
if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
final theme = ref.read(themePortProvider); final theme = ref.read(themePortProvider);
final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary);

View File

@@ -1,12 +1,36 @@
import 'dart:async';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart'; import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart'; import 'package:sf_shared/sf_shared.dart';
bool guardDeviceCommand(BuildContext context, WidgetRef ref) { const _deviceConnectionTtl = Duration(seconds: 30);
final device = ref.read(selectedDeviceProvider).value; const _snackbarDelay = Duration(milliseconds: 400);
Future<bool> guardDeviceCommand(BuildContext context, WidgetRef ref) async {
final notifier = ref.read(legacyDevicesProvider.notifier);
if (notifier.isStale(_deviceConnectionTtl)) {
final fetchFuture = notifier.refreshIfStale(_deviceConnectionTtl);
final delayedSnackbar = Timer(_snackbarDelay, () {
if (!context.mounted) return;
showTopSnackbar(
context,
message: context.translate(I18n.checkingDeviceConnection),
type: MessageType.info,
);
});
await fetchFuture;
delayedSnackbar.cancel();
}
if (!context.mounted) return false;
final device = await ref.read(selectedDeviceProvider.future);
if (device == null || device.isDisconnected) { if (device == null || device.isDisconnected) {
if (!context.mounted) return false;
showTopSnackbar( showTopSnackbar(
context, context,
message: context.translate(I18n.errorDeviceDisconnected), message: context.translate(I18n.errorDeviceDisconnected),

View File

@@ -691,6 +691,7 @@
"errorContactsMax": "Maximale Kontaktanzahl für dieses Gerät erreicht", "errorContactsMax": "Maximale Kontaktanzahl für dieses Gerät erreicht",
"errorPositions": "Positionen konnten nicht geladen werden", "errorPositions": "Positionen konnten nicht geladen werden",
"errorDeviceDisconnected": "Die Uhr ist getrennt und kann keine Befehle empfangen", "errorDeviceDisconnected": "Die Uhr ist getrennt und kann keine Befehle empfangen",
"checkingDeviceConnection": "Verbindung des Geräts wird überprüft...",
"errorSosContactsMax": "Maximale SOS-Kontaktanzahl für dieses Gerät erreicht", "errorSosContactsMax": "Maximale SOS-Kontaktanzahl für dieses Gerät erreicht",
"customBackground": "Benutzerdefiniertes Hintergrundbild", "customBackground": "Benutzerdefiniertes Hintergrundbild",
"backgroundImageDescription": "Legen Sie ein Foto als benutzerdefinierten Bildschirmschoner für das Gerät fest", "backgroundImageDescription": "Legen Sie ein Foto als benutzerdefinierten Bildschirmschoner für das Gerät fest",

View File

@@ -859,6 +859,7 @@
"errorContactsMax": "Maximum contacts reached for this device", "errorContactsMax": "Maximum contacts reached for this device",
"errorPositions": "Could not load positions", "errorPositions": "Could not load positions",
"errorDeviceDisconnected": "The watch is disconnected and cannot receive commands", "errorDeviceDisconnected": "The watch is disconnected and cannot receive commands",
"checkingDeviceConnection": "Checking if the device is connected...",
"errorSosContactsMax": "Maximum SOS contacts reached for this device", "errorSosContactsMax": "Maximum SOS contacts reached for this device",
"customBackground": "Custom background image", "customBackground": "Custom background image",
"backgroundImageDescription": "Set a photo as a custom screensaver for the device", "backgroundImageDescription": "Set a photo as a custom screensaver for the device",

View File

@@ -860,6 +860,7 @@
"errorContactsMax": "Se ha alcanzado el máximo de contactos para este dispositivo", "errorContactsMax": "Se ha alcanzado el máximo de contactos para este dispositivo",
"errorPositions": "No se pudieron cargar las posiciones", "errorPositions": "No se pudieron cargar las posiciones",
"errorDeviceDisconnected": "El reloj está desconectado y no puede recibir comandos", "errorDeviceDisconnected": "El reloj está desconectado y no puede recibir comandos",
"checkingDeviceConnection": "Revisando si el dispositivo está conectado...",
"errorSosContactsMax": "Se ha alcanzado el máximo de contactos SOS para este dispositivo", "errorSosContactsMax": "Se ha alcanzado el máximo de contactos SOS para este dispositivo",
"customBackground": "Fondo de pantalla personalizado", "customBackground": "Fondo de pantalla personalizado",
"backgroundImageDescription": "Configura una foto como protector de pantalla exclusivo para el dispositivo", "backgroundImageDescription": "Configura una foto como protector de pantalla exclusivo para el dispositivo",

View File

@@ -691,6 +691,7 @@
"errorContactsMax": "Nombre maximum de contacts atteint pour cet appareil", "errorContactsMax": "Nombre maximum de contacts atteint pour cet appareil",
"errorPositions": "Impossible de charger les positions", "errorPositions": "Impossible de charger les positions",
"errorDeviceDisconnected": "La montre est déconnectée et ne peut pas recevoir de commandes", "errorDeviceDisconnected": "La montre est déconnectée et ne peut pas recevoir de commandes",
"checkingDeviceConnection": "Vérification de la connexion de l'appareil...",
"errorSosContactsMax": "Nombre maximum de contacts SOS atteint pour cet appareil", "errorSosContactsMax": "Nombre maximum de contacts SOS atteint pour cet appareil",
"customBackground": "Image de fond personnalisée", "customBackground": "Image de fond personnalisée",
"backgroundImageDescription": "Définissez une photo comme écran de veille personnalisé pour l'appareil", "backgroundImageDescription": "Définissez une photo comme écran de veille personnalisé pour l'appareil",

View File

@@ -691,6 +691,7 @@
"errorContactsMax": "Numero massimo di contatti raggiunto per questo dispositivo", "errorContactsMax": "Numero massimo di contatti raggiunto per questo dispositivo",
"errorPositions": "Impossibile caricare le posizioni", "errorPositions": "Impossibile caricare le posizioni",
"errorDeviceDisconnected": "L'orologio è disconnesso e non può ricevere comandi", "errorDeviceDisconnected": "L'orologio è disconnesso e non può ricevere comandi",
"checkingDeviceConnection": "Verifica della connessione del dispositivo...",
"errorSosContactsMax": "Numero massimo di contatti SOS raggiunto per questo dispositivo", "errorSosContactsMax": "Numero massimo di contatti SOS raggiunto per questo dispositivo",
"customBackground": "Immagine di sfondo personalizzata", "customBackground": "Immagine di sfondo personalizzata",
"backgroundImageDescription": "Imposta una foto come screensaver personalizzato per il dispositivo", "backgroundImageDescription": "Imposta una foto come screensaver personalizzato per il dispositivo",

View File

@@ -691,6 +691,7 @@
"errorContactsMax": "Número máximo de contactos atingido para este dispositivo", "errorContactsMax": "Número máximo de contactos atingido para este dispositivo",
"errorPositions": "Não foi possível carregar as posições", "errorPositions": "Não foi possível carregar as posições",
"errorDeviceDisconnected": "O relógio está desconectado e não pode receber comandos", "errorDeviceDisconnected": "O relógio está desconectado e não pode receber comandos",
"checkingDeviceConnection": "A verificar se o dispositivo está conectado...",
"errorSosContactsMax": "Número máximo de contactos SOS atingido para este dispositivo", "errorSosContactsMax": "Número máximo de contactos SOS atingido para este dispositivo",
"customBackground": "Imagem de fundo personalizada", "customBackground": "Imagem de fundo personalizada",
"backgroundImageDescription": "Defina uma foto como protetor de ecrã personalizado para o dispositivo", "backgroundImageDescription": "Defina uma foto como protetor de ecrã personalizado para o dispositivo",

View File

@@ -395,6 +395,7 @@ class I18n {
static const String errorContactsMax = 'errorContactsMax'; static const String errorContactsMax = 'errorContactsMax';
static const String errorPositions = 'errorPositions'; static const String errorPositions = 'errorPositions';
static const String errorDeviceDisconnected = 'errorDeviceDisconnected'; static const String errorDeviceDisconnected = 'errorDeviceDisconnected';
static const String checkingDeviceConnection = 'checkingDeviceConnection';
static const String errorSosContactsMax = 'errorSosContactsMax'; static const String errorSosContactsMax = 'errorSosContactsMax';
static const String errorContactsMin = 'errorContactsMin'; static const String errorContactsMin = 'errorContactsMin';
static const String errorDisableFunctions = 'errorDisableFunctions'; static const String errorDisableFunctions = 'errorDisableFunctions';

View File

@@ -2,9 +2,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart'; import 'package:sf_shared/sf_shared.dart';
class LegacyDevices extends AsyncNotifier<List<DeviceEntity>> { class LegacyDevices extends AsyncNotifier<List<DeviceEntity>> {
DateTime? _lastFetchedAt;
@override @override
Future<List<DeviceEntity>> build() { Future<List<DeviceEntity>> build() async {
return ref.read(sharedDevicesRepositoryProvider).getDevices(); final devices = await ref.read(sharedDevicesRepositoryProvider).getDevices();
_lastFetchedAt = DateTime.now();
return devices;
} }
void removeDevice(String deviceId) { void removeDevice(String deviceId) {
@@ -30,6 +34,21 @@ class LegacyDevices extends AsyncNotifier<List<DeviceEntity>> {
state = await AsyncValue.guard( state = await AsyncValue.guard(
() => ref.read(sharedDevicesRepositoryProvider).getDevices(), () => ref.read(sharedDevicesRepositoryProvider).getDevices(),
); );
_lastFetchedAt = DateTime.now();
}
bool isStale(Duration ttl) {
final last = _lastFetchedAt;
return last == null || DateTime.now().difference(last) >= ttl;
}
Future<void> refreshIfStale(Duration ttl) async {
if (!isStale(ttl)) return;
final result = await AsyncValue.guard(
() => ref.read(sharedDevicesRepositoryProvider).getDevices(),
);
state = result;
_lastFetchedAt = DateTime.now();
} }
} }