diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 379a189..c650e3a 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -73,7 +73,7 @@ CREATE TABLE public.tricks ( ## `user_tricks` -Tracks each user's progress on a trick. One row per (user, trick) pair. `consistency` mirrors the `Consistency` enum (0 = Attempting … 5 = Always). `leash_position` mirrors `LeashPosition` (0 = Frontside, 1 = Backside, 2 = Center). +Tracks each user's progress on a trick. One row per (user, trick) pair. `consistency` mirrors the `Consistency` enum (0 = Never tried, 1 = Attempting … 6 = Always; values were shifted +1 by `migrate_consistency_never_tried.sql`). `leash_position` mirrors `LeashPosition` (0 = Frontside, 1 = Backside, 2 = Center). ```sql CREATE TABLE public.user_tricks ( @@ -88,7 +88,7 @@ CREATE TABLE public.user_tricks ( video_end SMALLINT, CONSTRAINT user_tricks_user_id_trick_id_key UNIQUE (user_id, trick_id), - CONSTRAINT user_tricks_consistency_check CHECK (consistency >= 0 AND consistency <= 5), + CONSTRAINT user_tricks_consistency_check CHECK (consistency >= 0 AND consistency <= 6), CONSTRAINT user_tricks_difficulty_vote_check CHECK (difficulty_vote >= 1 AND difficulty_vote <= 30), CONSTRAINT user_tricks_leash_position_check CHECK (leash_position >= 0 AND leash_position <= 2) ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ca7fb7c..6de9bbb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -203,6 +203,7 @@ "statusAttempting": "Attempting", "statusLandedAtLeastOnce": "Landed at least once", + "consistencyNeverTried": "Never tried", "consistencyOnce": "Once", "consistencySometimes": "Sometimes", "consistencyOften": "Often", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2abb937..c6904da 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -168,6 +168,7 @@ "statusAttempting": "En aprendizaje", "statusLandedAtLeastOnce": "Aterrizado al menos una vez", + "consistencyNeverTried": "Nunca intentado", "consistencyOnce": "Una vez", "consistencySometimes": "A veces", "consistencyOften": "Con frecuencia", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 397191f..4617ca1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -168,6 +168,7 @@ "statusAttempting": "En apprentissage", "statusLandedAtLeastOnce": "Réussi au moins une fois", + "consistencyNeverTried": "Jamais essayé", "consistencyOnce": "Une fois", "consistencySometimes": "Parfois", "consistencyOften": "Souvent", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b881bbb..7cbf080 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1006,6 +1006,12 @@ abstract class AppLocalizations { /// **'Landed at least once'** String get statusLandedAtLeastOnce; + /// No description provided for @consistencyNeverTried. + /// + /// In en, this message translates to: + /// **'Never tried'** + String get consistencyNeverTried; + /// No description provided for @consistencyOnce. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f3a9ebb..b32ade3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -480,6 +480,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get statusLandedAtLeastOnce => 'Landed at least once'; + @override + String get consistencyNeverTried => 'Never tried'; + @override String get consistencyOnce => 'Once'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 51eadea..3b2e7f9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -482,6 +482,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get statusLandedAtLeastOnce => 'Aterrizado al menos una vez'; + @override + String get consistencyNeverTried => 'Nunca intentado'; + @override String get consistencyOnce => 'Una vez'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5c89cda..a9647b0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -485,6 +485,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get statusLandedAtLeastOnce => 'Réussi au moins une fois'; + @override + String get consistencyNeverTried => 'Jamais essayé'; + @override String get consistencyOnce => 'Une fois'; diff --git a/lib/l10n/enum_localizations.dart b/lib/l10n/enum_localizations.dart index 4c7b1e9..045cdf5 100644 --- a/lib/l10n/enum_localizations.dart +++ b/lib/l10n/enum_localizations.dart @@ -33,7 +33,8 @@ extension TrickStatusL10n on TrickStatus { extension ConsistencyL10n on Consistency { String localizedLabel(AppLocalizations l10n) => switch (this) { - Consistency.never => l10n.statusAttempting, + Consistency.neverTried => l10n.consistencyNeverTried, + Consistency.attempting => l10n.statusAttempting, Consistency.once => l10n.consistencyOnce, Consistency.sometimes => l10n.consistencySometimes, Consistency.often => l10n.consistencyOften, diff --git a/lib/models/trick_filter.dart b/lib/models/trick_filter.dart index 9dd7882..e64580a 100644 --- a/lib/models/trick_filter.dart +++ b/lib/models/trick_filter.dart @@ -107,9 +107,9 @@ class TrickFilter { } TrickStatus _statusFor(int trickId, Map consistencyMap) { - final c = consistencyMap[trickId]; - if (c == null) return TrickStatus.neverAttempted; - if (c == Consistency.never) return TrickStatus.attempting; + final c = consistencyMap.forTrick(trickId); + if (c == Consistency.neverTried) return TrickStatus.neverAttempted; + if (c == Consistency.attempting) return TrickStatus.attempting; return TrickStatus.landed; } } diff --git a/lib/models/trick_sort.dart b/lib/models/trick_sort.dart index e83b9b6..bea822d 100644 --- a/lib/models/trick_sort.dart +++ b/lib/models/trick_sort.dart @@ -79,10 +79,10 @@ class TrickSorter { if (year == null) return ('Unknown', _kUnknownLast); return (year.toString(), year); case PrimarySort.consistency: - final c = consistencyMap[t.id]; + final c = consistencyMap.forTrick(t.id); // Sort order: Landed(0) first, Attempting(1), Never Attempted(2) last when ascending. - if (c == null) return ('Never Attempted', 2); - if (c == Consistency.never) return ('Attempting', 1); + if (c == Consistency.neverTried) return ('Never Attempted', 2); + if (c == Consistency.attempting) return ('Attempting', 1); return ('Landed', 0); } } @@ -116,9 +116,9 @@ class TrickSorter { } int _consistencyRank(int trickId, Map consistencyMap) { - final c = consistencyMap[trickId]; - if (c == null) return 0; - if (c == Consistency.never) return 1; + final c = consistencyMap.forTrick(trickId); + if (c == Consistency.neverTried) return 0; + if (c == Consistency.attempting) return 1; return 2; } } diff --git a/lib/models/user_trick.dart b/lib/models/user_trick.dart index 94ea514..feadb61 100644 --- a/lib/models/user_trick.dart +++ b/lib/models/user_trick.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; enum Consistency { - never('Attempting'), + // Stored in the DB as the enum index (0..6). Changing the order or adding + // values anywhere but the end requires a server + local-cache migration — + // see supabase/migrate_consistency_never_tried.sql. + neverTried('Never tried'), + attempting('Attempting'), once('Once'), sometimes('Sometimes'), often('Often'), @@ -11,10 +15,11 @@ enum Consistency { const Consistency(this.label); final String label; - bool get isLanded => this != Consistency.never; + bool get isLanded => index >= Consistency.once.index; double get borderWidth => switch (this) { - Consistency.never => 1.5, + Consistency.neverTried => 1.5, + Consistency.attempting => 1.5, Consistency.once => 1.5, Consistency.sometimes => 2.0, Consistency.often => 2.0, @@ -27,7 +32,8 @@ enum Consistency { Color borderColor(Brightness brightness) { if (brightness == Brightness.dark) { return switch (this) { - Consistency.never => const Color(0xFF9E9E9E), // gray + Consistency.neverTried => const Color(0xFF757575), // grey-600 + Consistency.attempting => const Color(0xFF9E9E9E), // gray Consistency.once => const Color(0xFFFF7043), // deep-orange-400 Consistency.sometimes => const Color(0xFFFFD54F), // amber-300 Consistency.often => const Color(0xFF8BC34A), // light-green-500 @@ -36,7 +42,8 @@ enum Consistency { }; } return switch (this) { - Consistency.never => const Color(0xFF9E9E9E), // gray + Consistency.neverTried => const Color(0xFFBDBDBD), // grey-400 + Consistency.attempting => const Color(0xFF9E9E9E), // gray Consistency.once => const Color(0xFFE65100), // orange Consistency.sometimes => const Color(0xFFF9A825), // yellow/amber Consistency.often => const Color(0xFF558B2F), // green @@ -45,10 +52,12 @@ enum Consistency { }; } - Color cardColor(Brightness brightness) { + // Null means no override: the card keeps the theme's default surface color. + Color? cardColor(Brightness brightness) { if (brightness == Brightness.dark) { return switch (this) { - Consistency.never => const Color(0xFF1C1C1C), // dark gray + Consistency.neverTried => null, + Consistency.attempting => const Color(0xFF1C1C1C), // dark gray Consistency.once => const Color(0xFF1E1A16), // subtle orange tint Consistency.sometimes => const Color(0xFF1E1D17), // subtle yellow tint Consistency.often => const Color(0xFF191D16), // subtle green tint @@ -57,7 +66,8 @@ enum Consistency { }; } return switch (this) { - Consistency.never => const Color(0xFFEEEEEE), // light gray + Consistency.neverTried => null, + Consistency.attempting => const Color(0xFFEEEEEE), // light gray Consistency.once => const Color(0xFFFFF3E0), // light orange Consistency.sometimes => const Color(0xFFFFFDE7), // light yellow Consistency.often => const Color(0xFFF1F8E9), // light green @@ -70,7 +80,8 @@ enum Consistency { Color? textColor(Brightness brightness) { if (brightness == Brightness.light) return null; return switch (this) { - Consistency.never => null, + Consistency.neverTried => null, + Consistency.attempting => null, Consistency.once => const Color(0xFF94A3B8), // slate-400 Consistency.sometimes => const Color(0xFF94A3B8), // slate-400 Consistency.often => const Color(0xFFE2E8F0), // slate-200 @@ -78,11 +89,17 @@ enum Consistency { Consistency.always => const Color(0xFFFFFFFF), }; } +} - static Consistency fromString(String value) => Consistency.values.firstWhere( - (e) => e.name == value, - orElse: () => Consistency.never, - ); +// Absence of a user_tricks row means the trick was never tried; these helpers +// make that rule total so callers never handle a nullable Consistency. +extension ConsistencyMapLookup on Map { + Consistency forTrick(int trickId) => this[trickId] ?? Consistency.neverTried; +} + +extension UserTrickConsistency on UserTrick? { + Consistency get effectiveConsistency => + this?.consistency ?? Consistency.neverTried; } enum LeashPosition { @@ -119,13 +136,26 @@ class UserTrick { required this.updatedAt, }); + UserTrick withConsistency(Consistency c) => UserTrick( + id: id, + userId: userId, + trickId: trickId, + consistency: c, + difficultyVote: difficultyVote, + leashPosition: leashPosition, + videoLink: videoLink, + videoStart: videoStart, + videoEnd: videoEnd, + updatedAt: updatedAt, + ); + factory UserTrick.fromJson(Map json) => UserTrick( id: json['id'] as int, userId: json['user_id'] as int, trickId: json['trick_id'] as int, - consistency: Consistency.values.elementAtOrNull( - json['consistency'] as int) ?? - Consistency.never, + consistency: Consistency.values + .elementAtOrNull(json['consistency'] as int) ?? + Consistency.neverTried, difficultyVote: json['difficulty_vote'] as int?, leashPosition: json['leash_position'] != null ? LeashPosition.values.elementAtOrNull( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a9bb197..12a9a41 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -54,6 +54,8 @@ class _HomeScreenState extends State { _nameSearchController = TextEditingController(); _savedTrickIds = OfflineVideoService.savedTrickIds.value; OfflineVideoService.savedTrickIds.addListener(_onSavedIdsChanged); + UserTricksService.consistencyOverrides + .addListener(_onConsistencyOverridesChanged); _load(initial: true); _authSub = Supabase.instance.client.auth.onAuthStateChange.listen((_) { _load(); // guard inside _load() prevents concurrent runs @@ -73,6 +75,8 @@ class _HomeScreenState extends State { void dispose() { _searchDebounce?.cancel(); OfflineVideoService.savedTrickIds.removeListener(_onSavedIdsChanged); + UserTricksService.consistencyOverrides + .removeListener(_onConsistencyOverridesChanged); _authSub.cancel(); Supabase.instance.client.removeChannel(_tricksChannel); _nameSearchController.dispose(); @@ -82,6 +86,17 @@ class _HomeScreenState extends State { void _onSavedIdsChanged() => setState(() => _savedTrickIds = OfflineVideoService.savedTrickIds.value); + // Applies optimistic consistency writes immediately so returning from a + // trick detail never shows the old color while the refetch is in flight. + void _onConsistencyOverridesChanged() { + final overrides = UserTricksService.consistencyOverrides.value; + if (overrides.isEmpty || !mounted) return; + setState(() { + _consistencyMap = {..._consistencyMap, ...overrides}; + _recompute(); + }); + } + Future _load({bool initial = false}) async { if (_loadInProgress) return; _loadInProgress = true; @@ -148,7 +163,7 @@ class _HomeScreenState extends State { _filter.statuses.any((s) => s != TrickStatus.landed))); _groups = rawGroups.map((g) { final landedCount = showLanded - ? g.$2.where((t) => _consistencyMap[t.id]?.isLanded == true).length + ? g.$2.where((t) => _consistencyMap.forTrick(t.id).isLanded).length : null; return (g.$1, g.$2, landedCount); }).toList(); @@ -161,7 +176,7 @@ class _HomeScreenState extends State { key: ValueKey(trick.id), child: TrickCard( trick: trick, - consistency: _consistencyMap[trick.id], + consistency: _consistencyMap.forTrick(trick.id), onReturn: _refresh, listMode: listMode, showDifficulty: true, diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 428abc7..25cea21 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -49,8 +49,14 @@ class _ProfileScreenState extends State with SafeStateMixin { void _refresh() => setState(() => _future = _load()); Future _updateConsistency(int trickId, Consistency consistency) async { - await UserTricksService.setConsistency(trickId, consistency); + final write = UserTricksService.setConsistency(trickId, consistency); + // The optimistic override makes this reload show the new value already. _refresh(); + try { + await write; + } finally { + _refresh(); + } } Future _signOut() async { diff --git a/lib/screens/trick_detail_screen.dart b/lib/screens/trick_detail_screen.dart index d166a44..71d1e7c 100644 --- a/lib/screens/trick_detail_screen.dart +++ b/lib/screens/trick_detail_screen.dart @@ -43,10 +43,20 @@ class _TrickDetailScreenState extends State TrickDetailData? _data; StreamSubscription>? _connectivitySub; + // Only the still-current load may assign _data, so stale results can never + // clobber a newer optimistic update. + void _startLoad() { + final future = _load(); + _future = future; + future.then((d) { + if (mounted && identical(_future, future)) setState(() => _data = d); + }, onError: (Object _) {}); // FutureBuilder surfaces the error + } + @override void initState() { super.initState(); - _future = _load(); + _startLoad(); if (!kIsWeb) { _connectivitySub = Connectivity().onConnectivityChanged.listen((results) { setDeviceConnectivity(results); @@ -121,9 +131,7 @@ class _TrickDetailScreenState extends State context, MaterialPageRoute(builder: (_) => SubmitTrickScreen(existingTrick: trick)), ); - setState(() { - _future = _load(); - }); + setState(_startLoad); } Future _openSuggestEdit(Trick trick) async { @@ -137,31 +145,22 @@ class _TrickDetailScreenState extends State Future _setConsistency(Consistency c) async { if (_data != null) { final existing = _data!.userTrick; - final optimistic = existing != null - ? UserTrick( - id: existing.id, - userId: existing.userId, - trickId: existing.trickId, - consistency: c, - difficultyVote: existing.difficultyVote, - leashPosition: existing.leashPosition, - videoLink: existing.videoLink, - videoStart: existing.videoStart, - videoEnd: existing.videoEnd, - updatedAt: existing.updatedAt, - ) - : UserTrick( - id: -1, - userId: -1, - trickId: widget.trickId, - consistency: c, - updatedAt: DateTime.now(), - ); + final optimistic = existing?.withConsistency(c) ?? + UserTrick( + id: -1, + userId: -1, + trickId: widget.trickId, + consistency: c, + updatedAt: DateTime.now(), + ); setState(() { _data = TrickDetailData( trick: _data!.trick, prerequisites: _data!.prerequisites, prerequisiteUserTricks: _data!.prerequisiteUserTricks, + baseTricks: _data!.baseTricks, + variations: _data!.variations, + variationUserTricks: _data!.variationUserTricks, userTrick: optimistic, canEditTricks: _data!.canEditTricks, voteStats: _data!.voteStats, @@ -169,9 +168,7 @@ class _TrickDetailScreenState extends State }); } await UserTricksService.setConsistency(widget.trickId, c); - setState(() { - _future = _load(); - }); + setState(_startLoad); } Future _copyLink() async { @@ -278,8 +275,6 @@ class _TrickDetailScreenState extends State } Widget _buildBody(AsyncSnapshot snap) { - if (snap.hasData) _data = snap.data; - if (_data == null) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -415,9 +410,9 @@ class _TrickDetailScreenState extends State spacing: 8, runSpacing: 4, children: variations.map((t) { - final consistency = variationUserTricks[t.id]?.consistency; - final bg = consistency?.cardColor(theme.brightness); - final border = consistency != null + final consistency = variationUserTricks[t.id].effectiveConsistency; + final bg = consistency.cardColor(theme.brightness); + final border = consistency != Consistency.neverTried ? BorderSide( color: consistency.borderColor(theme.brightness), width: consistency.borderWidth, @@ -427,9 +422,9 @@ class _TrickDetailScreenState extends State label: Text(t.givenName), backgroundColor: bg, side: border, - elevation: consistency?.hasGlow == true ? 6 : null, - shadowColor: consistency?.hasGlow == true - ? consistency! + elevation: consistency.hasGlow ? 6 : null, + shadowColor: consistency.hasGlow + ? consistency .borderColor(theme.brightness) .withValues(alpha: 0.7) : null, @@ -449,9 +444,9 @@ class _TrickDetailScreenState extends State spacing: 8, runSpacing: 4, children: prereqs.map((p) { - final consistency = prereqUserTricks[p.id]?.consistency; - final bg = consistency?.cardColor(theme.brightness); - final border = consistency != null + final consistency = prereqUserTricks[p.id].effectiveConsistency; + final bg = consistency.cardColor(theme.brightness); + final border = consistency != Consistency.neverTried ? BorderSide( color: consistency.borderColor(theme.brightness), width: consistency.borderWidth, @@ -461,9 +456,9 @@ class _TrickDetailScreenState extends State label: Text(p.givenName), backgroundColor: bg, side: border, - elevation: consistency?.hasGlow == true ? 6 : null, - shadowColor: consistency?.hasGlow == true - ? consistency! + elevation: consistency.hasGlow ? 6 : null, + shadowColor: consistency.hasGlow + ? consistency .borderColor(theme.brightness) .withValues(alpha: 0.7) : null, @@ -570,7 +565,7 @@ class _TrickDetailScreenState extends State ?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 8), ConsistencySelector( - selected: userTrick?.consistency, + selected: userTrick.effectiveConsistency, onChanged: _setConsistency, ), if (userTrick != null && userTrick.consistency.isLanded) ...[ @@ -588,9 +583,7 @@ class _TrickDetailScreenState extends State '${userTrick.difficultyVote}-${userTrick.leashPosition?.index}-${userTrick.videoLink}'), trickId: widget.trickId, userTrick: userTrick, - onSaved: () => setState(() { - _future = _load(); - }), + onSaved: () => setState(_startLoad), ), ], ], diff --git a/lib/screens/trick_progression_screen.dart b/lib/screens/trick_progression_screen.dart index 702d2d6..0d13a0f 100644 --- a/lib/screens/trick_progression_screen.dart +++ b/lib/screens/trick_progression_screen.dart @@ -376,7 +376,7 @@ class _TrickCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = DifficultyTier.badgeColors(trick.difficultyTier); - final isLanded = userTrick?.consistency.isLanded ?? false; + final isLanded = userTrick.effectiveConsistency.isLanded; final isViaVariation = !isLanded && landedViaVariation; final Color bgColor; diff --git a/lib/services/local_database_stub.dart b/lib/services/local_database_stub.dart index 45abfef..ba6b880 100644 --- a/lib/services/local_database_stub.dart +++ b/lib/services/local_database_stub.dart @@ -13,7 +13,9 @@ import '../models/user_trick.dart'; class LocalDatabase { LocalDatabase._(); - static const int _kVersion = 3; + // v4: Consistency gained neverTried at index 0, shifting all stored values + // +1 (matching supabase/migrate_consistency_never_tried.sql). + static const int _kVersion = 4; static Database? _db; static Database get _instance { @@ -60,11 +62,26 @@ class LocalDatabase { for (final row in savedWrites) { try { // Drop id so AUTOINCREMENT assigns a fresh one. - await db.insert('pending_writes', Map.from(row)..remove('id')); + final write = Map.from(row)..remove('id'); + if (oldV < 4) _shiftConsistencyPayload(write); + await db.insert('pending_writes', write); } catch (_) {} } } + // Pre-v4 payloads carry consistency ints in the old 0..5 scheme; they must + // be shifted +1 before they flush to the migrated server, or every queued + // offline write would land one level too low. + static void _shiftConsistencyPayload(Map write) { + if (write['table_name'] != 'user_tricks') return; + final payload = jsonDecode(write['payload'] as String); + if (payload is! Map) return; + final consistency = payload['consistency']; + if (consistency is! int) return; + payload['consistency'] = consistency + 1; + write['payload'] = jsonEncode(payload); + } + static Future _createSchema(Database db) async { await db.execute(''' CREATE TABLE tricks ( diff --git a/lib/services/user_tricks_service.dart b/lib/services/user_tricks_service.dart index cbc648d..cd19156 100644 --- a/lib/services/user_tricks_service.dart +++ b/lib/services/user_tricks_service.dart @@ -20,6 +20,35 @@ class UserTricksService { // server row wins on flush — intentional "last writer wins" behaviour. static const _noSnapshotAt = '1970-01-01T00:00:00.000Z'; + // Optimistic consistency values, keyed by trick ID. Set synchronously when a + // write starts so every screen can reflect it immediately; removed once a + // read confirms the stored value matches, or when the write fails hard. + static final ValueNotifier> consistencyOverrides = + ValueNotifier(const {}); + + static void _addConsistencyOverride(int trickId, Consistency c) { + consistencyOverrides.value = {...consistencyOverrides.value, trickId: c}; + } + + static void _removeConsistencyOverride(int trickId, Consistency c) { + if (consistencyOverrides.value[trickId] != c) return; + consistencyOverrides.value = {...consistencyOverrides.value} + ..remove(trickId); + } + + static UserTrick _withConsistencyOverride(UserTrick ut) { + final override = consistencyOverrides.value[ut.trickId]; + if (override == null) return ut; + if (ut.consistency == override) { + _removeConsistencyOverride(ut.trickId, override); + return ut; + } + return ut.withConsistency(override); + } + + static List _withConsistencyOverrides(List list) => + [for (final ut in list) _withConsistencyOverride(ut)]; + // Resolves the user's integer profile ID, with an offline fallback stored in // the meta table so cold launches without connectivity still work. static Future _getUserIntId() async { @@ -54,7 +83,9 @@ class UserTricksService { } return []; } - if (isDeviceOffline) return LocalDatabase.getUserTricks(intId); + if (isDeviceOffline) { + return _withConsistencyOverrides(await LocalDatabase.getUserTricks(intId)); + } try { final data = await _client.from('user_tricks').select().eq('user_id', intId); @@ -62,13 +93,13 @@ class UserTricksService { await LocalDatabase.cacheUserTricks(list); await LocalDatabase.setMeta( 'user_tricks_last_synced', DateTime.now().toUtc().toIso8601String()); - return list; + return _withConsistencyOverrides(list); } catch (e, st) { if (kIsWeb || !isNetworkError(e)) { debugPrint('UserTricksService.getUserTricks: $e\n$st'); rethrow; } - return LocalDatabase.getUserTricks(intId); + return _withConsistencyOverrides(await LocalDatabase.getUserTricks(intId)); } } @@ -77,7 +108,10 @@ class UserTricksService { if (trickIds.isEmpty) return {}; final intId = await _getUserIntId(); if (intId == null) return {}; - if (isDeviceOffline) return LocalDatabase.getUserTricksForTrickIds(intId, trickIds); + if (isDeviceOffline) { + final map = await LocalDatabase.getUserTricksForTrickIds(intId, trickIds); + return map.map((k, v) => MapEntry(k, _withConsistencyOverride(v))); + } try { final data = await _client .from('user_tricks') @@ -86,20 +120,26 @@ class UserTricksService { .inFilter('trick_id', trickIds); final list = (data as List).map((e) => UserTrick.fromJson(e)).toList(); await LocalDatabase.cacheUserTricks(list); - return {for (final t in list) t.trickId: t}; + return { + for (final t in _withConsistencyOverrides(list)) t.trickId: t + }; } catch (e, st) { if (kIsWeb || !isNetworkError(e)) { debugPrint('UserTricksService.getUserTricksForTrickIds: $e\n$st'); rethrow; } - return LocalDatabase.getUserTricksForTrickIds(intId, trickIds); + final map = await LocalDatabase.getUserTricksForTrickIds(intId, trickIds); + return map.map((k, v) => MapEntry(k, _withConsistencyOverride(v))); } } static Future getUserTrickForTrick(int trickId) async { final intId = await _getUserIntId(); if (intId == null) return null; - if (isDeviceOffline) return LocalDatabase.getUserTrickForTrick(intId, trickId); + if (isDeviceOffline) { + final ut = await LocalDatabase.getUserTrickForTrick(intId, trickId); + return ut != null ? _withConsistencyOverride(ut) : null; + } try { final data = await _client .from('user_tricks') @@ -110,7 +150,7 @@ class UserTricksService { if (data != null) { final ut = UserTrick.fromJson(data); await LocalDatabase.cacheUserTricks([ut]); - return ut; + return _withConsistencyOverride(ut); } return null; } catch (e, st) { @@ -118,7 +158,8 @@ class UserTricksService { debugPrint('UserTricksService.getUserTrickForTrick($trickId): $e\n$st'); rethrow; } - return LocalDatabase.getUserTrickForTrick(intId, trickId); + final ut = await LocalDatabase.getUserTrickForTrick(intId, trickId); + return ut != null ? _withConsistencyOverride(ut) : null; } } @@ -126,8 +167,22 @@ class UserTricksService { static Future setConsistency( int trickId, Consistency consistency) async { + _addConsistencyOverride(trickId, consistency); + try { + await _writeConsistency(trickId, consistency); + } catch (e) { + _removeConsistencyOverride(trickId, consistency); + rethrow; + } + } + + static Future _writeConsistency( + int trickId, Consistency consistency) async { final intId = await _getUserIntId(); - if (intId == null) return; + if (intId == null) { + _removeConsistencyOverride(trickId, consistency); + return; + } if (!isDeviceOffline) { try { @@ -211,10 +266,11 @@ class UserTricksService { // Use upsert so the write succeeds even when no user_tricks row exists yet, // matching the offline path. Read consistency from the local cache (native) // or from the server (web) to avoid overwriting an existing value on conflict. - Consistency? existingConsistency; + Consistency existingConsistency; if (!kIsWeb) { existingConsistency = - (await LocalDatabase.getUserTrickForTrick(intId, trickId))?.consistency; + (await LocalDatabase.getUserTrickForTrick(intId, trickId)) + .effectiveConsistency; } else { final row = await _client .from('user_tricks') @@ -222,15 +278,17 @@ class UserTricksService { .eq('user_id', intId) .eq('trick_id', trickId) .maybeSingle(); - if (row != null) { - existingConsistency = Consistency.values[row['consistency'] as int]; - } + existingConsistency = row != null + ? Consistency.values + .elementAtOrNull(row['consistency'] as int) ?? + Consistency.neverTried + : Consistency.neverTried; } await _client.from('user_tricks').upsert( { 'user_id': intId, 'trick_id': trickId, - 'consistency': (existingConsistency ?? Consistency.never).index, + 'consistency': existingConsistency.index, ...landedFields, }, onConflict: 'user_id,trick_id', @@ -248,10 +306,9 @@ class UserTricksService { // Offline path final existing = await LocalDatabase.getUserTrickForTrick(intId, trickId); final now = DateTime.now().toUtc().toIso8601String(); - // Use the existing consistency if available; fall back to never so the - // write is not silently dropped when the user sets landed details on a - // trick they rated offline moments earlier and the UI has not yet reloaded. - final consistency = existing?.consistency ?? Consistency.never; + // Keep the existing consistency; a missing row defaults to neverTried so + // the landed details are still written rather than silently dropped. + final consistency = existing.effectiveConsistency; final snapshotAt = existing?.updatedAt.toUtc().toIso8601String() ?? _noSnapshotAt; @@ -409,6 +466,11 @@ class UserTricksService { await LocalDatabase.cacheUserTricks([UserTrick.fromJson(fresh)]); } await LocalDatabase.deletePendingWrite(pendingId); + // Our write lost — drop its override so the UI shows the server row. + final lost = payload['consistency'] is int + ? Consistency.values.elementAtOrNull(payload['consistency'] as int) + : null; + if (lost != null) _removeConsistencyOverride(trickId, lost); return null; } } diff --git a/lib/utils/trick_progression_graph.dart b/lib/utils/trick_progression_graph.dart index ebf4dd6..ce77489 100644 --- a/lib/utils/trick_progression_graph.dart +++ b/lib/utils/trick_progression_graph.dart @@ -133,7 +133,7 @@ Future loadTrickProgressionGraph(int trickId) async { ); for (final variation in variations) { final ut = variationProgress[variation.id]; - if (ut == null || !ut.consistency.isLanded) continue; + if (!ut.effectiveConsistency.isLanded) continue; if (variation.difficultyTier <= 0) continue; for (final baseId in variation.baseTrickIds) { final base = tricks[baseId]; diff --git a/lib/widgets/consistency_selector.dart b/lib/widgets/consistency_selector.dart index e4e489d..4af11b6 100644 --- a/lib/widgets/consistency_selector.dart +++ b/lib/widgets/consistency_selector.dart @@ -3,8 +3,8 @@ import '../l10n/app_localizations_extension.dart'; import '../l10n/enum_localizations.dart'; import '../models/user_trick.dart'; -class ConsistencySelector extends StatelessWidget { - final Consistency? selected; +class ConsistencySelector extends StatefulWidget { + final Consistency selected; final ValueChanged onChanged; const ConsistencySelector({ @@ -13,28 +13,211 @@ class ConsistencySelector extends StatelessWidget { required this.onChanged, }); + @override + State createState() => _ConsistencySelectorState(); +} + +class _ConsistencySelectorState extends State { + static const _thumbRadius = 10.0; + static const _overlayRadius = 20.0; + + double? _dragValue; + + int get _maxLevel => Consistency.values.length - 1; + + Consistency get _displayed => _dragValue != null + ? Consistency.values[_dragValue!.round()] + : widget.selected; + @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Wrap( - spacing: 8, - runSpacing: 8, - children: Consistency.values.map((c) { - final isSelected = c == selected; - return ChoiceChip( - label: Text(c.localizedLabel(context.l10n)), - selected: isSelected, - onSelected: (_) => onChanged(c), - selectedColor: theme.colorScheme.primaryContainer, - labelStyle: TextStyle( - color: isSelected - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface, - fontWeight: - isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ); - }).toList(), + final brightness = theme.brightness; + final displayed = _displayed; + final activeColor = displayed.borderColor(brightness); + final trackColor = brightness == Brightness.dark + ? const Color(0xFF3A3A3A) + : Colors.black; + final sliderValue = _dragValue ?? widget.selected.index.toDouble(); + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: LayoutBuilder(builder: (context, constraints) { + final trackInset = _overlayRadius; + final trackWidth = constraints.maxWidth - 2 * trackInset; + // Same-row neighbours are two ticks apart; sizing labels to that + // distance makes them as wide as possible without overlapping. + final labelWidth = 2 * trackWidth / _maxLevel; + double tickCenter(int level) => + trackInset + trackWidth * level / _maxLevel; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _labelRow( + theme, + brightness, + displayed, + tickCenter, + labelWidth, + levels: [ + for (final c in Consistency.values) + if (c.index.isOdd) c + ], + dotBelowLabel: true, + ), + SliderTheme( + data: SliderThemeData( + trackHeight: 8, + trackShape: const RoundedRectSliderTrackShape(), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: _thumbRadius), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: _overlayRadius), + activeTrackColor: activeColor, + inactiveTrackColor: trackColor, + thumbColor: activeColor, + overlayColor: activeColor.withValues(alpha: 0.2), + tickMarkShape: SliderTickMarkShape.noTickMark, + ), + child: Slider( + value: sliderValue, + min: 0, + max: _maxLevel.toDouble(), + divisions: _maxLevel, + onChanged: (v) => setState(() => _dragValue = v), + onChangeEnd: (v) { + setState(() => _dragValue = null); + widget.onChanged(Consistency.values[v.round()]); + }, + ), + ), + _labelRow( + theme, + brightness, + displayed, + tickCenter, + labelWidth, + levels: [ + for (final c in Consistency.values) + if (c.index.isEven) c + ], + dotBelowLabel: false, + ), + ], + ); + }), + ), + ); + } + + static const _edgePadding = 4.0; + + Widget _labelRow( + ThemeData theme, + Brightness brightness, + Consistency displayed, + double Function(int level) tickCenter, + double labelWidth, { + required List levels, + required bool dotBelowLabel, + }) { + return SizedBox( + height: 30, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final level in levels) + Positioned( + left: tickCenter(level.index) - labelWidth / 2, + top: 0, + bottom: 0, + child: SizedBox( + width: labelWidth, + child: Column( + mainAxisAlignment: dotBelowLabel + ? MainAxisAlignment.end + : MainAxisAlignment.start, + // The first/last labels sit right at the track's edge + // ticks, so centering them here would push the pill past + // the screen edge; they're re-anchored to the container + // edge below instead, and only the dot stays on the tick. + children: level.index == 0 || level.index == _maxLevel + ? [_dot(theme, level, displayed)] + : dotBelowLabel + ? [ + _label(theme, brightness, level, displayed), + const SizedBox(height: 4), + _dot(theme, level, displayed), + ] + : [ + _dot(theme, level, displayed), + const SizedBox(height: 4), + _label(theme, brightness, level, displayed), + ], + ), + ), + ), + if (levels.isNotEmpty && levels.first.index == 0) + Positioned( + left: _edgePadding, + top: dotBelowLabel ? 0 : null, + bottom: dotBelowLabel ? null : 0, + child: _label(theme, brightness, levels.first, displayed), + ), + if (levels.isNotEmpty && levels.last.index == _maxLevel) + Positioned( + right: _edgePadding, + top: dotBelowLabel ? 0 : null, + bottom: dotBelowLabel ? null : 0, + child: _label(theme, brightness, levels.last, displayed), + ), + ], + ), + ); + } + + Widget _label(ThemeData theme, Brightness brightness, Consistency level, + Consistency displayed) { + final isSelected = level == displayed; + final levelColor = level.borderColor(brightness); + final pillTextColor = + ThemeData.estimateBrightnessForColor(levelColor) == Brightness.dark + ? Colors.white + : Colors.black87; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: isSelected + ? BoxDecoration( + color: levelColor, + borderRadius: BorderRadius.circular(4), + ) + : null, + child: Text( + level.localizedLabel(context.l10n), + textAlign: TextAlign.center, + softWrap: false, + overflow: TextOverflow.visible, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? pillTextColor : null, + ), + ), + ); + } + + Widget _dot(ThemeData theme, Consistency level, Consistency displayed) { + final isSelected = level == displayed; + return Container( + width: 4, + height: 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? theme.colorScheme.onSurface + : theme.colorScheme.outlineVariant, + ), ); } } diff --git a/lib/widgets/trick_card.dart b/lib/widgets/trick_card.dart index 9342e4f..b24bbd7 100644 --- a/lib/widgets/trick_card.dart +++ b/lib/widgets/trick_card.dart @@ -7,7 +7,7 @@ import '../utils/difficulty_tier.dart'; class TrickCard extends StatelessWidget { final Trick trick; - final Consistency? consistency; + final Consistency consistency; final VoidCallback? onReturn; final bool listMode; final bool showDifficulty; @@ -20,7 +20,7 @@ class TrickCard extends StatelessWidget { const TrickCard({ super.key, required this.trick, - this.consistency, + this.consistency = Consistency.neverTried, this.onReturn, this.listMode = false, this.showDifficulty = false, @@ -42,11 +42,11 @@ class TrickCard extends StatelessWidget { final card = Card( clipBehavior: Clip.antiAlias, - margin: consistency == Consistency.never ? EdgeInsets.zero : null, - color: consistency?.cardColor(theme.brightness), - elevation: consistency?.hasGlow == true ? 8 : null, - shadowColor: consistency?.hasGlow == true - ? consistency!.borderColor(theme.brightness).withValues(alpha: 0.7) + margin: consistency == Consistency.attempting ? EdgeInsets.zero : null, + color: consistency.cardColor(theme.brightness), + elevation: consistency.hasGlow ? 8 : null, + shadowColor: consistency.hasGlow + ? consistency.borderColor(theme.brightness).withValues(alpha: 0.7) : null, shape: _cardShape(theme), child: InkWell( @@ -131,7 +131,7 @@ class TrickCard extends StatelessWidget { ), ]; - if (consistency == Consistency.never) { + if (consistency == Consistency.attempting) { return Padding( padding: const EdgeInsets.all(4), child: Stack( @@ -141,7 +141,7 @@ class TrickCard extends StatelessWidget { child: IgnorePointer( child: CustomPaint( painter: _DashedBorderPainter( - color: consistency!.borderColor(theme.brightness), + color: consistency.borderColor(theme.brightness), ), ), ), @@ -168,10 +168,10 @@ class TrickCard extends StatelessWidget { final card = Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, - color: consistency?.cardColor(theme.brightness), - elevation: consistency?.hasGlow == true ? 8 : null, - shadowColor: consistency?.hasGlow == true - ? consistency!.borderColor(theme.brightness).withValues(alpha: 0.7) + color: consistency.cardColor(theme.brightness), + elevation: consistency.hasGlow ? 8 : null, + shadowColor: consistency.hasGlow + ? consistency.borderColor(theme.brightness).withValues(alpha: 0.7) : null, shape: _cardShape(theme), child: ListTile( @@ -197,7 +197,7 @@ class TrickCard extends StatelessWidget { ); Widget result = card; - if (consistency == Consistency.never) { + if (consistency == Consistency.attempting) { result = Stack( children: [ card, @@ -205,7 +205,7 @@ class TrickCard extends StatelessWidget { child: IgnorePointer( child: CustomPaint( painter: _DashedBorderPainter( - color: consistency!.borderColor(theme.brightness), + color: consistency.borderColor(theme.brightness), ), ), ), @@ -221,12 +221,15 @@ class TrickCard extends StatelessWidget { } ShapeBorder? _cardShape(ThemeData theme) { - if (consistency == null || consistency == Consistency.never) return null; + if (consistency == Consistency.neverTried || + consistency == Consistency.attempting) { + return null; + } return RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( - color: consistency!.borderColor(theme.brightness), - width: consistency!.borderWidth, + color: consistency.borderColor(theme.brightness), + width: consistency.borderWidth, ), ); } diff --git a/supabase/migrate_consistency_never_tried.sql b/supabase/migrate_consistency_never_tried.sql new file mode 100644 index 0000000..8a18710 --- /dev/null +++ b/supabase/migrate_consistency_never_tried.sql @@ -0,0 +1,40 @@ +-- Adds "never tried" as consistency 0 by shifting all existing values +1: +-- old: 0=Attempting .. 5=Always → new: 0=Never tried, 1=Attempting .. 6=Always +-- +-- MUST be deployed together with the app release that reads the new scheme +-- (Consistency enum with neverTried at index 0, local DB version 4). Older +-- clients read/write the old scheme and will be off by one until they update. +-- +-- Idempotent: the DO block only runs while the old 0..5 check constraint is +-- still in place, so re-running it can never double-shift the data. + +do $$ +declare + old_def text; +begin + select pg_get_constraintdef(oid) into old_def + from pg_constraint + where conrelid = 'public.user_tricks'::regclass + and conname = 'user_tricks_consistency_check'; + + if old_def is null or old_def not like '%<= 5%' then + raise notice 'consistency migration already applied — nothing to do'; + return; + end if; + + -- Block concurrent writes so no row is inserted with old semantics + -- between the shift and the new constraint. + lock table public.user_tricks in exclusive mode; + + alter table public.user_tricks + drop constraint user_tricks_consistency_check; + + update public.user_tricks + set consistency = consistency + 1; + + -- default 0 now means "never tried" instead of "attempting" — the app + -- always writes consistency explicitly, so the default is only a safety net. + alter table public.user_tricks + add constraint user_tricks_consistency_check + check (consistency between 0 and 6); +end $$; diff --git a/supabase/schema.sql b/supabase/schema.sql index a8a0a76..01b9554 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -52,7 +52,7 @@ create table user_tricks ( id integer generated always as identity primary key, user_id integer not null references profiles(int_id) on delete cascade, trick_id integer not null references tricks(id) on delete cascade, - consistency smallint not null default 0 check (consistency between 0 and 5), + consistency smallint not null default 0 check (consistency between 0 and 6), difficulty_vote smallint check (difficulty_vote between 1 and 30), leash_position smallint check (leash_position between 0 and 2), video_link text, diff --git a/test/models/user_trick_test.dart b/test/models/user_trick_test.dart index ce471b3..e311cb7 100644 --- a/test/models/user_trick_test.dart +++ b/test/models/user_trick_test.dart @@ -31,5 +31,35 @@ void main() { expect(ut.leashPosition, isNull); expect(ut.videoLink, isNull); }); + + test('maps the stored int directly to the enum index', () { + expect(UserTrick.fromJson(base()).consistency, Consistency.once); + expect(UserTrick.fromJson({...base(), 'consistency': 0}).consistency, + Consistency.neverTried); + expect(UserTrick.fromJson({...base(), 'consistency': 1}).consistency, + Consistency.attempting); + expect(UserTrick.fromJson({...base(), 'consistency': 6}).consistency, + Consistency.always); + }); + + test('falls back to neverTried for out-of-range values', () { + expect(UserTrick.fromJson({...base(), 'consistency': 99}).consistency, + Consistency.neverTried); + }); + }); + + group('Consistency', () { + test('index range matches the db check constraint 0..6', () { + expect(Consistency.values.length, 7); + expect(Consistency.neverTried.index, 0); + expect(Consistency.always.index, 6); + }); + + test('neverTried and attempting are not landed, once and above are', () { + expect(Consistency.neverTried.isLanded, isFalse); + expect(Consistency.attempting.isLanded, isFalse); + expect(Consistency.once.isLanded, isTrue); + expect(Consistency.always.isLanded, isTrue); + }); }); } diff --git a/test/services/local_database_test.dart b/test/services/local_database_test.dart index ecce91d..a5446fe 100644 --- a/test/services/local_database_test.dart +++ b/test/services/local_database_test.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' show join; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:freestyle_highline/models/approval_status.dart'; @@ -65,6 +69,7 @@ void main() { difficultyTier: 2, dateSubmitted: DateTime(2024, 1, 1), prerequisiteTrickIds: prereqs, + baseTrickIds: const [], status: ApprovalStatus.approved, flags: 0, ); @@ -115,6 +120,7 @@ void main() { difficultyTier: 3, dateSubmitted: DateTime(2024, 1, 1), prerequisiteTrickIds: const [], + baseTrickIds: const [], status: ApprovalStatus.approved, flags: 0, ); @@ -359,4 +365,87 @@ void main() { expect(await LocalDatabase.getMeta('no_such_key'), isNull); }); }); + + // ─── v3 → v4 upgrade (consistency shift) ────────────────────────────────── + + group('Upgrade to v4', () { + Future createV3DbWithPendingWrites( + List> writes) async { + final dir = await Directory.systemTemp.createTemp('freestyle_db_test'); + final path = join(dir.path, 'upgrade.db'); + final db = await databaseFactoryFfi.openDatabase( + path, + options: OpenDatabaseOptions( + version: 3, + onCreate: (db, _) => db.execute(''' + CREATE TABLE pending_writes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + operation TEXT NOT NULL, + payload TEXT NOT NULL, + local_snapshot_at TEXT NOT NULL, + created_at TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0 + ) + '''), + ), + ); + for (final w in writes) { + await db.insert('pending_writes', { + 'table_name': 'user_tricks', + 'operation': 'upsert', + 'local_snapshot_at': '2024-01-01T00:00:00.000Z', + 'created_at': '2024-01-01T00:00:00.000Z', + 'retry_count': 0, + ...w, + }); + } + await db.close(); + return path; + } + + test('shifts consistency in preserved pending-write payloads by +1', + () async { + final path = await createV3DbWithPendingWrites([ + { + 'payload': + jsonEncode({'user_id': 1, 'trick_id': 2, 'consistency': 3}) + }, + ]); + await LocalDatabase.resetForTest(); + await LocalDatabase.init(factory: databaseFactoryFfi, path: path); + + final writes = await LocalDatabase.getPendingWrites(); + expect(writes.length, 1); + final payload = + jsonDecode(writes.first['payload'] as String) as Map; + expect(payload['consistency'], 4); + }); + + test('leaves payloads without a consistency field untouched', () async { + final path = await createV3DbWithPendingWrites([ + { + 'payload': jsonEncode({'user_id': 1, 'trick_id': 2}) + }, + { + 'table_name': 'other_table', + 'payload': + jsonEncode({'user_id': 1, 'trick_id': 2, 'consistency': 3}) + }, + ]); + await LocalDatabase.resetForTest(); + await LocalDatabase.init(factory: databaseFactoryFfi, path: path); + + final writes = await LocalDatabase.getPendingWrites(); + expect(writes.length, 2); + // Identical created_at makes the order nondeterministic — key by table. + final byTable = { + for (final w in writes) + w['table_name']: + jsonDecode(w['payload'] as String) as Map + }; + expect(byTable['user_tricks']!['consistency'], isNull); + expect(byTable['other_table']!['consistency'], 3); + }); + }); } diff --git a/test/services/progression_service_test.dart b/test/services/progression_service_test.dart index bd4ece5..f5d8f3c 100644 --- a/test/services/progression_service_test.dart +++ b/test/services/progression_service_test.dart @@ -105,15 +105,15 @@ void main() { }); test('unlanded variation has no effect', () { - final never = UserTrick( + final attempting = UserTrick( id: aPrime.id, userId: 1, trickId: aPrime.id, - consistency: Consistency.never, + consistency: Consistency.attempting, updatedAt: _epoch, ); final result = ProgressionService.computeWhatsNext( - [never], + [attempting], [a, aPrime, b], ); expect(result.unlocked.map((t) => t.id), isNot(contains(b.id)));