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 +52 to +56

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 Reset swipe history when starting a deck refresh

refresh() clears the visible plans but keeps prior undoStack/shownPlanIds, so after a user has swiped cards and taps retry/refresh, undo can reinsert stale cards from the previous deck into the newly fetched results and telemetry positions continue from old history. Clearing deck content should also clear swipe history/state tied to the previous batch.

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),

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 Use a true 50% threshold for fallback activation

The fallback flag uses fallbackCount >= (response.plans.length ~/ 2), which floors odd lengths, so a 1-card batch marks fallback active even when fallbackCount is 0, and 3-card batches mark fallback active at 1 fallback card (33%). This over-reports fallback mode and shows misleading UI state for normal batches.

Useful? React with 👍 / 👎.

);

_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

_enqueueTelemetry starts a new async flush for every event without waiting for prior flushes, so fast interactions (e.g., opening a card then swiping repeatedly) can run multiple TelemetryRepository.flush calls against the same queue concurrently. flush mutates a shared list after an await, so overlapping calls can remove overlapping ranges and either throw (from removeRange) or drop/duplicate telemetry batches; this needs per-session serialization or a single background drain loop.

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