diff --git a/docs/plans/rtdb-presence.md b/docs/plans/rtdb-presence.md new file mode 100644 index 0000000..9d1e4b9 --- /dev/null +++ b/docs/plans/rtdb-presence.md @@ -0,0 +1,49 @@ +# Plan — Realtime Database presence for online friends + +- **Status:** Proposed +- **Effort:** Small +- **Firebase services:** Realtime Database, Authentication +- **Touches:** `lib/friends_screen.dart`, `lib/user_profile_page.dart`, new `lib/services/presence_service.dart` + +## Overview + +Show a small "online now" dot on friend cards and the profile page. Presence is ephemeral and high-churn, which makes Realtime Database (RTDB) `onDisconnect` a better fit than Firestore for cost and latency. + +## User flow + +1. User opens the Friends tab. +2. Friends who have the app in the foreground show a green dot next to their avatar; recently-active (<5 min background) show a grey dot. +3. Profile page mirrors the status in the header. + +## Technical approach + +- Add RTDB to the Firebase project (new database in the same region as Firestore, e.g. `europe-west1`). +- On auth state change to signed-in, `PresenceService` writes `/status/{uid}` = `{ state: "online", lastChanged: ServerValue.TIMESTAMP }` and registers `onDisconnect().set({ state: "offline", lastChanged: ServerValue.TIMESTAMP })`. +- On `AppLifecycleState.paused` set `state: "background"`; on `resumed` set `state: "online"`. +- Mirror to Firestore only on transitions via an RTDB-triggered Cloud Function (`functions/src/presence.ts` → `onValueWritten`), so Firestore-only clients can still read a coarse status. +- Friends screen subscribes directly to `/status/{friendUid}` refs for each friend in a batched `ValueEventListener`. + +## Data model + +- RTDB `/status/{uid}`: `{ state: "online" | "background" | "offline", lastChanged: number }`. +- Optional Firestore mirror at `users/{uid}.presence` for non-listening contexts. + +## Security rules + +- RTDB rules: `/status/{uid}` write-restricted to `auth.uid == $uid`; read restricted to signed-in users who are friends (enforce via Cloud Function mirror if RTDB rule complexity grows). + +## Rollout + +- Gate behind Remote Config flag `feature_presence_enabled` (default false). +- Ship dot UI behind the flag; enable for internal testers first. + +## Risks + +- RTDB adds a second datastore — keep logic thin, treat Firestore as source of truth. +- `onDisconnect` can fire late on mobile networks; treat "offline" as eventually consistent. +- Privacy: hide presence if the user toggles a "invisible mode" preference in settings. + +## Testing + +- Unit test `PresenceService` lifecycle transitions with a fake RTDB. +- Manual two-device test: sign in on both, kill one, verify the other sees offline within ~30 s. diff --git a/lib/claim.dart b/lib/claim.dart index 952c5d2..f14cf7e 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -125,11 +125,14 @@ class _ClaimState extends State with TickerProviderStateMixin { postboxes[entry.key as String] = Map.from(entry.value as Map); } + // Cloud Functions serialise JS numbers as either int or double; + // assigning a double to a typed int field throws, so normalise via num. + int asInt(dynamic v) => (v as num?)?.toInt() ?? 0; setState(() { - _count = counts['total'] ?? 0; - _maxPoints = points['max'] ?? 0; - _minPoints = points['min'] ?? 0; - _claimedToday = counts['claimedToday'] ?? 0; + _count = asInt(counts['total']); + _maxPoints = asInt(points['max']); + _minPoints = asInt(points['min']); + _claimedToday = asInt(counts['claimedToday']); _postboxes = postboxes; currentStage = _count > 0 ? ClaimStage.results : ClaimStage.empty; }); diff --git a/lib/nearby.dart b/lib/nearby.dart index 4f743cb..a047335 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -113,23 +113,26 @@ class _NearbyState extends State { final claimedCompass = data['claimedCompass'] != null ? Map.from(data['claimedCompass'] as Map) : {}; + // Cloud Functions serialise JS numbers as either int or double; assigning + // a double to a typed int field throws, so normalise every count via num. + int asInt(dynamic v) => (v as num?)?.toInt() ?? 0; setState(() { - _count = counts['total'] ?? 0; - _maxPoints = pts['max'] ?? 0; - _minPoints = pts['min'] ?? 0; + _count = asInt(counts['total']); + _maxPoints = asInt(pts['max']); + _minPoints = asInt(pts['min']); currentStage = NearbyStage.results; for (final dir in const [ 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW', ]) { - _compassCounts[dir] = compass[dir] ?? 0; - _claimedCompassCounts[dir] = claimedCompass[dir] ?? 0; + _compassCounts[dir] = asInt(compass[dir]); + _claimedCompassCounts[dir] = asInt(claimedCompass[dir]); } - _claimedToday = counts['claimedToday'] ?? 0; + _claimedToday = asInt(counts['claimedToday']); for (final cipher in MonarchInfo.all) { - _cipherTotals[cipher] = counts[cipher] ?? 0; - _cipherClaimed[cipher] = counts['${cipher}_claimed'] ?? 0; + _cipherTotals[cipher] = asInt(counts[cipher]); + _cipherClaimed[cipher] = asInt(counts['${cipher}_claimed']); } _lastScanned = DateTime.now(); }); diff --git a/lib/wear/wear_claim_page.dart b/lib/wear/wear_claim_page.dart index 32e1baf..b8f2445 100644 --- a/lib/wear/wear_claim_page.dart +++ b/lib/wear/wear_claim_page.dart @@ -52,8 +52,10 @@ class _WearClaimPageState extends State { }); if (!mounted) return; final counts = result.data['counts'] ?? {}; - final total = (counts['total'] as int?) ?? 0; - final claimed = (counts['claimedToday'] as int?) ?? 0; + // Cloud Functions serialise JS numbers as either int or double; `as int?` + // would throw on a double, so normalise via num. + final total = (counts['total'] as num?)?.toInt() ?? 0; + final claimed = (counts['claimedToday'] as num?)?.toInt() ?? 0; _postboxes = Map.from(result.data['postboxes'] ?? {}); setState(() { _count = total; diff --git a/lib/wear/wear_compass_page.dart b/lib/wear/wear_compass_page.dart index 8ab96c3..48174e2 100644 --- a/lib/wear/wear_compass_page.dart +++ b/lib/wear/wear_compass_page.dart @@ -75,8 +75,13 @@ class _WearCompassPageState extends State { final counts = result.data['counts'] ?? {}; final compassRaw = result.data['compass'] ?? {}; setState(() { - _totalCount = (counts['total'] as int?) ?? 0; - _compassCounts = Map.from(compassRaw); + // Cloud Functions serialise JS numbers as either int or double; + // `as int?` would throw on a double, so normalise via num. + _totalCount = (counts['total'] as num?)?.toInt() ?? 0; + _compassCounts = { + for (final e in (compassRaw as Map).entries) + e.key as String: (e.value as num).toInt(), + }; _stage = _CompassStage.results; }); // Haptic pulse to signal scan complete. @@ -206,7 +211,13 @@ class _WearCompassPageState extends State { angle: -rotation, child: CustomPaint( size: Size(size, size), - painter: FuzzyCompassPainter(sectors: sectorValues), + // Pass rotation so the painter counter-rotates the 'N' label; + // otherwise it spins with the canvas and is unreadable whenever + // the watch is not pointing north. + painter: FuzzyCompassPainter( + sectors: sectorValues, + rotation: rotation, + ), ), ), // Centre count overlay (doesn't rotate) diff --git a/lib/wear/wear_status_page.dart b/lib/wear/wear_status_page.dart index 6a2147a..093233d 100644 --- a/lib/wear/wear_status_page.dart +++ b/lib/wear/wear_status_page.dart @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget { class _WearStatusPageState extends State { final StreakService _streakService = StreakService(); late final Stream _streakStream = _streakService.streakStream(); + // Cache so the StreamBuilder doesn't re-subscribe (and briefly show empty + // stats) on every rebuild. Computed once in initState from the uid at mount. + late final Stream>>? _userDocStream = + _buildUserDocStream(); String? get _displayName => FirebaseAuth.instance.currentUser?.displayName; - Stream>>? _userDocStream() { + Stream>>? _buildUserDocStream() { final uid = FirebaseAuth.instance.currentUser?.uid; if (uid == null) return null; return FirebaseFirestore.instance.collection('users').doc(uid).snapshots(); @@ -71,7 +75,7 @@ class _WearStatusPageState extends State { // Lifetime points StreamBuilder>>( - stream: _userDocStream(), + stream: _userDocStream, builder: (context, snap) { final data = snap.data?.data(); final lifetimePoints =