Skip to content
Open
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
10 changes: 10 additions & 0 deletions app/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

import '../core/identity/identity_provider.dart';
import '../features/deck/deck_page.dart';
import '../features/home/home_page.dart';
import '../features/invite/invite_page.dart';
import '../features/onboarding/onboarding_intro_page.dart';
Expand Down Expand Up @@ -107,6 +108,15 @@ final routerProvider = Provider<GoRouter>((ref) {
return SessionPage(sessionId: sessionId);
},
),

GoRoute(
path: '/sessions/:id/deck',
name: 'session-deck',
builder: (context, state) {
final sessionId = state.pathParameters['id'] ?? '';
return DeckPage(sessionId: sessionId);
},
),
GoRoute(
path: '/sessions/:id/settings',
name: 'session-settings',
Expand Down
29 changes: 29 additions & 0 deletions app/lib/core/format/formatters.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
String formatDistance(double? meters) {
if (meters == null) {
return 'Unknown distance';
}

if (meters < 1000) {
return '${meters.round()} m';
}

final km = meters / 1000;
return '${km.toStringAsFixed(km >= 10 ? 0 : 1)} km';
}

String formatPriceLevel(int? level) {
if (level == null || level <= 0) {
return 'Any';
}

return r'$' * level;
}

String formatRating(double? rating, int? reviewCount) {
if (rating == null) {
return 'No rating';
}

final reviews = reviewCount ?? 0;
return '${rating.toStringAsFixed(1)} ($reviews)';
}
271 changes: 271 additions & 0 deletions app/lib/features/deck/deck_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../core/location/location_controller.dart';
import '../../models/plan.dart';
import '../../models/telemetry.dart';
import '../../repositories/deck_repository.dart';
import '../../repositories/sessions_repository.dart';
import '../../repositories/telemetry_repository.dart';
import 'deck_state.dart';

class DeckController extends StateNotifier<DeckState> {
DeckController({
required String sessionId,
required SessionsRepository sessionsRepository,
required Future<DeckRepository> Function() deckRepository,
required Future<TelemetryRepository> Function() telemetryRepository,
required LocationControllerState Function() getLocationState,
required Future<void> Function() requestPermissionAndLoadLocation,
}) : _sessionsRepository = sessionsRepository,
_deckRepository = deckRepository,
_telemetryRepository = telemetryRepository,
_getLocationState = getLocationState,
_requestPermissionAndLoadLocation = requestPermissionAndLoadLocation,
super(DeckState.initial(sessionId));

final SessionsRepository _sessionsRepository;
final Future<DeckRepository> Function() _deckRepository;
final Future<TelemetryRepository> Function() _telemetryRepository;
final LocationControllerState Function() _getLocationState;
final Future<void> Function() _requestPermissionAndLoadLocation;

DateTime? _viewStartedAt;

Future<void> initialize() async {
await _ensureLocation();
if (!_hasEffectiveLocation) {
state = state.copyWith(
isLoadingInitial: false,
errorMessage: 'Location required',
);
return;
}
await refresh();
}

Future<void> requestLocation() async {
await _requestPermissionAndLoadLocation();
await initialize();
}

Future<void> refresh() async {
state = state.copyWith(
isLoadingInitial: true,
plans: const [],
nextCursor: null,
errorMessage: null,
);
Comment on lines +53 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear undo state when refreshing deck data

Refreshing only resets loading flags and plans, but leaves undoStack (and shown-card history) intact. After a retry/refresh, the Undo button can still restore cards from the previous batch into the newly fetched deck, mixing stale suggestions with current results and skewing subsequent swipe flow/telemetry.

Useful? React with 👍 / 👎.

await _fetchBatch(forceRefresh: true);
}

Future<void> loadNextBatch() async {
if (state.isLoadingMore || !state.hasMore) {
return;
}

state = state.copyWith(isLoadingMore: true, errorMessage: null);
await _fetchBatch(cursor: state.nextCursor);
}

void onSwipeNo() => _swipe('no');
void onSwipeYes() => _swipe('yes');
void onSwipeMaybe() => _swipe('maybe');

void undo() {
if (state.undoStack.isEmpty) {
return;
}

final restore = state.undoStack.last;
state = state.copyWith(
plans: [restore.plan, ...state.plans],
undoStack: [...state.undoStack]..removeLast(),
);
_viewStartedAt = DateTime.now();
}

void onCardOpened(Plan plan) {
_emitCardViewed(plan);
_enqueueTelemetry(
TelemetryEventInput.cardOpened(
planId: plan.id,
source: plan.source,
),
);
_viewStartedAt = DateTime.now();
}

void onOutboundLinkClicked(Plan plan, String linkType) {
_enqueueTelemetry(
TelemetryEventInput.outboundLinkClicked(
planId: plan.id,
linkType: linkType,
source: plan.source,
),
);
}

void registerTopCardViewed() {
_viewStartedAt ??= DateTime.now();
}

Future<void> _ensureLocation() async {
if (_getLocationState().effectiveLocation != null) {
return;
}

await _requestPermissionAndLoadLocation();
}

bool get _hasEffectiveLocation => _getLocationState().effectiveLocation != null;

Future<void> _fetchBatch({String? cursor, bool forceRefresh = false}) async {
final location = _getLocationState().effectiveLocation;
if (location == null) {
state = state.copyWith(
isLoadingInitial: false,
isLoadingMore: false,
errorMessage: 'Location required',
);
return;
}

try {
final session = await _sessionsRepository.getById(state.sessionId);
if (session == null) {
state = state.copyWith(
isLoadingInitial: false,
isLoadingMore: false,
errorMessage: 'Session not found',
);
return;
}

final deckRepository = await _deckRepository();
final response = await deckRepository.fetchDeckBatch(
state.sessionId,
DeckQueryParams(
cursor: cursor,
lat: location.lat,
lng: location.lng,
radiusMeters: session.filters.radiusMeters,
categories: session.filters.categories.map((e) => e.name).toList(),
openNow: session.filters.openNow,
priceLevelMax: session.filters.priceLevelMax,
timeStart: session.filters.timeWindow?.startISO,
timeEnd: session.filters.timeWindow?.endISO,
),
forceRefresh: forceRefresh,
);

final mergedPlans = _mergeUnique(state.plans, response.plans);
final fallbackCount = response.mix.planSourceCounts.entries
.where((entry) => entry.key == 'curated' || entry.key == 'byo')
.fold<int>(0, (sum, entry) => sum + entry.value);

state = state.copyWith(
isLoadingInitial: false,
isLoadingMore: false,
errorMessage: null,
plans: mergedPlans,
nextCursor: response.nextCursor,
hasMore: response.nextCursor != null,
lastBatchMix: response.mix,
usedFallback: response.plans.isEmpty || fallbackCount >= (response.plans.length ~/ 2),
);

_enqueueTelemetry(
TelemetryEventInput.deckLoaded(
batchSize: response.plans.length,
returned: response.plans.length,
nextCursorPresent: response.nextCursor != null,
cursor: cursor,
planSourceCounts: response.mix.planSourceCounts,
),
);

if (state.plans.isNotEmpty) {
_viewStartedAt = DateTime.now();
}
} catch (_) {
state = state.copyWith(
isLoadingInitial: false,
isLoadingMore: false,
errorMessage: 'Failed to load deck. Pull to retry.',
);
}
}

List<Plan> _mergeUnique(List<Plan> current, List<Plan> incoming) {
final seenIds = current.map((plan) => plan.id).toSet();
final merged = [...current];

for (final plan in incoming) {
if (!seenIds.contains(plan.id)) {
seenIds.add(plan.id);
merged.add(plan);
}
}

return merged;
}

void _enqueueTelemetry(TelemetryEventInput event) {
_telemetryRepository().then((repo) async {
repo.enqueue(state.sessionId, event);
await repo.flush(state.sessionId);
Comment on lines +214 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize telemetry flushes per session

Each telemetry event starts a fire-and-forget repo.flush(...) via .then, so rapid interactions (multiple swipes/taps in quick succession) can run TelemetryRepository.flush concurrently on the same in-memory queue. Because flush removes ranges after awaiting network I/O, overlapping flushes can remove stale indices, causing dropped/duplicated events or RangeError at runtime; this corrupts deck telemetry under normal user behavior.

Useful? React with 👍 / 👎.

});
}

void _swipe(String action) {
if (state.plans.isEmpty) {
return;
}

final topPlan = state.plans.first;
_emitCardViewed(topPlan);

_enqueueTelemetry(
TelemetryEventInput.swipe(
planId: topPlan.id,
action: action,
source: topPlan.source,
position: state.shownPlanIds.length,
),
);

final shownPlanIds = [...state.shownPlanIds];
if (!shownPlanIds.contains(topPlan.id)) {
shownPlanIds.add(topPlan.id);
}

state = state.copyWith(
plans: [...state.plans]..removeAt(0),
shownPlanIds: shownPlanIds,
undoStack: [
...state.undoStack,
SwipeRecord(plan: topPlan, action: action),
],
);

_viewStartedAt = DateTime.now();
if (state.plans.length <= 5 && state.hasMore) {
loadNextBatch();
}
}

void _emitCardViewed(Plan topPlan) {
final startedAt = _viewStartedAt;
if (startedAt == null) {
return;
}

_enqueueTelemetry(
TelemetryEventInput.cardViewed(
planId: topPlan.id,
viewMs: DateTime.now().difference(startedAt).inMilliseconds,
source: topPlan.source,
),
);
}
}
Loading