335940cf23
User intent (likes, hides, playlist adds, Lidarr requests, cancels) now persists across network loss. Controllers write their optimistic local state to drift first, then try the REST call; on failure the call is enqueued in cached_mutations rather than rolled back. MutationReplayer drains the queue on connectivity transitions and a 1-minute periodic tick. **Infrastructure (schema 8):** * CachedMutations table — id / kind / payload (JSON) / createdAt / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a permanently-failing mutation eventually drops, and next sync reconciles drift to the server's authoritative state. * MutationQueue.enqueue / pendingCount * MutationReplayer.start + .drain — start fires from app.dart's postFrameCallback alongside SyncController / Prefetcher / etc. * Kind registry: like.add / like.remove / quarantine.flag / quarantine.unflag / playlist.append / request.create / request.cancel — each with a Ref+payload handler that re-fires the corresponding REST call. **Wired surfaces:** * LikesController.toggle — optimistic drift like/unlike stays across REST failure; queues the call. Drops the old rollback. * MyQuarantineController.flag / .unflag — same pattern. Hide/unhide visibly persists offline; replays when back online. * addToPlaylistActionProvider — now does an optimistic cached_playlist_tracks write (position = max + 1) so the playlist detail screen shows the new track instantly. Queues appendTracks on REST failure. * DiscoverScreen._request — queues request.create on DioException. No drift state for the request itself (myRequestsProvider is still REST-only) so the row won't show on /requests until replay succeeds — acceptable for v1. * MyRequestsController.cancel — optimistic in-memory remove no longer restores on failure; queues request.cancel instead. **Test update:** quarantine_provider_test "flag rolls back on server failure" renamed and rewritten to assert the new offline behavior: optimistic drift row persists, mutation is enqueued for replay. **Out of scope (v2):** * Playlist create / rename / delete (no Flutter UI exposes these yet) * Lidarr request optimistic local row (would need a cached_requests drift table) * UI "syncing N pending changes" indicator (operator preference: silent unless we find a concrete need)
74 lines
2.9 KiB
Dart
74 lines
2.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import 'cache/cache_filler.dart';
|
|
import 'cache/metadata_prefetcher.dart';
|
|
import 'cache/mutation_queue.dart';
|
|
import 'cache/prefetcher.dart';
|
|
import 'cache/sync_controller.dart';
|
|
import 'shared/live_events_dispatcher.dart';
|
|
import 'shared/routing.dart';
|
|
import 'theme/theme_data.dart';
|
|
import 'theme/theme_mode_provider.dart';
|
|
|
|
class MinstrelApp extends ConsumerStatefulWidget {
|
|
const MinstrelApp({super.key});
|
|
|
|
@override
|
|
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
|
|
}
|
|
|
|
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Activate offline-mode infrastructure once the first frame ships.
|
|
// SyncController.sync() is connectivity-aware (no-ops on no auth /
|
|
// no server URL), so calling it unconditionally is safe.
|
|
// Reading prefetcherProvider runs its constructor, which wires the
|
|
// queue + settings listeners.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// ignore: unawaited_futures
|
|
ref.read(syncControllerProvider.notifier).sync();
|
|
ref.read(prefetcherProvider);
|
|
// Metadata prefetcher: when /api/home returns, fire background
|
|
// albumProvider/artistProvider reads for the top-N items in
|
|
// each section so subsequent taps are drift hits, not network
|
|
// round trips.
|
|
ref.read(metadataPrefetcherProvider);
|
|
// Live events (#392): subscribes to /api/events/stream and
|
|
// invalidates publicly-scoped providers when relevant events
|
|
// arrive. Also installs an AppLifecycleState observer for
|
|
// resume-time defensive invalidation.
|
|
ref.read(liveEventsDispatcherProvider);
|
|
// Cache filler: background sweeper that walks cached_artists
|
|
// / cached_albums for missing relations and fetches the
|
|
// per-entity detail so tapping an artist surfaces albums
|
|
// immediately (drift hit, no /api/artists/:id round-trip at
|
|
// tap time). First sweep 10s after launch; every 5 minutes
|
|
// thereafter. Throttled 200ms between requests so it never
|
|
// competes with user activity.
|
|
ref.read(cacheFillerProvider);
|
|
// Mutation replayer: drains the cached_mutations queue when
|
|
// connectivity comes back. Controllers (LikesController,
|
|
// MyQuarantineController, the add-to-playlist + request flows)
|
|
// enqueue on REST failure so user intent persists across
|
|
// network loss instead of getting rolled back.
|
|
ref.read(mutationReplayerProvider);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final router = ref.watch(routerProvider);
|
|
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
|
return MaterialApp.router(
|
|
title: 'Minstrel',
|
|
theme: buildLightTheme(),
|
|
darkTheme: buildDarkTheme(),
|
|
themeMode: mode.materialMode,
|
|
routerConfig: router,
|
|
);
|
|
}
|
|
}
|