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
4 changes: 2 additions & 2 deletions docs/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)
);
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
"statusAttempting": "Attempting",
"statusLandedAtLeastOnce": "Landed at least once",

"consistencyNeverTried": "Never tried",
"consistencyOnce": "Once",
"consistencySometimes": "Sometimes",
"consistencyOften": "Often",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
"statusAttempting": "En apprentissage",
"statusLandedAtLeastOnce": "Réussi au moins une fois",

"consistencyNeverTried": "Jamais essayé",
"consistencyOnce": "Une fois",
"consistencySometimes": "Parfois",
"consistencyOften": "Souvent",
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_es.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_fr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion lib/l10n/enum_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions lib/models/trick_filter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ class TrickFilter {
}

TrickStatus _statusFor(int trickId, Map<int, Consistency> 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;
}
}
12 changes: 6 additions & 6 deletions lib/models/trick_sort.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -116,9 +116,9 @@ class TrickSorter {
}

int _consistencyRank(int trickId, Map<int, Consistency> 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;
}
}
62 changes: 46 additions & 16 deletions lib/models/user_trick.dart
Original file line number Diff line number Diff line change
@@ -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'),
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -70,19 +80,26 @@ 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
Consistency.generally => const Color(0xFFFFFFFF),
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<int, Consistency> {
Consistency forTrick(int trickId) => this[trickId] ?? Consistency.neverTried;
}

extension UserTrickConsistency on UserTrick? {
Consistency get effectiveConsistency =>
this?.consistency ?? Consistency.neverTried;
}

enum LeashPosition {
Expand Down Expand Up @@ -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<String, dynamic> 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(
Expand Down
19 changes: 17 additions & 2 deletions lib/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class _HomeScreenState extends State<HomeScreen> {
_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
Expand All @@ -73,6 +75,8 @@ class _HomeScreenState extends State<HomeScreen> {
void dispose() {
_searchDebounce?.cancel();
OfflineVideoService.savedTrickIds.removeListener(_onSavedIdsChanged);
UserTricksService.consistencyOverrides
.removeListener(_onConsistencyOverridesChanged);
_authSub.cancel();
Supabase.instance.client.removeChannel(_tricksChannel);
_nameSearchController.dispose();
Expand All @@ -82,6 +86,17 @@ class _HomeScreenState extends State<HomeScreen> {
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<void> _load({bool initial = false}) async {
if (_loadInProgress) return;
_loadInProgress = true;
Expand Down Expand Up @@ -148,7 +163,7 @@ class _HomeScreenState extends State<HomeScreen> {
_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();
Expand All @@ -161,7 +176,7 @@ class _HomeScreenState extends State<HomeScreen> {
key: ValueKey(trick.id),
child: TrickCard(
trick: trick,
consistency: _consistencyMap[trick.id],
consistency: _consistencyMap.forTrick(trick.id),
onReturn: _refresh,
listMode: listMode,
showDifficulty: true,
Expand Down
8 changes: 7 additions & 1 deletion lib/screens/profile_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ class _ProfileScreenState extends State<ProfileScreen> with SafeStateMixin {
void _refresh() => setState(() => _future = _load());

Future<void> _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<void> _signOut() async {
Expand Down
Loading