updateTimezoneOrOffsetIfChanged method
Updates timezone and/or offset in Firestore if changed.
Performs a fast local check first (no network if unchanged and within throttle window). Only syncs to server when:
- Timezone IANA string changed (user traveled)
- Timezone offset changed (DST transition)
- Forced refresh interval exceeded (safety net for missed DST)
Throttling
- Change debounce: 10 minutes (prevents rapid flapping near borders)
- Unchanged throttle: 48 hours (avoids resume spam)
- Forced refresh: 48 hours (catches missed DST transitions)
When Called
- On app resume from background (via AppLifecycleService)
- Throttled automatically
Returns
Right(true)if server was updatedRight(false)if unchanged or throttled (no update needed)Left(RepositoryFailure)on failure
DST Safety
The system computes offset using DateTime.now().timeZoneOffset.inMinutes,
which correctly handles half-hour and 45-minute offsets (India, Nepal, etc).
DST transitions are detected because offset changes even when the IANA
timezone string doesn't.
Implementation
@override
Future<Either<RepositoryFailure, bool>> updateTimezoneOrOffsetIfChanged() async {
logd('DeviceService: updateTimezoneOrOffsetIfChanged called');
try {
// Fast local checks (no network)
final currentTimezone = await getCurrentTimezone();
final currentOffsetMinutes = _getCurrentOffsetMinutes();
final now = DateTime.now();
// Check if values actually changed
final timezoneChanged = _cachedTimezone != currentTimezone;
final offsetChanged = _cachedOffsetMinutes != currentOffsetMinutes;
final didChange = timezoneChanged || offsetChanged;
// Get throttle configuration
final changeDebounceMinutes = AppConfigBase.deviceTimezoneChangeDebounceMinutes;
final unchangedSyncMinMinutes = AppConfigBase.deviceTimezoneUnchangedSyncMinMinutes;
final unchangedSyncMaxMinutes = AppConfigBase.deviceTimezoneUnchangedSyncMaxMinutes;
// Clamp max to be at least min (defensive against misconfiguration)
final effectiveMax = unchangedSyncMaxMinutes < unchangedSyncMinMinutes
? unchangedSyncMinMinutes
: unchangedSyncMaxMinutes;
// Check throttle conditions
final withinChangeDebounce = _lastServerSyncAt != null &&
now.difference(_lastServerSyncAt!) < Duration(minutes: changeDebounceMinutes);
final recentlySyncedUnchanged = _lastServerSyncAt != null &&
now.difference(_lastServerSyncAt!) < Duration(minutes: unchangedSyncMinMinutes);
// Safety net: Force sync if max interval exceeded, even if within min interval.
// In normal operation this never triggers (min < max means recentlySyncedUnchanged
// is already false when we'd exceed max), but provides self-healing if
// _lastServerSyncAt becomes stale due to bugs or unexpected state.
final exceededMaxInterval = _lastServerSyncAt != null &&
now.difference(_lastServerSyncAt!) >= Duration(minutes: effectiveMax);
// Apply throttling logic
if (didChange && withinChangeDebounce) {
logd(
'DeviceService: Timezone/offset changed but within debounce window, skipping sync');
return const Right(false);
}
if (!didChange && recentlySyncedUnchanged && !exceededMaxInterval) {
logd(
'DeviceService: Timezone/offset unchanged and recently synced, skipping sync');
// Try to flush any pending payload on this lifecycle event (if authenticated)
if (_isUserAuthenticated()) {
await _flushPendingPayload();
}
return const Right(false);
}
// Check authentication - if not authenticated, store pending and return
if (!_isUserAuthenticated()) {
logd('DeviceService: User not authenticated, storing pending timezone update');
final deviceId = await getDeviceId();
final platform = _getCurrentPlatform();
final platformString = DevicePlatformSerialization.serialize(platform);
final packageInfo = await PackageInfo.fromPlatform();
await _updatePendingPayload(
deviceId: deviceId,
timezone: currentTimezone,
timezoneOffsetMinutes: currentOffsetMinutes,
platform: platformString,
appVersion: packageInfo.version,
touch: true,
hasChangedFields: didChange,
);
// Return false since no server sync occurred - but data is saved
return const Right(false);
}
// Log when max interval forces a sync (self-healing scenario)
if (!didChange && exceededMaxInterval) {
logd(
'DeviceService: Forcing sync due to max interval ceiling ($effectiveMax min)');
}
// Perform server sync
logd(
'DeviceService: Syncing timezone/offset - changed: $didChange, timezone: $currentTimezone, offset: $currentOffsetMinutes');
final deviceId = await getDeviceId();
final platform = _getCurrentPlatform();
final platformString = DevicePlatformSerialization.serialize(platform);
final packageInfo = await PackageInfo.fromPlatform();
final result = await _deviceCallable.call({
'action': 'register', // Use register action which handles updates
'deviceId': deviceId,
'timezone': currentTimezone,
'timezoneOffsetMinutes': currentOffsetMinutes,
'platform': platformString,
'appVersion': packageInfo.version,
});
final data = Map<String, dynamic>.from(result.data as Map);
if (data['success'] != true) {
logw('DeviceService: Timezone sync response indicated failure');
// Store in pending payload for retry
await _updatePendingPayload(
deviceId: deviceId,
timezone: currentTimezone,
timezoneOffsetMinutes: currentOffsetMinutes,
platform: platformString,
appVersion: packageInfo.version,
touch: true,
hasChangedFields: didChange,
);
return const Left(RepositoryFailure.unexpected);
}
// Update cached values on success
_cachedTimezone = currentTimezone;
_cachedOffsetMinutes = currentOffsetMinutes;
_lastServerSyncAt = now;
_lastTouchAt = now; // register also updates lastActiveAt
// Clear any pending payload since we just synced
await _clearPendingPayload();
logd('DeviceService: Timezone/offset sync completed successfully');
return const Right(true);
} on FirebaseFunctionsException catch (e) {
loge(e, 'DeviceService: Firebase Functions error during timezone sync');
// Store in pending payload for retry on transient errors
if (_shouldStorePendingOnError(e)) {
await _storePendingTimezoneUpdate();
}
return _mapFirebaseFunctionsException(e);
} catch (e) {
loge(e, 'DeviceService: Unexpected error during timezone sync');
// Store in pending payload for retry
await _storePendingTimezoneUpdate();
return const Left(RepositoryFailure.unexpected);
}
}