updateTimezoneOrOffsetIfChanged method

  1. @override
Future<Either<RepositoryFailure, bool>> updateTimezoneOrOffsetIfChanged()
override

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 updated
  • Right(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);
  }
}