Skip to content
Draft
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
63 changes: 63 additions & 0 deletions docs/plans/friend-challenges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Plan — Friend challenges (head-to-head)

- **Status:** Proposed
- **Effort:** Large
- **Firebase services:** Cloud Functions (callable + Firestore triggers), Firestore, FCM

## Overview

Let a user challenge a friend to a short-term contest ("First to 5 EVIIR boxes this week" / "Most claims in the next 48 h"). Winner earns a cosmetic badge. No real-world wagering.

## Data model

- `challenges/{id}` = `{ fromUid, toUid, type, target, goalValue, startAt, endAt, status, fromProgress, toProgress, winnerUid?, createdAt }`.
- `challenges/{id}/events/{eventId}` — append-only for auditability.
- `users/{uid}.badges[]` appended on win.

## Challenge types (initial)

- `first_to_n_rare` — first to claim N rare-tier boxes.
- `most_claims` — highest claim count in window.
- `longest_streak` — for 7-day challenges.

## Callables

- `createChallenge({ toUid, type, target, goalValue, durationHours })` — validates friendship, window <= 7 days, goal within allowed range.
- `acceptChallenge({ id })`, `declineChallenge({ id })`.
- `cancelChallenge({ id })` — before acceptance only.

## Progress tracking

- Firestore trigger `onClaimCreated` checks active challenges involving the claimant and updates `fromProgress` / `toProgress`.
- When progress hits goal or window expires, set `status=completed`, determine winner, write badge.

## Notifications

- FCM on invite, accept, mid-challenge overtake, and completion.
- Reuse `_notifications.ts` helpers.

## Client

- `lib/challenges_screen.dart` — list of active, invited, completed challenges.
- Compose flow from friend profile: "Challenge this friend" button.
- Badge display on profile page.

## Security

- Firestore rules: challenges are read-only to participants; writes via callable only.

## Rollout

- Flag `feature_challenges_enabled`.
- Internal testing first — small friend group.
- Cap concurrent active challenges per user at 3.

## Risks

- Abuse: spamming challenge invites. Rate-limit `createChallenge` to 5/day per user.
- Clock drift in claim timestamps near window boundaries — use server timestamps only.

## Testing

- Unit tests for progress calc across challenge types.
- Integration test: full lifecycle create → accept → progress → completion.
11 changes: 7 additions & 4 deletions lib/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ class _ClaimState extends State<Claim> with TickerProviderStateMixin {
postboxes[entry.key as String] =
Map<String, dynamic>.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;
});
Expand Down
19 changes: 11 additions & 8 deletions lib/nearby.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,26 @@ class _NearbyState extends State<Nearby> {
final claimedCompass = data['claimedCompass'] != null
? Map<String, dynamic>.from(data['claimedCompass'] as Map)
: <String, dynamic>{};
// 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();
});
Expand Down
6 changes: 4 additions & 2 deletions lib/wear/wear_claim_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ class _WearClaimPageState extends State<WearClaimPage> {
});
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<String, dynamic>.from(result.data['postboxes'] ?? {});
setState(() {
_count = total;
Expand Down
17 changes: 14 additions & 3 deletions lib/wear/wear_compass_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
final counts = result.data['counts'] ?? {};
final compassRaw = result.data['compass'] ?? {};
setState(() {
_totalCount = (counts['total'] as int?) ?? 0;
_compassCounts = Map<String, int>.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.
Expand Down Expand Up @@ -206,7 +211,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
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)
Expand Down
8 changes: 6 additions & 2 deletions lib/wear/wear_status_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget {
class _WearStatusPageState extends State<WearStatusPage> {
final StreakService _streakService = StreakService();
late final Stream<int?> _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<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream =
_buildUserDocStream();

String? get _displayName => FirebaseAuth.instance.currentUser?.displayName;

Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream() {
Stream<DocumentSnapshot<Map<String, dynamic>>>? _buildUserDocStream() {
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid == null) return null;
return FirebaseFirestore.instance.collection('users').doc(uid).snapshots();
Expand Down Expand Up @@ -71,7 +75,7 @@ class _WearStatusPageState extends State<WearStatusPage> {

// Lifetime points
StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
stream: _userDocStream(),
stream: _userDocStream,
builder: (context, snap) {
final data = snap.data?.data();
final lifetimePoints =
Expand Down
Loading