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,
);
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 Correct fallback ratio check for odd batch sizes

The fallback flag uses fallbackCount >= (response.plans.length ~/ 2), which floors odd lengths and makes the threshold too low (for example, 1-card batches always mark fallback active because the threshold becomes 0, and 3-card batches mark it active at only 33%). This causes false fallback labeling and misleading deck quality signals whenever batch size is odd.

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 +217

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

In DeckController, _swipe emits two telemetry events back-to-back (card_viewed then swipe), and _enqueueTelemetry starts repo.flush in a fire-and-forget callback each time. That allows concurrent TelemetryRepository.flush executions on the same queue; because each flush computes takeCount, awaits network I/O, then mutates with removeRange, overlapping completions can remove overlapping ranges and raise RangeError or lose/duplicate events during normal fast swiping.

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