From 89d8b4b5a0bd6625718f242d8f3a8bdb2dd8dae5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 12:16:52 -0400 Subject: [PATCH] feat(flutter): send timezone on login + app-start + weekly Flutter client posts FlutterTimezone.getLocalTimezone() to PUT /api/me/timezone on every setSession (login / register success) and on every AuthController.build (app cold-start with valid session), when the locally-stored tz_last_sent_at is >7 days old. Cadence tracked in flutter_secure_storage so it survives app restarts. Failures swallowed: the server's UTC default + last-known value keep the scheduler functioning until the next attempt. Completes the client side of #392 Half B (per-user timezone scheduling). Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/api/endpoints/me.dart | 8 +++++ flutter_client/lib/auth/auth_provider.dart | 41 +++++++++++++++++++--- flutter_client/pubspec.lock | 8 +++++ flutter_client/pubspec.yaml | 1 + 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart index 3d5656ab..9d867c2e 100644 --- a/flutter_client/lib/api/endpoints/me.dart +++ b/flutter_client/lib/api/endpoints/me.dart @@ -38,4 +38,12 @@ class MeApi { ); return SystemPlaylistsStatus.fromJson(r.data ?? const {}); } + + /// PUT /api/me/timezone — submit the device's IANA timezone. The + /// scheduler uses this to fire the user's daily playlist build at + /// 03:00 in their local time. See AuthController._sendTimezoneIfStale + /// for the weekly cadence trigger. + Future putTimezone(String timezone) async { + await _dio.put('/api/me/timezone', data: {'timezone': timezone}); + } } diff --git a/flutter_client/lib/auth/auth_provider.dart b/flutter_client/lib/auth/auth_provider.dart index af8ca625..4ab61793 100644 --- a/flutter_client/lib/auth/auth_provider.dart +++ b/flutter_client/lib/auth/auth_provider.dart @@ -2,12 +2,17 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import '../api/endpoints/me.dart'; +import '../library/library_providers.dart' show dioProvider; import '../models/user.dart'; -const _kServerUrl = 'server_url'; -const _kSessionToken = 'session_token'; -const _kCurrentUser = 'current_user'; +const _kServerUrl = 'server_url'; +const _kSessionToken = 'session_token'; +const _kCurrentUser = 'current_user'; +const _kTzLastSentAt = 'tz_last_sent_at'; +const _weeklyMs = 7 * 24 * 60 * 60 * 1000; final secureStorageProvider = Provider( (ref) => const FlutterSecureStorage(), @@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier { _storage = ref.watch(secureStorageProvider); final raw = await _storage.read(key: _kCurrentUser); if (raw == null) return null; - return User.fromJson(jsonDecode(raw) as Map); + final user = User.fromJson(jsonDecode(raw) as Map); + // Fire-and-forget timezone send on app start with an existing + // session — no-op when last_sent_at is fresh. + // ignore: unawaited_futures + _sendTimezoneIfStale(); + return user; } Future setServerUrl(String url) async { @@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier { await _storage.write(key: _kCurrentUser, value: userJson); ref.invalidate(sessionTokenProvider); state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); + // ignore: unawaited_futures + _sendTimezoneIfStale(); } Future clearSession() async { @@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier { ref.invalidate(sessionTokenProvider); state = const AsyncData(null); } + + /// Sends the device's current IANA timezone to PUT /api/me/timezone + /// when the last successful send was >7 days ago (or never). + /// Cadence persists in flutter_secure_storage so it survives app + /// restarts. Failures are swallowed: the server keeps its previous + /// value (or 'UTC' default) until the next attempt. + Future _sendTimezoneIfStale() async { + try { + final lastStr = await _storage.read(key: _kTzLastSentAt); + final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - lastMs < _weeklyMs) return; + final tz = await FlutterTimezone.getLocalTimezone(); + if (tz.isEmpty) return; + final dio = await ref.read(dioProvider.future); + await MeApi(dio).putTimezone(tz); + await _storage.write(key: _kTzLastSentAt, value: nowMs.toString()); + } catch (_) { + // Non-fatal — server falls back to UTC or last-known value. + } + } } final authControllerProvider = diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index 95704394..4c049000 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -411,6 +411,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" + url: "https://pub.dev" + source: hosted + version: "4.1.1" flutter_web_plugins: dependency: transitive description: flutter diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 68d04ddf..6da3624f 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: drift_flutter: ^0.2.0 sqlite3_flutter_libs: ^0.5.24 connectivity_plus: ^6.0.5 + flutter_timezone: ^4.1.1 dev_dependencies: flutter_test: