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
53 changes: 53 additions & 0 deletions docs/plans/crashlytics-custom-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Plan — Crashlytics custom keys and non-fatal logging

- **Status:** Proposed
- **Effort:** Small
- **Firebase services:** Crashlytics

## Overview

Crashlytics is already configured. Add structured context so crashes are diagnosable without a repro, and promote handled exceptions in key flows to non-fatals so they're visible in the dashboard.

## Custom keys to set on the current session

- `auth_state` — `anon | email | google`.
- `active_tab` — `nearby | claim | leaderboard | friends`.
- `last_claim_id`.
- `last_monarch`.
- `remote_config_fetch_ts`.
- `has_location_permission` — bool.

## Non-fatals to record

- Catch blocks in `UserRepository.signIn*` → `recordError` with `fatal: false`.
- Callable failures in `nearbyPostboxes` and `startScoring`.
- `FlutterCompass` unexpected stream errors.
- Firestore snapshot errors on leaderboard.
- App Check token fetch failures.

## Implementation

- `lib/services/crashlytics_helper.dart` with:
- `setContext({ key, value })` — typed wrapper with length guards.
- `recordHandled(error, stack, { reason })` — always `fatal: false`.
- Centralise tab changes in `home.dart` to set `active_tab` once.
- Route all callable wrappers through a helper that logs to Crashlytics on failure and rethrows.

## Privacy

- Never log user email, display name, or exact coordinates as custom keys.
- Crashlytics opt-out is already honoured by Firebase; keep current user-consent path.

## Rollout

- No flag needed — purely additive.
- Ship; watch dashboard for new non-fatals; tune which ones matter.

## Risks

- Noisy non-fatals from expected network flakes — rate-limit or drop after first per session.

## Testing

- Unit test the helper's length-guard truncation.
- Force a non-fatal in debug builds to verify ingestion.
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