diff --git a/lib/claim.dart b/lib/claim.dart index d242761..0ac44f3 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:geolocator/geolocator.dart'; import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/streak_service.dart'; import 'package:postbox_game/james_controller.dart'; import 'package:postbox_game/theme.dart'; @@ -77,6 +78,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { FirebaseFunctions.instance.httpsCallable('nearbyPostboxes'); final HttpsCallable _claimCallable = FirebaseFunctions.instance.httpsCallable('startScoring'); + final StreakService _streakService = StreakService(); Future _getPosition() async { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); @@ -147,6 +149,11 @@ class ClaimState extends State with SingleTickerProviderStateMixin { currentStage = ClaimStage.claimed; }); _successController.forward(from: 0); + try { + await _streakService.updateStreakAfterClaim(); + } catch (e) { + debugPrint('Streak update failed (non-fatal): $e'); + } if (mounted) { final msg = _pointsEarned >= 50 ? "Oh ho — a rare one! That's a find. Well done." @@ -291,6 +298,39 @@ class ClaimState extends State with SingleTickerProviderStateMixin { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + StreamBuilder( + stream: _streakService.streakStream(), + builder: (context, snapshot) { + final streak = snapshot.data; + if (streak == null || streak == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: postalRed.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: postalRed.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('🔥', style: TextStyle(fontSize: 18)), + const SizedBox(width: AppSpacing.xs), + Text( + '$streak-day streak', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: postalRed, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + }, + ), SvgPicture.asset( 'assets/postbox.svg', height: 100, @@ -646,6 +686,21 @@ class ClaimState extends State with SingleTickerProviderStateMixin { ), ), const SizedBox(height: AppSpacing.sm), + StreamBuilder( + stream: _streakService.streakStream(), + builder: (context, snap) { + final streak = snap.data ?? 0; + if (streak < 2) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '🔥 $streak-day streak!', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ); + }, + ), if (_pointsEarned > 0) Container( padding: const EdgeInsets.symmetric( diff --git a/lib/streak_service.dart b/lib/streak_service.dart new file mode 100644 index 0000000..4b1cee5 --- /dev/null +++ b/lib/streak_service.dart @@ -0,0 +1,73 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// Updates and reads daily claim streak for the current user. +class StreakService { + StreakService({FirebaseFirestore? firestore, FirebaseAuth? auth}) + : _firestore = firestore ?? FirebaseFirestore.instance, + _auth = auth ?? FirebaseAuth.instance; + + final FirebaseFirestore _firestore; + final FirebaseAuth _auth; + + static String get _today { + final now = DateTime.now().toUtc(); + // Approximate BST: last Sunday March → last Sunday October + // BST = UTC+1, GMT = UTC+0 + final month = now.month; + final isDstCandidate = month > 3 && month < 10 || + (month == 3 && now.day > 24) || // last week of March (rough) + (month == 10 && now.day <= 24); // first 3 weeks of October (rough) + final london = isDstCandidate + ? now.add(const Duration(hours: 1)) + : now; + return london.toIso8601String().split('T').first; + } + + /// Call after user successfully claims (e.g. startScoring returned found with claims). + Future updateStreakAfterClaim() async { + final user = _auth.currentUser; + if (user == null) return; + + final today = _today; + final todayDate = DateTime.parse(today); + final yesterday = todayDate + .subtract(const Duration(days: 1)) + .toIso8601String() + .split('T') + .first; + + final ref = _firestore.collection('users').doc(user.uid); + await _firestore.runTransaction((tx) async { + final doc = await tx.get(ref); + final data = doc.data() ?? {}; + final lastClaimDate = data['lastClaimDate'] as String?; + final currentStreak = (data['streak'] as int?) ?? 0; + + int newStreak; + if (lastClaimDate == today) { + return; // Already claimed today + } else if (lastClaimDate == yesterday) { + newStreak = currentStreak + 1; + } else { + newStreak = 1; + } + + tx.set(ref, { + 'lastClaimDate': today, + 'streak': newStreak, + }, SetOptions(merge: true)); + }); + } + + /// Stream of the current user's streak (for UI). + Stream streakStream() { + final uid = _auth.currentUser?.uid; + if (uid == null) return Stream.value(null); + return _firestore.collection('users').doc(uid).snapshots().map((s) { + final d = s.data(); + if (d == null || d['streak'] is! int) return null; + return d['streak'] as int; + }); + } +}