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

_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.

P2 Badge Serialize telemetry flushes per session

Each call to _enqueueTelemetry starts repo.flush(...) in a detached then block, so rapid interactions can run multiple flushes concurrently for the same session. TelemetryRepository.flush mutates a shared queue (removeRange in a loop), so overlapping flushes can send duplicate batches and then fail mid-drain when one flush removes items the other still expects, which makes analytics inaccurate under normal swipe/open bursts.

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