diff --git a/functions/src/_leaderboardUtils.ts b/functions/src/_leaderboardUtils.ts index b617fb5..9f354de 100644 --- a/functions/src/_leaderboardUtils.ts +++ b/functions/src/_leaderboardUtils.ts @@ -111,3 +111,42 @@ export async function updateUserLeaderboards( } } } + +interface LifetimeLeaderboardEntry { + uid: string; + displayName: string; + uniquePostboxesClaimed: number; + totalPoints: number; +} + +/** + * Upserts the user's lifetime entry in leaderboards/lifetime. + * Sorts by uniquePostboxesClaimed descending, keeps top 100. + * periodKey is always "lifetime" — no rollover. + */ +export async function updateLifetimeLeaderboard( + uid: string, + displayName: string, + uniquePostboxesClaimed: number, + totalPoints: number, + db: Firestore +): Promise { + const ref = db.collection("leaderboards").doc("lifetime"); + await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const existing: LifetimeLeaderboardEntry[] = + (snap.data()?.entries as LifetimeLeaderboardEntry[]) ?? []; + + const others = existing.filter((e) => e.uid !== uid); + const updated: LifetimeLeaderboardEntry[] = [ + ...others, + ...(uniquePostboxesClaimed > 0 || totalPoints > 0 + ? [{ uid, displayName, uniquePostboxesClaimed, totalPoints }] + : []), + ] + .sort((a, b) => b.uniquePostboxesClaimed - a.uniquePostboxesClaimed) + .slice(0, 100); + + tx.set(ref, { periodKey: "lifetime", entries: updated }, { merge: false }); + }); +} diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index 7b128f6..4cf8c88 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -3,7 +3,7 @@ import * as functions from "firebase-functions"; import { getPoints } from "./_getPoints"; import { getTodayLondon } from "./_dateUtils"; import { lookupPostboxes } from "./_lookupPostboxes"; -import { updateUserLeaderboards } from "./_leaderboardUtils"; +import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils"; import { computeNewStreak } from "./_streakUtils"; const database = admin.firestore(); @@ -100,7 +100,7 @@ export const startScoring = functions.https.onCall(async (request) => { // Keep dailyClaim on the postbox doc for display purposes (shows // "someone found this today" in future UI); does not gate claiming. tx.set(postboxRef, { dailyClaim: { date: todayLondon, by: userid } }, { merge: true }); - return pts; + return { key, pts }; }); }) ); @@ -112,10 +112,17 @@ export const startScoring = functions.https.onCall(async (request) => { } } - const earnedPoints = claimSettled - .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled" && typeof r.value === "number") + const successfulClaims = claimSettled + .filter((r): r is PromiseFulfilledResult<{ key: string; pts: number }> => + r.status === "fulfilled" && + r.value !== null && + typeof r.value === "object" && + "key" in r.value + ) .map((r) => r.value); + const earnedPoints = successfulClaims.map((c) => c.pts); + // If no points were earned but at least one transaction was rejected (as // opposed to being skipped because already claimed today), surface an error // so the client shows a retry prompt rather than "Already claimed today". @@ -153,6 +160,43 @@ export const startScoring = functions.https.onCall(async (request) => { // updateUserLeaderboards uses Promise.allSettled internally and never // throws; individual period failures are logged inside the function. await updateUserLeaderboards(userid, displayName, todayLondon, database); + + // ── Lifetime leaderboard update ───────────────────────────────────────── + try { + // For each postbox claimed in this session, check if the user has any + // prior claim on a different day. Empty result = first-ever claim for + // that postbox → increment unique counter by 1. + const uniqueChecks = await Promise.all( + successfulClaims.map(({ key }) => + database.collection("claims") + .where("userid", "==", userid) + .where("postboxes", "==", `/postbox/${key}`) + .where("dailyDate", "<", todayLondon) + .limit(1) + .get() + .then((snap) => snap.empty ? 1 : 0) + ) + ); + const uniqueIncrement = uniqueChecks.reduce((a, b) => a + b, 0); + const lifetimePointsIncrement = earnedPoints.reduce((s, p) => s + p, 0); + + await database.collection("users").doc(userid).set( + { + uniquePostboxesClaimed: admin.firestore.FieldValue.increment(uniqueIncrement), + lifetimePoints: admin.firestore.FieldValue.increment(lifetimePointsIncrement), + }, + { merge: true } + ); + + const updatedUser = await database.collection("users").doc(userid).get(); + const d = updatedUser.data() ?? {}; + const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0; + const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0; + + await updateLifetimeLeaderboard(userid, displayName, uniquePostboxesClaimed, lifetimePoints, database); + } catch (lifetimeErr) { + console.error("lifetime leaderboard update failed (non-fatal):", lifetimeErr); + } } return { diff --git a/functions/src/updateDisplayName.ts b/functions/src/updateDisplayName.ts index 28fd4a0..bd6212d 100644 --- a/functions/src/updateDisplayName.ts +++ b/functions/src/updateDisplayName.ts @@ -2,7 +2,7 @@ import "./adminInit"; import * as admin from "firebase-admin"; import * as functions from "firebase-functions"; import { getTodayLondon } from "./_dateUtils"; -import { updateUserLeaderboards } from "./_leaderboardUtils"; +import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils"; import { containsProfanity } from "./_profanityFilter"; /** @@ -75,5 +75,15 @@ export const updateDisplayName = functions.https.onCall(async (request) => { const today = getTodayLondon(); await updateUserLeaderboards(uid, name, today, admin.firestore()); + try { + const userDoc = await admin.firestore().collection("users").doc(uid).get(); + const d = userDoc.data() ?? {}; + const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0; + const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0; + await updateLifetimeLeaderboard(uid, name, uniquePostboxesClaimed, lifetimePoints, admin.firestore()); + } catch (lifetimeErr) { + console.error("lifetime leaderboard display name update failed (non-fatal):", lifetimeErr); + } + return { displayName: name }; }); diff --git a/lib/friends_screen.dart b/lib/friends_screen.dart index 125f5ee..391b1f7 100644 --- a/lib/friends_screen.dart +++ b/lib/friends_screen.dart @@ -260,32 +260,44 @@ class _FriendsScreenState extends State { future: _nameCache[friendUid] ??= _firestore.collection('users').doc(friendUid).get(), builder: (context, nameSnap) { + final isLoading = nameSnap.connectionState == ConnectionState.waiting; final displayName = nameSnap.data?.data()?['displayName'] as String?; - final label = displayName ?? friendUid; - final initials = label.length >= 2 - ? label.substring(0, 2).toUpperCase() - : label.toUpperCase(); + final initials = displayName != null && displayName.length >= 2 + ? displayName.substring(0, 2).toUpperCase() + : '?'; return Card( child: ListTile( leading: CircleAvatar( backgroundColor: postalRed, - child: Text( - initials, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), + child: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + initials, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), ), - title: Text(label, overflow: TextOverflow.ellipsis), - subtitle: Text( - displayName != null ? friendUid : 'UID', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + title: isLoading + ? Text( + 'Loading...', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + : Text( + displayName ?? 'Unknown player', + overflow: TextOverflow.ellipsis, ), - ), trailing: IconButton( icon: Icon(Icons.person_remove_outlined, color: Theme.of(context).colorScheme.onSurfaceVariant), diff --git a/lib/james_messages.dart b/lib/james_messages.dart index b198941..3561d5d 100644 --- a/lib/james_messages.dart +++ b/lib/james_messages.dart @@ -59,6 +59,14 @@ abstract final class JamesMessages { ["Add friends by UID to see them here. More the merrier."], ); + static const navLifetimeScores = JamesMessage( + 'jamesNavLifetimeScores', + [ + "This is the all-time tally — unique postboxes ever claimed. " + "Claiming the same box twice doesn't count, so get out and explore!", + ], + ); + /// Returns the nav hint for tab [index] (0–3), or null for unknown indices. static JamesMessage? forTabIndex(int index) => switch (index) { 0 => navNearby, diff --git a/lib/james_strip.dart b/lib/james_strip.dart index 905fcc6..452600b 100644 --- a/lib/james_strip.dart +++ b/lib/james_strip.dart @@ -90,6 +90,22 @@ class _JamesStripState extends State with SingleTickerProviderStateM }); } + void _dismiss() { + if (!_slideCtrl.isAnimating && !_slideCtrl.isCompleted) return; + _typeTimer?.cancel(); + _dismissTimer?.cancel(); + final messageToDismiss = _currentMessage; + _slideCtrl.reverse().then((_) { + if (mounted && _currentMessage == messageToDismiss) { + widget.controller.clear(); + setState(() { + _currentMessage = ''; + _charIndex = 0; + }); + } + }); + } + void _startDismissTimer() { final messageToDismiss = _currentMessage; // Give at least 3 s, plus ~40 ms per character so longer messages stay @@ -115,7 +131,9 @@ class _JamesStripState extends State with SingleTickerProviderStateM final colorScheme = Theme.of(context).colorScheme; return SlideTransition( position: _slideAnim, - child: Container( + child: GestureDetector( + onTap: _dismiss, + child: Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), @@ -151,6 +169,7 @@ class _JamesStripState extends State with SingleTickerProviderStateM ), ), ), + ), ); } } diff --git a/lib/leaderboard_screen.dart b/lib/leaderboard_screen.dart index eb5e8f9..b3f179e 100644 --- a/lib/leaderboard_screen.dart +++ b/lib/leaderboard_screen.dart @@ -1,38 +1,68 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; +import 'package:postbox_game/james_controller.dart'; +import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/theme.dart'; -/// Leaderboard with Daily, Weekly, Monthly tabs. -/// Reads from Firestore leaderboards/{period}; backend can aggregate via Cloud Function. -class LeaderboardScreen extends StatelessWidget { +/// Leaderboard with Daily, Weekly, Monthly, Lifetime tabs. +/// Reads from Firestore leaderboards/{period}; backend aggregates via Cloud Function. +class LeaderboardScreen extends StatefulWidget { const LeaderboardScreen({super.key}); - static const List _periods = ['daily', 'weekly', 'monthly']; + @override + State createState() => _LeaderboardScreenState(); +} + +class _LeaderboardScreenState extends State + with SingleTickerProviderStateMixin { + static const List _periods = ['daily', 'weekly', 'monthly', 'lifetime']; + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _periods.length, vsync: this); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (_tabController.indexIsChanging) return; + if (_tabController.index == _periods.indexOf('lifetime')) { + JamesController.of(context) + ?.show(JamesMessages.navLifetimeScores.resolve()); + } + } @override Widget build(BuildContext context) { - return DefaultTabController( - length: _periods.length, - child: Column( - children: [ - Container( - color: Theme.of(context).colorScheme.surface, - child: TabBar( - tabs: _periods - .map((p) => Tab(text: p[0].toUpperCase() + p.substring(1))) - .toList(), - ), + return Column( + children: [ + Container( + color: Theme.of(context).colorScheme.surface, + child: TabBar( + controller: _tabController, + tabs: _periods + .map((p) => Tab(text: p[0].toUpperCase() + p.substring(1))) + .toList(), ), - Expanded( - child: TabBarView( - children: _periods - .map((period) => _LeaderboardList(period: period)) - .toList(), - ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: _periods + .map((period) => _LeaderboardList(period: period)) + .toList(), ), - ], - ), + ), + ], ); } } @@ -50,6 +80,8 @@ class _LeaderboardListState extends State<_LeaderboardList> { late final Stream>> _stream; final String? _currentUid = FirebaseAuth.instance.currentUser?.uid; + bool get _isLifetime => widget.period == 'lifetime'; + @override void initState() { super.initState(); @@ -97,7 +129,9 @@ class _LeaderboardListState extends State<_LeaderboardList> { )), const SizedBox(height: AppSpacing.xs), Text( - 'Leaderboard is updated by the backend.', + _isLifetime + ? 'Start claiming postboxes to appear here.' + : 'Leaderboard is updated by the backend.', style: Theme.of(context) .textTheme .bodySmall @@ -109,7 +143,7 @@ class _LeaderboardListState extends State<_LeaderboardList> { } final currentUserInList = _currentUid != null && entries.any((e) => - (e as Map?)?['uid'] == _currentUid); + e is Map && e['uid'] == _currentUid); // Only show the "outside the top N" footer when authenticated but not // in the list; omit it for unauthenticated viewers. final showFooter = _currentUid != null && !currentUserInList; @@ -132,7 +166,9 @@ class _LeaderboardListState extends State<_LeaderboardList> { padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.sm, AppSpacing.md, AppSpacing.lg), child: Text( - 'You\'re outside the top ${entries.length} — keep claiming to climb!', + _isLifetime + ? 'You\'re outside the top ${entries.length} — keep exploring!' + : 'You\'re outside the top ${entries.length} — keep claiming to climb!', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -141,17 +177,38 @@ class _LeaderboardListState extends State<_LeaderboardList> { ); } - final e = entries[index] as Map? ?? {}; + final e = (entries[index] is Map + ? entries[index] as Map + : const {}); final rank = index + 1; final displayName = e['displayName'] as String? ?? 'Unknown'; final entryUid = e['uid'] as String?; - final points = - (e['points'] is num) ? (e['points'] as num).toInt() : 0; final isCurrentUser = entryUid != null && entryUid == _currentUid; + // Lifetime-specific fields + final uniqueBoxes = _isLifetime + ? ((e['uniquePostboxesClaimed'] is num) + ? (e['uniquePostboxesClaimed'] as num).toInt() + : 0) + : 0; + final totalPoints = _isLifetime + ? ((e['totalPoints'] is num) + ? (e['totalPoints'] as num).toInt() + : 0) + : 0; + + // Standard period fields + final points = !_isLifetime + ? ((e['points'] is num) ? (e['points'] as num).toInt() : 0) + : 0; + + final trailingText = _isLifetime + ? '$uniqueBoxes ${uniqueBoxes == 1 ? 'box' : 'boxes'} · $totalPoints pts' + : '$points pts'; + return Card( color: isCurrentUser - ? postalRed.withValues(alpha:0.08) + ? postalRed.withValues(alpha: 0.08) : null, child: ListTile( leading: _rankWidget(rank), @@ -162,7 +219,7 @@ class _LeaderboardListState extends State<_LeaderboardList> { : null, ), trailing: Text( - '$points pts', + trailingText, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: isCurrentUser ? postalRed @@ -192,7 +249,7 @@ class _LeaderboardListState extends State<_LeaderboardList> { default: return CircleAvatar( radius: 16, - backgroundColor: postalRed.withValues(alpha:0.1), + backgroundColor: postalRed.withValues(alpha: 0.1), child: Text( '$rank', style: const TextStyle(