Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions lib/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,6 +78,7 @@ class ClaimState extends State<Claim> with SingleTickerProviderStateMixin {
FirebaseFunctions.instance.httpsCallable('nearbyPostboxes');
final HttpsCallable _claimCallable =
FirebaseFunctions.instance.httpsCallable('startScoring');
final StreakService _streakService = StreakService();

Future<Position> _getPosition() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
Expand Down Expand Up @@ -147,6 +149,11 @@ class ClaimState extends State<Claim> 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."
Expand Down Expand Up @@ -291,6 +298,39 @@ class ClaimState extends State<Claim> with SingleTickerProviderStateMixin {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamBuilder<int?>(
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,
Expand Down Expand Up @@ -646,6 +686,21 @@ class ClaimState extends State<Claim> with SingleTickerProviderStateMixin {
),
),
const SizedBox(height: AppSpacing.sm),
StreamBuilder<int?>(
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(
Expand Down
73 changes: 73 additions & 0 deletions lib/streak_service.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<int?> streakStream() {
final uid = _auth.currentUser?.uid;
if (uid == null) return Stream<int?>.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;
});
}
}
Loading