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 Clear swipe history when starting a fresh deck load

refresh() resets the card list but leaves undoStack and shownPlanIds untouched, so after a retry/pull-to-refresh users can still undo into cards from the previous deck and telemetry positions continue from the old run; this mixes stale state into what should be a fresh batch load.

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 +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 to avoid queue races

Each telemetry event triggers an unawaited _enqueueTelemetry call that immediately flushes, so rapid interactions (for example card_viewed + swipe emitted back-to-back) can run overlapping flushes on the same session queue; TelemetryRepository.flush mutates a shared list, so concurrent flushes can send duplicate batches or leave events unsafely handled after removeRange races.

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