polished the app
Browse files- .gitignore +3 -1
- app/lib/data/repositories/card_repository.dart +3 -0
- app/lib/data/services/api_client.dart +19 -1
- app/lib/data/services/local_store.dart +10 -0
- app/lib/ui/core/home_shell.dart +4 -42
- app/lib/ui/core/theme.dart +11 -1
- app/lib/ui/core/widgets/loading_tiles.dart +17 -11
- app/lib/ui/core/widgets/processing_glyph.dart +1 -1
- app/lib/ui/features/actions/view_models/actions_view_model.dart +2 -1
- app/lib/ui/features/catalog/view_models/catalog_view_model.dart +2 -1
- app/lib/ui/features/collections/view_models/collections_view_model.dart +2 -1
- app/lib/ui/features/collections/views/folder_picker_sheet.dart +166 -0
- app/lib/ui/features/concepts/view_models/concepts_view_model.dart +2 -1
- app/lib/ui/features/feed/views/knowledge_feed_screen.dart +4 -2
- app/lib/ui/features/library/view_models/library_view_model.dart +57 -6
- app/lib/ui/features/library/views/library_dialogs.dart +44 -0
- app/lib/ui/features/library/views/library_screen.dart +19 -50
- app/lib/ui/features/onboarding/views/name_screen.dart +3 -17
- app/lib/ui/features/onboarding/views/onboarding_screen.dart +10 -64
- app/lib/ui/features/onboarding/views/splash_screen.dart +1 -1
- app/lib/ui/features/profile/views/profile_screen.dart +10 -3
- app/lib/ui/features/reader/views/insight_section.dart +1 -1
- app/lib/ui/features/reader/views/reader_screen.dart +8 -4
- app/lib/ui/features/share/view_models/share_view_model.dart +3 -3
- app/lib/ui/features/share/views/share_screen.dart +3 -2
- app/test/api_exception_test.dart +25 -0
- app/test/bulk_move_test.dart +53 -0
- app/test/library_wakeup_test.dart +52 -0
- app/test/local_store_test.dart +20 -0
- app/test/reduced_motion_test.dart +35 -0
- app/web/index.html +1 -1
- docs/planning/plans/2026-07-10-backend-auth-quotas.md +790 -0
- docs/planning/plans/2026-07-10-flutter-auth.md +625 -0
- docs/planning/plans/2026-07-10-ui-trust-polish.md +435 -0
- docs/planning/plans/2026-07-10-v2-on-device-ai.md +76 -0
- docs/planning/specs/2026-07-10-public-distribution-auth-quotas-design.md +133 -0
- docs/planning/specs/2026-07-10-v2-on-device-ai-design.md +89 -0
.gitignore
CHANGED
|
@@ -47,4 +47,6 @@ i-want-you-to-spicy-dusk.md
|
|
| 47 |
CACHY_OVERVIEW.md
|
| 48 |
# Root-level Python prototype agent only — anchored so it does NOT swallow the
|
| 49 |
# Flutter feature dir app/lib/ui/features/presenter/.
|
| 50 |
-
/presenter/
|
|
|
|
|
|
|
|
|
| 47 |
CACHY_OVERVIEW.md
|
| 48 |
# Root-level Python prototype agent only — anchored so it does NOT swallow the
|
| 49 |
# Flutter feature dir app/lib/ui/features/presenter/.
|
| 50 |
+
/presenter/
|
| 51 |
+
PRODUCT.md
|
| 52 |
+
.impeccable/critique/2026-07-10T11-11-16Z__app-lib.md
|
app/lib/data/repositories/card_repository.dart
CHANGED
|
@@ -159,6 +159,9 @@ class CardRepository extends ChangeNotifier {
|
|
| 159 |
return card;
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
| 162 |
Future<void> delete(String cardId) async {
|
| 163 |
await _store.removeCard(cardId);
|
| 164 |
try {
|
|
|
|
| 159 |
return card;
|
| 160 |
}
|
| 161 |
|
| 162 |
+
/// Clear all locally cached cards. Returns how many were removed.
|
| 163 |
+
Future<int> clearCardCache() => _store.clearCardCache();
|
| 164 |
+
|
| 165 |
Future<void> delete(String cardId) async {
|
| 166 |
await _store.removeCard(cardId);
|
| 167 |
try {
|
app/lib/data/services/api_client.dart
CHANGED
|
@@ -21,11 +21,29 @@ import '../../domain/models/pipeline_event.dart';
|
|
| 21 |
class ApiException implements Exception {
|
| 22 |
ApiException(this.statusCode, this.message);
|
| 23 |
final int statusCode;
|
| 24 |
-
final String message;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
@override
|
| 26 |
String toString() => 'ApiException($statusCode): $message';
|
| 27 |
}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
class CreateCardResult {
|
| 30 |
const CreateCardResult({
|
| 31 |
required this.cardId,
|
|
|
|
| 21 |
class ApiException implements Exception {
|
| 22 |
ApiException(this.statusCode, this.message);
|
| 23 |
final int statusCode;
|
| 24 |
+
final String message; // raw body — for logs only, never for UI
|
| 25 |
+
|
| 26 |
+
/// What users see. Raw bodies (which may include server details or
|
| 27 |
+
/// tracebacks) never leave the data layer.
|
| 28 |
+
String get friendlyMessage => switch (statusCode) {
|
| 29 |
+
401 || 403 => 'Session expired — please sign in again.',
|
| 30 |
+
404 => "That card isn't there anymore.",
|
| 31 |
+
429 => "You've hit today's limit. It resets at midnight UTC.",
|
| 32 |
+
>= 500 => 'Something went wrong on our side. Try again in a moment.',
|
| 33 |
+
_ => "That didn't work. Try again.",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
@override
|
| 37 |
String toString() => 'ApiException($statusCode): $message';
|
| 38 |
}
|
| 39 |
|
| 40 |
+
/// UI-safe message for any thrown object. The only thing view-models should
|
| 41 |
+
/// ever store in a user-visible error field.
|
| 42 |
+
String friendlyError(Object e) => switch (e) {
|
| 43 |
+
ApiException api => api.friendlyMessage,
|
| 44 |
+
_ => "Can't reach Cachy. Check your connection.",
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
class CreateCardResult {
|
| 48 |
const CreateCardResult({
|
| 49 |
required this.cardId,
|
app/lib/data/services/local_store.dart
CHANGED
|
@@ -90,6 +90,16 @@ class LocalStore {
|
|
| 90 |
await _prefs.setStringList(_indexKey, index.toList());
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
// --------------------------------------------------------------------- //
|
| 94 |
// Offline share queue
|
| 95 |
// --------------------------------------------------------------------- //
|
|
|
|
| 90 |
await _prefs.setStringList(_indexKey, index.toList());
|
| 91 |
}
|
| 92 |
|
| 93 |
+
/// Remove every cached card and the index. Returns how many were removed.
|
| 94 |
+
Future<int> clearCardCache() async {
|
| 95 |
+
final ids = cachedCardIds();
|
| 96 |
+
for (final id in ids) {
|
| 97 |
+
await _prefs.remove('$_cardPrefix$id');
|
| 98 |
+
}
|
| 99 |
+
await _prefs.remove(_indexKey);
|
| 100 |
+
return ids.length;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
// --------------------------------------------------------------------- //
|
| 104 |
// Offline share queue
|
| 105 |
// --------------------------------------------------------------------- //
|
app/lib/ui/core/home_shell.dart
CHANGED
|
@@ -16,14 +16,15 @@ import '../features/actions/views/actions_screen.dart';
|
|
| 16 |
import '../features/capture/views/capture_sheet.dart';
|
| 17 |
import '../features/collections/views/collections_screen.dart';
|
| 18 |
import '../features/feed/views/knowledge_feed_screen.dart';
|
|
|
|
| 19 |
import '../features/library/view_models/library_view_model.dart';
|
|
|
|
| 20 |
import '../features/library/views/library_screen.dart';
|
| 21 |
import '../features/profile/views/profile_screen.dart';
|
| 22 |
import '../features/reader/views/reader_screen.dart';
|
| 23 |
import 'brand.dart';
|
| 24 |
import 'theme.dart';
|
| 25 |
import 'ui_bus.dart';
|
| 26 |
-
import 'widgets/adaptive_modal.dart';
|
| 27 |
import 'widgets/glass.dart';
|
| 28 |
import 'widgets/selection_action_bar.dart';
|
| 29 |
|
|
@@ -167,49 +168,12 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 167 |
bottomNavigationBar: selecting
|
| 168 |
? _SelectionNavSlot(
|
| 169 |
vm: vm,
|
| 170 |
-
onConfirmDelete: () =>
|
| 171 |
)
|
| 172 |
: _GlassNav(index: _index, onSelect: _select),
|
| 173 |
);
|
| 174 |
}
|
| 175 |
|
| 176 |
-
Future<void> _confirmBulkDelete(
|
| 177 |
-
BuildContext context, LibraryViewModel vm) async {
|
| 178 |
-
final count = vm.selectedCount;
|
| 179 |
-
final ok = await showAdaptiveModal<bool>(
|
| 180 |
-
context: context,
|
| 181 |
-
builder: (ctx, dialog) => AlertDialog(
|
| 182 |
-
title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
|
| 183 |
-
content: const Text(
|
| 184 |
-
'This removes the cards and their media. This cannot be undone.'),
|
| 185 |
-
actions: [
|
| 186 |
-
TextButton(
|
| 187 |
-
onPressed: () => Navigator.pop(ctx, false),
|
| 188 |
-
child: const Text('Cancel'),
|
| 189 |
-
),
|
| 190 |
-
FilledButton(
|
| 191 |
-
onPressed: () => Navigator.pop(ctx, true),
|
| 192 |
-
style: FilledButton.styleFrom(
|
| 193 |
-
backgroundColor: Theme.of(ctx).colorScheme.error,
|
| 194 |
-
),
|
| 195 |
-
child: const Text('Delete'),
|
| 196 |
-
),
|
| 197 |
-
],
|
| 198 |
-
),
|
| 199 |
-
);
|
| 200 |
-
if (ok == true && context.mounted) {
|
| 201 |
-
await vm.bulkDelete();
|
| 202 |
-
if (context.mounted) {
|
| 203 |
-
final error = context.read<LibraryViewModel>().error;
|
| 204 |
-
if (error != null) {
|
| 205 |
-
ScaffoldMessenger.of(context).showSnackBar(
|
| 206 |
-
SnackBar(content: Text('Delete failed: $error')),
|
| 207 |
-
);
|
| 208 |
-
}
|
| 209 |
-
}
|
| 210 |
-
}
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
Widget _desktopShell(BuildContext context) {
|
| 214 |
return Scaffold(
|
| 215 |
body: Stack(
|
|
@@ -480,9 +444,7 @@ class _SelectionNavSlot extends StatelessWidget {
|
|
| 480 |
child: SelectionActionBar(
|
| 481 |
selectedCount: vm.selectedCount,
|
| 482 |
onClose: vm.clearSelection,
|
| 483 |
-
onMoveToFolder: () =>
|
| 484 |
-
const SnackBar(content: Text('Move to Folder coming soon')),
|
| 485 |
-
),
|
| 486 |
onDeleteSelected: onConfirmDelete,
|
| 487 |
),
|
| 488 |
);
|
|
|
|
| 16 |
import '../features/capture/views/capture_sheet.dart';
|
| 17 |
import '../features/collections/views/collections_screen.dart';
|
| 18 |
import '../features/feed/views/knowledge_feed_screen.dart';
|
| 19 |
+
import '../features/collections/views/folder_picker_sheet.dart';
|
| 20 |
import '../features/library/view_models/library_view_model.dart';
|
| 21 |
+
import '../features/library/views/library_dialogs.dart';
|
| 22 |
import '../features/library/views/library_screen.dart';
|
| 23 |
import '../features/profile/views/profile_screen.dart';
|
| 24 |
import '../features/reader/views/reader_screen.dart';
|
| 25 |
import 'brand.dart';
|
| 26 |
import 'theme.dart';
|
| 27 |
import 'ui_bus.dart';
|
|
|
|
| 28 |
import 'widgets/glass.dart';
|
| 29 |
import 'widgets/selection_action_bar.dart';
|
| 30 |
|
|
|
|
| 168 |
bottomNavigationBar: selecting
|
| 169 |
? _SelectionNavSlot(
|
| 170 |
vm: vm,
|
| 171 |
+
onConfirmDelete: () => confirmBulkDelete(context, vm),
|
| 172 |
)
|
| 173 |
: _GlassNav(index: _index, onSelect: _select),
|
| 174 |
);
|
| 175 |
}
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
Widget _desktopShell(BuildContext context) {
|
| 178 |
return Scaffold(
|
| 179 |
body: Stack(
|
|
|
|
| 444 |
child: SelectionActionBar(
|
| 445 |
selectedCount: vm.selectedCount,
|
| 446 |
onClose: vm.clearSelection,
|
| 447 |
+
onMoveToFolder: () => showFolderPicker(context, vm),
|
|
|
|
|
|
|
| 448 |
onDeleteSelected: onConfirmDelete,
|
| 449 |
),
|
| 450 |
);
|
app/lib/ui/core/theme.dart
CHANGED
|
@@ -148,7 +148,17 @@ class Motion {
|
|
| 148 |
static const slow = Duration(milliseconds: 420);
|
| 149 |
static const stagger = Duration(milliseconds: 45);
|
| 150 |
static const curve = Curves.easeOutCubic;
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
/// Layout tokens: generous editorial margins.
|
|
|
|
| 148 |
static const slow = Duration(milliseconds: 420);
|
| 149 |
static const stagger = Duration(milliseconds: 45);
|
| 150 |
static const curve = Curves.easeOutCubic;
|
| 151 |
+
// Ease-out with no overshoot: the product register bans bounce/elastic.
|
| 152 |
+
static const spring = Curves.easeOutCubic;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/// Reduced-motion gate: animations collapse to zero duration when the OS asks
|
| 156 |
+
/// for reduced motion, honouring the accessibility setting everywhere at once.
|
| 157 |
+
extension MotionGate on BuildContext {
|
| 158 |
+
bool get motionEnabled => !MediaQuery.of(this).disableAnimations;
|
| 159 |
+
|
| 160 |
+
/// [d] when motion is allowed, otherwise [Duration.zero] (instant).
|
| 161 |
+
Duration gated(Duration d) => motionEnabled ? d : Duration.zero;
|
| 162 |
}
|
| 163 |
|
| 164 |
/// Layout tokens: generous editorial margins.
|
app/lib/ui/core/widgets/loading_tiles.dart
CHANGED
|
@@ -14,6 +14,7 @@ class LoadingTiles extends StatelessWidget {
|
|
| 14 |
@override
|
| 15 |
Widget build(BuildContext context) {
|
| 16 |
final scheme = Theme.of(context).colorScheme;
|
|
|
|
| 17 |
return GridView.count(
|
| 18 |
padding: const EdgeInsets.all(Insets.page),
|
| 19 |
physics: const NeverScrollableScrollPhysics(),
|
|
@@ -23,18 +24,23 @@ class LoadingTiles extends StatelessWidget {
|
|
| 23 |
childAspectRatio: 0.72,
|
| 24 |
children: [
|
| 25 |
for (var i = 0; i < count; i++)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
)
|
| 32 |
-
.animate(onPlay: (c) => c.repeat())
|
| 33 |
-
.shimmer(
|
| 34 |
-
duration: const Duration(milliseconds: 1100),
|
| 35 |
-
delay: Duration(milliseconds: i * 90),
|
| 36 |
-
color: scheme.surfaceContainerHighest,
|
| 37 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
],
|
| 39 |
);
|
| 40 |
}
|
|
|
|
| 14 |
@override
|
| 15 |
Widget build(BuildContext context) {
|
| 16 |
final scheme = Theme.of(context).colorScheme;
|
| 17 |
+
final animate = context.motionEnabled;
|
| 18 |
return GridView.count(
|
| 19 |
padding: const EdgeInsets.all(Insets.page),
|
| 20 |
physics: const NeverScrollableScrollPhysics(),
|
|
|
|
| 24 |
childAspectRatio: 0.72,
|
| 25 |
children: [
|
| 26 |
for (var i = 0; i < count; i++)
|
| 27 |
+
() {
|
| 28 |
+
final tile = Container(
|
| 29 |
+
decoration: BoxDecoration(
|
| 30 |
+
color: scheme.surfaceContainerHigh,
|
| 31 |
+
borderRadius: BorderRadius.circular(Insets.radius),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
),
|
| 33 |
+
);
|
| 34 |
+
// Reduced motion: a static placeholder, no looping shimmer.
|
| 35 |
+
if (!animate) return tile;
|
| 36 |
+
return tile
|
| 37 |
+
.animate(onPlay: (c) => c.repeat())
|
| 38 |
+
.shimmer(
|
| 39 |
+
duration: const Duration(milliseconds: 1100),
|
| 40 |
+
delay: Duration(milliseconds: i * 90),
|
| 41 |
+
color: scheme.surfaceContainerHighest,
|
| 42 |
+
);
|
| 43 |
+
}(),
|
| 44 |
],
|
| 45 |
);
|
| 46 |
}
|
app/lib/ui/core/widgets/processing_glyph.dart
CHANGED
|
@@ -73,7 +73,7 @@ class _ProcessingGlyphState extends State<ProcessingGlyph>
|
|
| 73 |
child: TweenAnimationBuilder<double>(
|
| 74 |
tween: Tween(begin: 0.0, end: 1.0),
|
| 75 |
duration: const Duration(milliseconds: 600),
|
| 76 |
-
curve: Curves.
|
| 77 |
builder: (context, t, _) {
|
| 78 |
final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
|
| 79 |
return Transform.scale(
|
|
|
|
| 73 |
child: TweenAnimationBuilder<double>(
|
| 74 |
tween: Tween(begin: 0.0, end: 1.0),
|
| 75 |
duration: const Duration(milliseconds: 600),
|
| 76 |
+
curve: Curves.easeOutCubic,
|
| 77 |
builder: (context, t, _) {
|
| 78 |
final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
|
| 79 |
return Transform.scale(
|
app/lib/ui/features/actions/view_models/actions_view_model.dart
CHANGED
|
@@ -7,6 +7,7 @@ library;
|
|
| 7 |
import 'package:flutter/foundation.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 10 |
import '../../../../domain/models/card.dart';
|
| 11 |
|
| 12 |
enum ActionsStatus { idle, loading, ready, error, empty }
|
|
@@ -70,7 +71,7 @@ class ActionsViewModel extends ChangeNotifier {
|
|
| 70 |
_status = groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready;
|
| 71 |
_error = null;
|
| 72 |
} catch (e) {
|
| 73 |
-
_error =
|
| 74 |
_status = ActionsStatus.error;
|
| 75 |
}
|
| 76 |
notifyListeners();
|
|
|
|
| 7 |
import 'package:flutter/foundation.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
+
import '../../../../data/services/api_client.dart';
|
| 11 |
import '../../../../domain/models/card.dart';
|
| 12 |
|
| 13 |
enum ActionsStatus { idle, loading, ready, error, empty }
|
|
|
|
| 71 |
_status = groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready;
|
| 72 |
_error = null;
|
| 73 |
} catch (e) {
|
| 74 |
+
_error = friendlyError(e);
|
| 75 |
_status = ActionsStatus.error;
|
| 76 |
}
|
| 77 |
notifyListeners();
|
app/lib/ui/features/catalog/view_models/catalog_view_model.dart
CHANGED
|
@@ -5,6 +5,7 @@ library;
|
|
| 5 |
import 'package:flutter/foundation.dart';
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 8 |
import '../../../../domain/models/artifact.dart';
|
| 9 |
|
| 10 |
enum CatalogStatus { idle, loading, ready, error, empty }
|
|
@@ -84,7 +85,7 @@ class CatalogViewModel extends ChangeNotifier {
|
|
| 84 |
_entries.isEmpty ? CatalogStatus.empty : CatalogStatus.ready;
|
| 85 |
_error = null;
|
| 86 |
} catch (e) {
|
| 87 |
-
_error =
|
| 88 |
_status = _entries.isEmpty ? CatalogStatus.error : CatalogStatus.ready;
|
| 89 |
}
|
| 90 |
notifyListeners();
|
|
|
|
| 5 |
import 'package:flutter/foundation.dart';
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
+
import '../../../../data/services/api_client.dart';
|
| 9 |
import '../../../../domain/models/artifact.dart';
|
| 10 |
|
| 11 |
enum CatalogStatus { idle, loading, ready, error, empty }
|
|
|
|
| 85 |
_entries.isEmpty ? CatalogStatus.empty : CatalogStatus.ready;
|
| 86 |
_error = null;
|
| 87 |
} catch (e) {
|
| 88 |
+
_error = friendlyError(e);
|
| 89 |
_status = _entries.isEmpty ? CatalogStatus.error : CatalogStatus.ready;
|
| 90 |
}
|
| 91 |
notifyListeners();
|
app/lib/ui/features/collections/view_models/collections_view_model.dart
CHANGED
|
@@ -3,6 +3,7 @@ library;
|
|
| 3 |
import 'package:flutter/foundation.dart';
|
| 4 |
|
| 5 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 6 |
import '../../../../domain/models/card.dart';
|
| 7 |
import '../../../../domain/models/collection.dart';
|
| 8 |
import '../../../../domain/models/enums.dart';
|
|
@@ -95,7 +96,7 @@ class CollectionsViewModel extends ChangeNotifier {
|
|
| 95 |
_error = null;
|
| 96 |
_status = _collections.isEmpty ? CollectionsStatus.empty : CollectionsStatus.ready;
|
| 97 |
} catch (e) {
|
| 98 |
-
_error =
|
| 99 |
// Fall back to client-side grouping from cached cards.
|
| 100 |
try {
|
| 101 |
final cards = await _repository.list();
|
|
|
|
| 3 |
import 'package:flutter/foundation.dart';
|
| 4 |
|
| 5 |
import '../../../../data/repositories/card_repository.dart';
|
| 6 |
+
import '../../../../data/services/api_client.dart';
|
| 7 |
import '../../../../domain/models/card.dart';
|
| 8 |
import '../../../../domain/models/collection.dart';
|
| 9 |
import '../../../../domain/models/enums.dart';
|
|
|
|
| 96 |
_error = null;
|
| 97 |
_status = _collections.isEmpty ? CollectionsStatus.empty : CollectionsStatus.ready;
|
| 98 |
} catch (e) {
|
| 99 |
+
_error = friendlyError(e);
|
| 100 |
// Fall back to client-side grouping from cached cards.
|
| 101 |
try {
|
| 102 |
final cards = await _repository.list();
|
app/lib/ui/features/collections/views/folder_picker_sheet.dart
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Bulk "Move to Folder" picker — an adaptive sheet listing the user's folders
|
| 2 |
+
/// (plus "New folder…" and "Remove from folders"). Replaces the old
|
| 3 |
+
/// coming-soon stub in the selection action bar.
|
| 4 |
+
library;
|
| 5 |
+
|
| 6 |
+
import 'package:flutter/material.dart';
|
| 7 |
+
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 8 |
+
import 'package:provider/provider.dart';
|
| 9 |
+
|
| 10 |
+
import '../../../../data/repositories/card_repository.dart';
|
| 11 |
+
import '../../../../domain/models/collection.dart';
|
| 12 |
+
import '../../../core/theme.dart';
|
| 13 |
+
import '../../../core/widgets/adaptive_modal.dart';
|
| 14 |
+
import '../../library/view_models/library_view_model.dart';
|
| 15 |
+
|
| 16 |
+
/// Present the folder picker for the current selection. Moves every selected
|
| 17 |
+
/// card into the chosen folder and shows a confirmation. No-op if nothing is
|
| 18 |
+
/// selected.
|
| 19 |
+
Future<void> showFolderPicker(BuildContext context, LibraryViewModel vm) async {
|
| 20 |
+
if (!vm.selectionActive) return;
|
| 21 |
+
final count = vm.selectedCount;
|
| 22 |
+
final repo = context.read<CardRepository>();
|
| 23 |
+
final messenger = ScaffoldMessenger.of(context);
|
| 24 |
+
|
| 25 |
+
await showAdaptiveModal<void>(
|
| 26 |
+
context: context,
|
| 27 |
+
builder: (ctx, dialog) => _FolderPicker(
|
| 28 |
+
repo: repo,
|
| 29 |
+
dialog: dialog,
|
| 30 |
+
onPick: (collectionId, label) async {
|
| 31 |
+
Navigator.pop(ctx);
|
| 32 |
+
await vm.bulkMove(collectionId);
|
| 33 |
+
messenger.showSnackBar(SnackBar(
|
| 34 |
+
content: Text(
|
| 35 |
+
'Moved $count ${count == 1 ? 'card' : 'cards'}'
|
| 36 |
+
'${label == null ? ' out of folders' : ' to "$label"'}',
|
| 37 |
+
),
|
| 38 |
+
));
|
| 39 |
+
},
|
| 40 |
+
),
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
class _FolderPicker extends StatelessWidget {
|
| 45 |
+
const _FolderPicker({
|
| 46 |
+
required this.repo,
|
| 47 |
+
required this.dialog,
|
| 48 |
+
required this.onPick,
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
final CardRepository repo;
|
| 52 |
+
final bool dialog;
|
| 53 |
+
|
| 54 |
+
/// (collectionId, label): collectionId null = remove from all folders.
|
| 55 |
+
final Future<void> Function(String? collectionId, String? label) onPick;
|
| 56 |
+
|
| 57 |
+
@override
|
| 58 |
+
Widget build(BuildContext context) {
|
| 59 |
+
final theme = Theme.of(context);
|
| 60 |
+
final scheme = theme.colorScheme;
|
| 61 |
+
return Container(
|
| 62 |
+
decoration: BoxDecoration(
|
| 63 |
+
color: scheme.surface,
|
| 64 |
+
borderRadius: dialog
|
| 65 |
+
? BorderRadius.circular(Insets.radius)
|
| 66 |
+
: const BorderRadius.vertical(top: Radius.circular(Insets.radius)),
|
| 67 |
+
),
|
| 68 |
+
padding: EdgeInsets.only(
|
| 69 |
+
top: 12,
|
| 70 |
+
bottom: MediaQuery.of(context).viewInsets.bottom + 12,
|
| 71 |
+
),
|
| 72 |
+
child: Column(
|
| 73 |
+
mainAxisSize: MainAxisSize.min,
|
| 74 |
+
children: [
|
| 75 |
+
if (!dialog)
|
| 76 |
+
Container(
|
| 77 |
+
width: 36,
|
| 78 |
+
height: 4,
|
| 79 |
+
margin: const EdgeInsets.only(bottom: 12),
|
| 80 |
+
decoration: BoxDecoration(
|
| 81 |
+
color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
|
| 82 |
+
borderRadius: BorderRadius.circular(2),
|
| 83 |
+
),
|
| 84 |
+
),
|
| 85 |
+
Padding(
|
| 86 |
+
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
| 87 |
+
child: Align(
|
| 88 |
+
alignment: Alignment.centerLeft,
|
| 89 |
+
child: Text('Move to folder', style: theme.textTheme.titleMedium),
|
| 90 |
+
),
|
| 91 |
+
),
|
| 92 |
+
Flexible(
|
| 93 |
+
child: FutureBuilder<List<CollectionEntry>>(
|
| 94 |
+
future: repo.listCollections(),
|
| 95 |
+
builder: (context, snap) {
|
| 96 |
+
if (snap.connectionState == ConnectionState.waiting) {
|
| 97 |
+
return const Padding(
|
| 98 |
+
padding: EdgeInsets.all(24),
|
| 99 |
+
child: CircularProgressIndicator(),
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
final folders = snap.data ?? const <CollectionEntry>[];
|
| 103 |
+
return ListView(
|
| 104 |
+
shrinkWrap: true,
|
| 105 |
+
padding: EdgeInsets.zero,
|
| 106 |
+
children: [
|
| 107 |
+
for (final f in folders)
|
| 108 |
+
ListTile(
|
| 109 |
+
leading: const PhosphorIcon(PhosphorIconsRegular.folder),
|
| 110 |
+
title: Text(f.name),
|
| 111 |
+
onTap: () => onPick(f.id, f.name),
|
| 112 |
+
),
|
| 113 |
+
const Divider(height: 1),
|
| 114 |
+
ListTile(
|
| 115 |
+
leading: PhosphorIcon(PhosphorIconsRegular.folderPlus,
|
| 116 |
+
color: scheme.primary),
|
| 117 |
+
title: Text('New folder…',
|
| 118 |
+
style: TextStyle(color: scheme.primary)),
|
| 119 |
+
onTap: () => _createAndMove(context),
|
| 120 |
+
),
|
| 121 |
+
if (folders.isNotEmpty)
|
| 122 |
+
ListTile(
|
| 123 |
+
leading: const PhosphorIcon(PhosphorIconsRegular.folderMinus),
|
| 124 |
+
title: const Text('Remove from folders'),
|
| 125 |
+
onTap: () => onPick(null, null),
|
| 126 |
+
),
|
| 127 |
+
],
|
| 128 |
+
);
|
| 129 |
+
},
|
| 130 |
+
),
|
| 131 |
+
),
|
| 132 |
+
],
|
| 133 |
+
),
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
Future<void> _createAndMove(BuildContext context) async {
|
| 138 |
+
final controller = TextEditingController();
|
| 139 |
+
final name = await showDialog<String>(
|
| 140 |
+
context: context,
|
| 141 |
+
builder: (ctx) => AlertDialog(
|
| 142 |
+
title: const Text('New folder'),
|
| 143 |
+
content: TextField(
|
| 144 |
+
controller: controller,
|
| 145 |
+
autofocus: true,
|
| 146 |
+
textCapitalization: TextCapitalization.words,
|
| 147 |
+
decoration: const InputDecoration(hintText: 'Folder name'),
|
| 148 |
+
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
| 149 |
+
),
|
| 150 |
+
actions: [
|
| 151 |
+
TextButton(
|
| 152 |
+
onPressed: () => Navigator.pop(ctx),
|
| 153 |
+
child: const Text('Cancel'),
|
| 154 |
+
),
|
| 155 |
+
FilledButton(
|
| 156 |
+
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
| 157 |
+
child: const Text('Create'),
|
| 158 |
+
),
|
| 159 |
+
],
|
| 160 |
+
),
|
| 161 |
+
);
|
| 162 |
+
if (name == null || name.isEmpty) return;
|
| 163 |
+
final created = await repo.createCollection(name);
|
| 164 |
+
await onPick(created.id, created.name);
|
| 165 |
+
}
|
| 166 |
+
}
|
app/lib/ui/features/concepts/view_models/concepts_view_model.dart
CHANGED
|
@@ -5,6 +5,7 @@ library;
|
|
| 5 |
import 'package:flutter/foundation.dart';
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 8 |
import '../../../../domain/models/concept.dart';
|
| 9 |
import '../../../core/safe_notifier.dart';
|
| 10 |
|
|
@@ -61,7 +62,7 @@ class ConceptsViewModel extends ChangeNotifier with SafeNotifier {
|
|
| 61 |
_status = _entries.isEmpty ? ConceptsStatus.empty : ConceptsStatus.ready;
|
| 62 |
_error = null;
|
| 63 |
} catch (e) {
|
| 64 |
-
_error =
|
| 65 |
_status = _entries.isEmpty ? ConceptsStatus.error : ConceptsStatus.ready;
|
| 66 |
}
|
| 67 |
notifyListeners();
|
|
|
|
| 5 |
import 'package:flutter/foundation.dart';
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
+
import '../../../../data/services/api_client.dart';
|
| 9 |
import '../../../../domain/models/concept.dart';
|
| 10 |
import '../../../core/safe_notifier.dart';
|
| 11 |
|
|
|
|
| 62 |
_status = _entries.isEmpty ? ConceptsStatus.empty : ConceptsStatus.ready;
|
| 63 |
_error = null;
|
| 64 |
} catch (e) {
|
| 65 |
+
_error = friendlyError(e);
|
| 66 |
_status = _entries.isEmpty ? ConceptsStatus.error : ConceptsStatus.ready;
|
| 67 |
}
|
| 68 |
notifyListeners();
|
app/lib/ui/features/feed/views/knowledge_feed_screen.dart
CHANGED
|
@@ -641,14 +641,16 @@ class _SwipeHint extends StatelessWidget {
|
|
| 641 |
@override
|
| 642 |
Widget build(BuildContext context) {
|
| 643 |
final scheme = Theme.of(context).colorScheme;
|
| 644 |
-
|
| 645 |
mainAxisSize: MainAxisSize.min,
|
| 646 |
children: [
|
| 647 |
PhosphorIcon(PhosphorIconsRegular.caretUp, size: 18, color: scheme.onSurfaceVariant),
|
| 648 |
Text('SWIPE UP',
|
| 649 |
style: Brand.label(size: 9, color: scheme.onSurfaceVariant, weight: FontWeight.w600)),
|
| 650 |
],
|
| 651 |
-
)
|
|
|
|
|
|
|
| 652 |
.animate(onPlay: (c) => c.repeat(reverse: true))
|
| 653 |
.moveY(begin: 4, end: -4, duration: 900.ms, curve: Curves.easeInOut);
|
| 654 |
}
|
|
|
|
| 641 |
@override
|
| 642 |
Widget build(BuildContext context) {
|
| 643 |
final scheme = Theme.of(context).colorScheme;
|
| 644 |
+
final hint = Column(
|
| 645 |
mainAxisSize: MainAxisSize.min,
|
| 646 |
children: [
|
| 647 |
PhosphorIcon(PhosphorIconsRegular.caretUp, size: 18, color: scheme.onSurfaceVariant),
|
| 648 |
Text('SWIPE UP',
|
| 649 |
style: Brand.label(size: 9, color: scheme.onSurfaceVariant, weight: FontWeight.w600)),
|
| 650 |
],
|
| 651 |
+
);
|
| 652 |
+
if (!context.motionEnabled) return hint; // reduced motion: static hint
|
| 653 |
+
return hint
|
| 654 |
.animate(onPlay: (c) => c.repeat(reverse: true))
|
| 655 |
.moveY(begin: 4, end: -4, duration: 900.ms, curve: Curves.easeInOut);
|
| 656 |
}
|
app/lib/ui/features/library/view_models/library_view_model.dart
CHANGED
|
@@ -7,20 +7,53 @@ import 'dart:async';
|
|
| 7 |
import 'package:flutter/foundation.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 10 |
import '../../../../domain/models/card.dart';
|
| 11 |
import '../../../../domain/models/enums.dart';
|
| 12 |
import '../../../core/safe_notifier.dart';
|
| 13 |
|
| 14 |
-
enum LibraryStatus { idle, loading, ready, error, empty }
|
| 15 |
|
| 16 |
class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
| 17 |
-
LibraryViewModel({
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
_repository.addListener(_onRepoChange);
|
| 20 |
}
|
| 21 |
|
| 22 |
final CardRepository _repository;
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
void _onRepoChange() {
|
| 25 |
if (_status != LibraryStatus.loading) {
|
| 26 |
load(showSpinner: false);
|
|
@@ -207,13 +240,13 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
|
| 207 |
}
|
| 208 |
try {
|
| 209 |
await _repository.flushPendingShares();
|
| 210 |
-
final cards = await
|
| 211 |
_cards = cards;
|
| 212 |
_offline = false;
|
| 213 |
_status = cards.isEmpty ? LibraryStatus.empty : LibraryStatus.ready;
|
| 214 |
_error = null;
|
| 215 |
} catch (e) {
|
| 216 |
-
_error =
|
| 217 |
_offline = true;
|
| 218 |
_status = _cards.isEmpty ? LibraryStatus.error : LibraryStatus.ready;
|
| 219 |
}
|
|
@@ -247,6 +280,24 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
|
| 247 |
/// repository. If any deletion fails, the card list and selection are
|
| 248 |
/// restored exactly to their pre-operation values and an error is surfaced
|
| 249 |
/// (Requirement 8.9).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
Future<void> bulkDelete() async {
|
| 251 |
final ids = _selectedIds.toList();
|
| 252 |
if (ids.isEmpty) return;
|
|
@@ -287,7 +338,7 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
|
| 287 |
..addAll(selectionSnapshot);
|
| 288 |
_selectionMode = selectionModeSnapshot;
|
| 289 |
_selectionAnchorId = anchorSnapshot;
|
| 290 |
-
_error =
|
| 291 |
notifyListeners();
|
| 292 |
}
|
| 293 |
}
|
|
|
|
| 7 |
import 'package:flutter/foundation.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
+
import '../../../../data/services/api_client.dart';
|
| 11 |
import '../../../../domain/models/card.dart';
|
| 12 |
import '../../../../domain/models/enums.dart';
|
| 13 |
import '../../../core/safe_notifier.dart';
|
| 14 |
|
| 15 |
+
enum LibraryStatus { idle, loading, waking, ready, error, empty }
|
| 16 |
|
| 17 |
class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
| 18 |
+
LibraryViewModel({
|
| 19 |
+
required CardRepository repository,
|
| 20 |
+
this.wakeRetryDelay = const Duration(seconds: 10),
|
| 21 |
+
}) : _repository = repository {
|
| 22 |
_repository.addListener(_onRepoChange);
|
| 23 |
}
|
| 24 |
|
| 25 |
final CardRepository _repository;
|
| 26 |
|
| 27 |
+
/// Delay between cold-start retries; overridden to zero in tests.
|
| 28 |
+
final Duration wakeRetryDelay;
|
| 29 |
+
|
| 30 |
+
/// Free HF Spaces nap when idle and take ~30s to wake. We retry a handful of
|
| 31 |
+
/// times, showing the "waking" state, before conceding to an error.
|
| 32 |
+
static const _maxWakeAttempts = 6;
|
| 33 |
+
int _wakeAttempt = 0;
|
| 34 |
+
int get wakeAttempt => _wakeAttempt;
|
| 35 |
+
|
| 36 |
+
/// A connection-shaped failure worth retrying: no HTTP response at all
|
| 37 |
+
/// (socket/timeout, thrown as non-[ApiException]) or a gateway 502/503 from
|
| 38 |
+
/// a Space that is still booting.
|
| 39 |
+
bool _isWaking(Object e) =>
|
| 40 |
+
e is! ApiException || e.statusCode == 502 || e.statusCode == 503;
|
| 41 |
+
|
| 42 |
+
/// Fetch the library, retrying through cold start. Rethrows once retries are
|
| 43 |
+
/// exhausted or the failure isn't cold-start-shaped.
|
| 44 |
+
Future<List<Card>> _fetchWithWake() async {
|
| 45 |
+
for (_wakeAttempt = 0; ; _wakeAttempt++) {
|
| 46 |
+
try {
|
| 47 |
+
return await _repository.list(state: _filter);
|
| 48 |
+
} catch (e) {
|
| 49 |
+
if (!_isWaking(e) || _wakeAttempt >= _maxWakeAttempts) rethrow;
|
| 50 |
+
_status = LibraryStatus.waking;
|
| 51 |
+
notifyListeners();
|
| 52 |
+
await Future<void>.delayed(wakeRetryDelay);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
void _onRepoChange() {
|
| 58 |
if (_status != LibraryStatus.loading) {
|
| 59 |
load(showSpinner: false);
|
|
|
|
| 240 |
}
|
| 241 |
try {
|
| 242 |
await _repository.flushPendingShares();
|
| 243 |
+
final cards = await _fetchWithWake();
|
| 244 |
_cards = cards;
|
| 245 |
_offline = false;
|
| 246 |
_status = cards.isEmpty ? LibraryStatus.empty : LibraryStatus.ready;
|
| 247 |
_error = null;
|
| 248 |
} catch (e) {
|
| 249 |
+
_error = friendlyError(e);
|
| 250 |
_offline = true;
|
| 251 |
_status = _cards.isEmpty ? LibraryStatus.error : LibraryStatus.ready;
|
| 252 |
}
|
|
|
|
| 280 |
/// repository. If any deletion fails, the card list and selection are
|
| 281 |
/// restored exactly to their pre-operation values and an error is surfaced
|
| 282 |
/// (Requirement 8.9).
|
| 283 |
+
/// Move every currently-selected card into [collectionId] (null removes them
|
| 284 |
+
/// from all folders). Clears the selection and resyncs on success; surfaces a
|
| 285 |
+
/// friendly error and keeps the selection on failure.
|
| 286 |
+
Future<void> bulkMove(String? collectionId) async {
|
| 287 |
+
final ids = _selectedIds.toList();
|
| 288 |
+
if (ids.isEmpty) return;
|
| 289 |
+
try {
|
| 290 |
+
for (final id in ids) {
|
| 291 |
+
await _repository.moveCardToCollection(id, collectionId);
|
| 292 |
+
}
|
| 293 |
+
clearSelection();
|
| 294 |
+
await load(showSpinner: false);
|
| 295 |
+
} catch (e) {
|
| 296 |
+
_error = friendlyError(e);
|
| 297 |
+
notifyListeners();
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
Future<void> bulkDelete() async {
|
| 302 |
final ids = _selectedIds.toList();
|
| 303 |
if (ids.isEmpty) return;
|
|
|
|
| 338 |
..addAll(selectionSnapshot);
|
| 339 |
_selectionMode = selectionModeSnapshot;
|
| 340 |
_selectionAnchorId = anchorSnapshot;
|
| 341 |
+
_error = friendlyError(e);
|
| 342 |
notifyListeners();
|
| 343 |
}
|
| 344 |
}
|
app/lib/ui/features/library/views/library_dialogs.dart
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Shared bulk-selection dialogs for the library. Extracted so the mobile shell
|
| 2 |
+
/// and the desktop overlay invoke the exact same confirmation flow instead of
|
| 3 |
+
/// each carrying its own copy.
|
| 4 |
+
library;
|
| 5 |
+
|
| 6 |
+
import 'package:flutter/material.dart';
|
| 7 |
+
|
| 8 |
+
import '../../../core/widgets/adaptive_modal.dart';
|
| 9 |
+
import '../view_models/library_view_model.dart';
|
| 10 |
+
|
| 11 |
+
/// Confirm and perform a bulk delete of the currently-selected cards. Surfaces
|
| 12 |
+
/// a snackbar if the delete fails.
|
| 13 |
+
Future<void> confirmBulkDelete(BuildContext context, LibraryViewModel vm) async {
|
| 14 |
+
final count = vm.selectedCount;
|
| 15 |
+
if (count == 0) return;
|
| 16 |
+
final messenger = ScaffoldMessenger.of(context);
|
| 17 |
+
final ok = await showAdaptiveModal<bool>(
|
| 18 |
+
context: context,
|
| 19 |
+
builder: (ctx, dialog) => AlertDialog(
|
| 20 |
+
title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
|
| 21 |
+
content: const Text(
|
| 22 |
+
'This removes the cards and their media. This cannot be undone.'),
|
| 23 |
+
actions: [
|
| 24 |
+
TextButton(
|
| 25 |
+
onPressed: () => Navigator.pop(ctx, false),
|
| 26 |
+
child: const Text('Cancel'),
|
| 27 |
+
),
|
| 28 |
+
FilledButton(
|
| 29 |
+
onPressed: () => Navigator.pop(ctx, true),
|
| 30 |
+
style: FilledButton.styleFrom(
|
| 31 |
+
backgroundColor: Theme.of(ctx).colorScheme.error,
|
| 32 |
+
),
|
| 33 |
+
child: const Text('Delete'),
|
| 34 |
+
),
|
| 35 |
+
],
|
| 36 |
+
),
|
| 37 |
+
);
|
| 38 |
+
if (ok == true) {
|
| 39 |
+
await vm.bulkDelete();
|
| 40 |
+
if (vm.error != null) {
|
| 41 |
+
messenger.showSnackBar(SnackBar(content: Text(vm.error!)));
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
app/lib/ui/features/library/views/library_screen.dart
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
/// The library: a browsable wall of card faces (docs/06), not a feed of text.
|
| 2 |
-
///
|
| 3 |
-
///
|
| 4 |
-
///
|
| 5 |
-
///
|
| 6 |
-
///
|
| 7 |
library;
|
| 8 |
|
| 9 |
import 'dart:io' show Platform;
|
|
@@ -21,7 +21,6 @@ import '../../../../domain/models/highlight.dart';
|
|
| 21 |
import '../../../core/app_controller.dart';
|
| 22 |
import '../../../core/brand.dart';
|
| 23 |
import '../../../core/theme.dart';
|
| 24 |
-
import '../../../core/widgets/adaptive_modal.dart';
|
| 25 |
import '../../../core/widgets/empty_state.dart';
|
| 26 |
import '../../../core/widgets/error_state.dart';
|
| 27 |
import '../../../core/widgets/loading_tiles.dart';
|
|
@@ -30,6 +29,7 @@ import '../../../core/widgets/split_pane.dart';
|
|
| 30 |
import '../../../core/widgets/spot_art.dart';
|
| 31 |
import '../../capture/views/capture_sheet.dart';
|
| 32 |
import '../../catalog/views/catalog_screen.dart';
|
|
|
|
| 33 |
import '../../concepts/views/concepts_screen.dart';
|
| 34 |
import '../../graph/views/graph_screen.dart';
|
| 35 |
import '../../library/views/library_chat_screen.dart';
|
|
@@ -38,6 +38,7 @@ import '../../reader/views/reader_screen.dart';
|
|
| 38 |
import '../../search/views/search_screen.dart';
|
| 39 |
import '../view_models/library_view_model.dart';
|
| 40 |
import 'card_tile.dart';
|
|
|
|
| 41 |
import 'grid_navigation.dart';
|
| 42 |
|
| 43 |
class LibraryScreen extends StatelessWidget {
|
|
@@ -312,60 +313,28 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 312 |
child: SelectionActionBar(
|
| 313 |
selectedCount: vm.selectedCount,
|
| 314 |
onClose: () => context.read<LibraryViewModel>().clearSelection(),
|
| 315 |
-
onMoveToFolder: ()
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
);
|
| 319 |
-
},
|
| 320 |
-
onDeleteSelected: () => _confirmBulkDelete(context),
|
| 321 |
),
|
| 322 |
),
|
| 323 |
],
|
| 324 |
);
|
| 325 |
}
|
| 326 |
|
| 327 |
-
Future<void> _confirmBulkDelete(BuildContext context) async {
|
| 328 |
-
final vm = context.read<LibraryViewModel>();
|
| 329 |
-
final count = vm.selectedCount;
|
| 330 |
-
final ok = await showAdaptiveModal<bool>(
|
| 331 |
-
context: context,
|
| 332 |
-
builder: (ctx, dialog) => AlertDialog(
|
| 333 |
-
title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
|
| 334 |
-
content: const Text(
|
| 335 |
-
'This removes the cards and their media. This cannot be undone.'),
|
| 336 |
-
actions: [
|
| 337 |
-
TextButton(
|
| 338 |
-
onPressed: () => Navigator.pop(ctx, false),
|
| 339 |
-
child: const Text('Cancel'),
|
| 340 |
-
),
|
| 341 |
-
FilledButton(
|
| 342 |
-
onPressed: () => Navigator.pop(ctx, true),
|
| 343 |
-
style: FilledButton.styleFrom(
|
| 344 |
-
backgroundColor: Theme.of(ctx).colorScheme.error,
|
| 345 |
-
),
|
| 346 |
-
child: const Text('Delete'),
|
| 347 |
-
),
|
| 348 |
-
],
|
| 349 |
-
),
|
| 350 |
-
);
|
| 351 |
-
if (ok == true && context.mounted) {
|
| 352 |
-
await context.read<LibraryViewModel>().bulkDelete();
|
| 353 |
-
// If bulkDelete surfaced an error (vm.error != null), show it.
|
| 354 |
-
if (context.mounted) {
|
| 355 |
-
final error = context.read<LibraryViewModel>().error;
|
| 356 |
-
if (error != null) {
|
| 357 |
-
ScaffoldMessenger.of(context).showSnackBar(
|
| 358 |
-
SnackBar(content: Text('Delete failed: $error')),
|
| 359 |
-
);
|
| 360 |
-
}
|
| 361 |
-
}
|
| 362 |
-
}
|
| 363 |
-
}
|
| 364 |
-
|
| 365 |
Widget _body(BuildContext context, LibraryViewModel vm, dynamic api) {
|
| 366 |
switch (vm.status) {
|
| 367 |
case LibraryStatus.loading:
|
| 368 |
return const LoadingTiles();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
case LibraryStatus.error:
|
| 370 |
return _scrollable(
|
| 371 |
ErrorState(
|
|
|
|
| 1 |
/// The library: a browsable wall of card faces (docs/06), not a feed of text.
|
| 2 |
+
/// Three segments — Cards (the grid), Concepts, and Catalog. Branded chrome
|
| 3 |
+
/// (wordmark, gradient tab indicator), designed empty/loading/waking/error/
|
| 4 |
+
/// offline states, and a staggered tile entrance. Capture lives in the shell's
|
| 5 |
+
/// center button; search opens a dedicated screen. Tap a tile → reader via a
|
| 6 |
+
/// shared-element face transition.
|
| 7 |
library;
|
| 8 |
|
| 9 |
import 'dart:io' show Platform;
|
|
|
|
| 21 |
import '../../../core/app_controller.dart';
|
| 22 |
import '../../../core/brand.dart';
|
| 23 |
import '../../../core/theme.dart';
|
|
|
|
| 24 |
import '../../../core/widgets/empty_state.dart';
|
| 25 |
import '../../../core/widgets/error_state.dart';
|
| 26 |
import '../../../core/widgets/loading_tiles.dart';
|
|
|
|
| 29 |
import '../../../core/widgets/spot_art.dart';
|
| 30 |
import '../../capture/views/capture_sheet.dart';
|
| 31 |
import '../../catalog/views/catalog_screen.dart';
|
| 32 |
+
import '../../collections/views/folder_picker_sheet.dart';
|
| 33 |
import '../../concepts/views/concepts_screen.dart';
|
| 34 |
import '../../graph/views/graph_screen.dart';
|
| 35 |
import '../../library/views/library_chat_screen.dart';
|
|
|
|
| 38 |
import '../../search/views/search_screen.dart';
|
| 39 |
import '../view_models/library_view_model.dart';
|
| 40 |
import 'card_tile.dart';
|
| 41 |
+
import 'library_dialogs.dart';
|
| 42 |
import 'grid_navigation.dart';
|
| 43 |
|
| 44 |
class LibraryScreen extends StatelessWidget {
|
|
|
|
| 313 |
child: SelectionActionBar(
|
| 314 |
selectedCount: vm.selectedCount,
|
| 315 |
onClose: () => context.read<LibraryViewModel>().clearSelection(),
|
| 316 |
+
onMoveToFolder: () =>
|
| 317 |
+
showFolderPicker(context, context.read<LibraryViewModel>()),
|
| 318 |
+
onDeleteSelected: () => confirmBulkDelete(context, vm),
|
|
|
|
|
|
|
|
|
|
| 319 |
),
|
| 320 |
),
|
| 321 |
],
|
| 322 |
);
|
| 323 |
}
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
Widget _body(BuildContext context, LibraryViewModel vm, dynamic api) {
|
| 326 |
switch (vm.status) {
|
| 327 |
case LibraryStatus.loading:
|
| 328 |
return const LoadingTiles();
|
| 329 |
+
case LibraryStatus.waking:
|
| 330 |
+
return _scrollable(
|
| 331 |
+
const EmptyState(
|
| 332 |
+
showGlyph: true,
|
| 333 |
+
title: 'Waking Cachy up…',
|
| 334 |
+
message: 'The free server naps when idle. First load takes about '
|
| 335 |
+
'30 seconds — your library is on its way.',
|
| 336 |
+
),
|
| 337 |
+
);
|
| 338 |
case LibraryStatus.error:
|
| 339 |
return _scrollable(
|
| 340 |
ErrorState(
|
app/lib/ui/features/onboarding/views/name_screen.dart
CHANGED
|
@@ -4,7 +4,6 @@
|
|
| 4 |
library;
|
| 5 |
|
| 6 |
import 'package:flutter/material.dart';
|
| 7 |
-
import 'package:google_fonts/google_fonts.dart';
|
| 8 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 9 |
import 'package:provider/provider.dart';
|
| 10 |
|
|
@@ -87,26 +86,13 @@ class _NameScreenState extends State<NameScreen> {
|
|
| 87 |
const SizedBox(height: 32),
|
| 88 |
RichText(
|
| 89 |
text: TextSpan(
|
| 90 |
-
style:
|
| 91 |
-
|
| 92 |
-
fontWeight: FontWeight.w800,
|
| 93 |
-
letterSpacing: -1.0,
|
| 94 |
-
height: 1.1,
|
| 95 |
-
color: scheme.onSurface,
|
| 96 |
-
),
|
| 97 |
children: [
|
| 98 |
const TextSpan(text: "What's\nyour "),
|
| 99 |
TextSpan(
|
| 100 |
text: 'name?',
|
| 101 |
-
style: TextStyle(
|
| 102 |
-
color: scheme.primary,
|
| 103 |
-
shadows: [
|
| 104 |
-
Shadow(
|
| 105 |
-
color: scheme.primary.withValues(alpha: 0.35),
|
| 106 |
-
blurRadius: 24,
|
| 107 |
-
),
|
| 108 |
-
],
|
| 109 |
-
),
|
| 110 |
),
|
| 111 |
],
|
| 112 |
),
|
|
|
|
| 4 |
library;
|
| 5 |
|
| 6 |
import 'package:flutter/material.dart';
|
|
|
|
| 7 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 8 |
import 'package:provider/provider.dart';
|
| 9 |
|
|
|
|
| 86 |
const SizedBox(height: 32),
|
| 87 |
RichText(
|
| 88 |
text: TextSpan(
|
| 89 |
+
style: theme.textTheme.displaySmall
|
| 90 |
+
?.copyWith(color: scheme.onSurface),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
children: [
|
| 92 |
const TextSpan(text: "What's\nyour "),
|
| 93 |
TextSpan(
|
| 94 |
text: 'name?',
|
| 95 |
+
style: TextStyle(color: scheme.primary),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
),
|
| 97 |
],
|
| 98 |
),
|
app/lib/ui/features/onboarding/views/onboarding_screen.dart
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
/// First-run onboarding:
|
| 2 |
-
///
|
| 3 |
-
/// floating capability badges, mock structured feature
|
|
|
|
| 4 |
library;
|
| 5 |
|
| 6 |
import 'package:flutter/material.dart';
|
| 7 |
-
import 'package:google_fonts/google_fonts.dart';
|
| 8 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 9 |
|
| 10 |
import '../../../core/brand.dart';
|
|
@@ -160,33 +160,8 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|
| 160 |
class _LogoBadge extends StatelessWidget {
|
| 161 |
@override
|
| 162 |
Widget build(BuildContext context) {
|
| 163 |
-
|
| 164 |
-
return
|
| 165 |
-
children: [
|
| 166 |
-
Container(
|
| 167 |
-
width: 34,
|
| 168 |
-
height: 34,
|
| 169 |
-
decoration: BoxDecoration(
|
| 170 |
-
color: scheme.primary,
|
| 171 |
-
borderRadius: BorderRadius.circular(10),
|
| 172 |
-
boxShadow: [
|
| 173 |
-
BoxShadow(
|
| 174 |
-
color: scheme.primary.withValues(alpha: 0.4),
|
| 175 |
-
blurRadius: 12,
|
| 176 |
-
offset: const Offset(0, 3),
|
| 177 |
-
),
|
| 178 |
-
],
|
| 179 |
-
),
|
| 180 |
-
child: PhosphorIcon(
|
| 181 |
-
PhosphorIconsRegular.lightning,
|
| 182 |
-
color: scheme.onPrimary,
|
| 183 |
-
size: 20,
|
| 184 |
-
),
|
| 185 |
-
),
|
| 186 |
-
const SizedBox(width: 10),
|
| 187 |
-
Text('Cachy', style: Brand.wordmarkStyle(20, color: scheme.onSurface)),
|
| 188 |
-
],
|
| 189 |
-
);
|
| 190 |
}
|
| 191 |
}
|
| 192 |
|
|
@@ -244,27 +219,10 @@ class _PageHook extends StatelessWidget {
|
|
| 244 |
RichText(
|
| 245 |
textAlign: TextAlign.center,
|
| 246 |
text: TextSpan(
|
| 247 |
-
style:
|
| 248 |
-
fontSize: 48,
|
| 249 |
-
fontWeight: FontWeight.w800,
|
| 250 |
-
letterSpacing: -1.2,
|
| 251 |
-
height: 1.1,
|
| 252 |
-
color: scheme.onSurface,
|
| 253 |
-
),
|
| 254 |
children: [
|
| 255 |
const TextSpan(text: 'Any Link,\n'),
|
| 256 |
-
TextSpan(
|
| 257 |
-
text: 'Captured.',
|
| 258 |
-
style: TextStyle(
|
| 259 |
-
color: scheme.primary,
|
| 260 |
-
shadows: [
|
| 261 |
-
Shadow(
|
| 262 |
-
color: scheme.primary.withValues(alpha: 0.35),
|
| 263 |
-
blurRadius: 24,
|
| 264 |
-
),
|
| 265 |
-
],
|
| 266 |
-
),
|
| 267 |
-
),
|
| 268 |
],
|
| 269 |
),
|
| 270 |
),
|
|
@@ -308,13 +266,7 @@ class _PageStructure extends StatelessWidget {
|
|
| 308 |
RichText(
|
| 309 |
textAlign: TextAlign.center,
|
| 310 |
text: TextSpan(
|
| 311 |
-
style:
|
| 312 |
-
fontSize: 40,
|
| 313 |
-
fontWeight: FontWeight.w800,
|
| 314 |
-
letterSpacing: -1,
|
| 315 |
-
height: 1.1,
|
| 316 |
-
color: scheme.onSurface,
|
| 317 |
-
),
|
| 318 |
children: [
|
| 319 |
const TextSpan(text: 'Every Source,\n'),
|
| 320 |
TextSpan(
|
|
@@ -488,13 +440,7 @@ class _PageLibrary extends StatelessWidget {
|
|
| 488 |
RichText(
|
| 489 |
textAlign: TextAlign.center,
|
| 490 |
text: TextSpan(
|
| 491 |
-
style:
|
| 492 |
-
fontSize: 40,
|
| 493 |
-
fontWeight: FontWeight.w800,
|
| 494 |
-
letterSpacing: -1,
|
| 495 |
-
height: 1.1,
|
| 496 |
-
color: scheme.onSurface,
|
| 497 |
-
),
|
| 498 |
children: [
|
| 499 |
const TextSpan(text: 'Personal Web\n'),
|
| 500 |
TextSpan(
|
|
|
|
| 1 |
+
/// First-run onboarding: a 3-page showcase — capture, structure, library — on
|
| 2 |
+
/// the brand's calm-editorial chrome (CachyGlyph wordmark, Fraunces serif
|
| 3 |
+
/// display via the theme), floating capability badges, mock structured feature
|
| 4 |
+
/// cards, and library vault previews.
|
| 5 |
library;
|
| 6 |
|
| 7 |
import 'package:flutter/material.dart';
|
|
|
|
| 8 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 9 |
|
| 10 |
import '../../../core/brand.dart';
|
|
|
|
| 160 |
class _LogoBadge extends StatelessWidget {
|
| 161 |
@override
|
| 162 |
Widget build(BuildContext context) {
|
| 163 |
+
// The real brand mark, not a generic lightning-in-a-box.
|
| 164 |
+
return const CachyWordmark(size: 20);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
}
|
| 167 |
|
|
|
|
| 219 |
RichText(
|
| 220 |
textAlign: TextAlign.center,
|
| 221 |
text: TextSpan(
|
| 222 |
+
style: theme.textTheme.displayMedium?.copyWith(color: scheme.onSurface),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
children: [
|
| 224 |
const TextSpan(text: 'Any Link,\n'),
|
| 225 |
+
TextSpan(text: 'Captured.', style: TextStyle(color: scheme.primary)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
],
|
| 227 |
),
|
| 228 |
),
|
|
|
|
| 266 |
RichText(
|
| 267 |
textAlign: TextAlign.center,
|
| 268 |
text: TextSpan(
|
| 269 |
+
style: theme.textTheme.displaySmall?.copyWith(color: scheme.onSurface),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
children: [
|
| 271 |
const TextSpan(text: 'Every Source,\n'),
|
| 272 |
TextSpan(
|
|
|
|
| 440 |
RichText(
|
| 441 |
textAlign: TextAlign.center,
|
| 442 |
text: TextSpan(
|
| 443 |
+
style: theme.textTheme.displaySmall?.copyWith(color: scheme.onSurface),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
children: [
|
| 445 |
const TextSpan(text: 'Personal Web\n'),
|
| 446 |
TextSpan(
|
app/lib/ui/features/onboarding/views/splash_screen.dart
CHANGED
|
@@ -31,7 +31,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|
| 31 |
);
|
| 32 |
_drop = CurvedAnimation(
|
| 33 |
parent: _c,
|
| 34 |
-
curve: const Interval(0.0, 0.55, curve: Curves.
|
| 35 |
);
|
| 36 |
_wordmark = CurvedAnimation(
|
| 37 |
parent: _c,
|
|
|
|
| 31 |
);
|
| 32 |
_drop = CurvedAnimation(
|
| 33 |
parent: _c,
|
| 34 |
+
curve: const Interval(0.0, 0.55, curve: Curves.easeOutCubic),
|
| 35 |
);
|
| 36 |
_wordmark = CurvedAnimation(
|
| 37 |
parent: _c,
|
app/lib/ui/features/profile/views/profile_screen.dart
CHANGED
|
@@ -249,9 +249,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|
| 249 |
),
|
| 250 |
);
|
| 251 |
if (ok == true && mounted) {
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
}
|
| 257 |
|
|
|
|
| 249 |
),
|
| 250 |
);
|
| 251 |
if (ok == true && mounted) {
|
| 252 |
+
final removed = await context.read<CardRepository>().clearCardCache();
|
| 253 |
+
if (mounted) {
|
| 254 |
+
ScaffoldMessenger.of(context).showSnackBar(
|
| 255 |
+
SnackBar(
|
| 256 |
+
content: Text(removed == 0
|
| 257 |
+
? 'Nothing cached yet'
|
| 258 |
+
: 'Cleared $removed offline ${removed == 1 ? 'card' : 'cards'}'),
|
| 259 |
+
),
|
| 260 |
+
);
|
| 261 |
+
}
|
| 262 |
}
|
| 263 |
}
|
| 264 |
|
app/lib/ui/features/reader/views/insight_section.dart
CHANGED
|
@@ -561,7 +561,7 @@ class _QuizCardState extends State<_QuizCard> {
|
|
| 561 |
),
|
| 562 |
),
|
| 563 |
],
|
| 564 |
-
).animate().fadeIn(duration: 300.ms).scaleXY(begin: 0.96, end: 1, curve: Curves.
|
| 565 |
}
|
| 566 |
}
|
| 567 |
|
|
|
|
| 561 |
),
|
| 562 |
),
|
| 563 |
],
|
| 564 |
+
).animate().fadeIn(duration: 300.ms).scaleXY(begin: 0.96, end: 1, curve: Curves.easeOutCubic);
|
| 565 |
}
|
| 566 |
}
|
| 567 |
|
app/lib/ui/features/reader/views/reader_screen.dart
CHANGED
|
@@ -764,11 +764,13 @@ class _PulsingDot extends StatelessWidget {
|
|
| 764 |
|
| 765 |
@override
|
| 766 |
Widget build(BuildContext context) {
|
| 767 |
-
|
| 768 |
width: 8,
|
| 769 |
height: 8,
|
| 770 |
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
| 771 |
-
)
|
|
|
|
|
|
|
| 772 |
.animate(onPlay: (c) => c.repeat(reverse: true))
|
| 773 |
.scaleXY(
|
| 774 |
begin: 0.5,
|
|
@@ -1321,14 +1323,16 @@ class _SkeletonBox extends StatelessWidget {
|
|
| 1321 |
@override
|
| 1322 |
Widget build(BuildContext context) {
|
| 1323 |
final scheme = Theme.of(context).colorScheme;
|
| 1324 |
-
|
| 1325 |
width: width,
|
| 1326 |
height: height,
|
| 1327 |
decoration: BoxDecoration(
|
| 1328 |
color: scheme.surfaceContainerHigh,
|
| 1329 |
borderRadius: BorderRadius.circular(radius),
|
| 1330 |
),
|
| 1331 |
-
)
|
|
|
|
|
|
|
| 1332 |
.animate(onPlay: (c) => c.repeat())
|
| 1333 |
.shimmer(
|
| 1334 |
duration: const Duration(milliseconds: 1200),
|
|
|
|
| 764 |
|
| 765 |
@override
|
| 766 |
Widget build(BuildContext context) {
|
| 767 |
+
final dot = Container(
|
| 768 |
width: 8,
|
| 769 |
height: 8,
|
| 770 |
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
| 771 |
+
);
|
| 772 |
+
if (!context.motionEnabled) return dot; // reduced motion: static dot
|
| 773 |
+
return dot
|
| 774 |
.animate(onPlay: (c) => c.repeat(reverse: true))
|
| 775 |
.scaleXY(
|
| 776 |
begin: 0.5,
|
|
|
|
| 1323 |
@override
|
| 1324 |
Widget build(BuildContext context) {
|
| 1325 |
final scheme = Theme.of(context).colorScheme;
|
| 1326 |
+
final box = Container(
|
| 1327 |
width: width,
|
| 1328 |
height: height,
|
| 1329 |
decoration: BoxDecoration(
|
| 1330 |
color: scheme.surfaceContainerHigh,
|
| 1331 |
borderRadius: BorderRadius.circular(radius),
|
| 1332 |
),
|
| 1333 |
+
);
|
| 1334 |
+
if (!context.motionEnabled) return box; // reduced motion: static block
|
| 1335 |
+
return box
|
| 1336 |
.animate(onPlay: (c) => c.repeat())
|
| 1337 |
.shimmer(
|
| 1338 |
duration: const Duration(milliseconds: 1200),
|
app/lib/ui/features/share/view_models/share_view_model.dart
CHANGED
|
@@ -64,11 +64,11 @@ class ShareViewModel extends ChangeNotifier {
|
|
| 64 |
} catch (e) {
|
| 65 |
if (e is ApiException) {
|
| 66 |
_status = ShareStatus.failed;
|
| 67 |
-
_failureReason = e.
|
| 68 |
} else {
|
| 69 |
// Network down: repository has queued the share for later.
|
| 70 |
_status = ShareStatus.queuedOffline;
|
| 71 |
-
_error =
|
| 72 |
}
|
| 73 |
notifyListeners();
|
| 74 |
return null;
|
|
@@ -92,7 +92,7 @@ class ShareViewModel extends ChangeNotifier {
|
|
| 92 |
_safeNotify();
|
| 93 |
},
|
| 94 |
onError: (e) {
|
| 95 |
-
_error =
|
| 96 |
_safeNotify();
|
| 97 |
},
|
| 98 |
cancelOnError: false,
|
|
|
|
| 64 |
} catch (e) {
|
| 65 |
if (e is ApiException) {
|
| 66 |
_status = ShareStatus.failed;
|
| 67 |
+
_failureReason = e.friendlyMessage;
|
| 68 |
} else {
|
| 69 |
// Network down: repository has queued the share for later.
|
| 70 |
_status = ShareStatus.queuedOffline;
|
| 71 |
+
_error = friendlyError(e);
|
| 72 |
}
|
| 73 |
notifyListeners();
|
| 74 |
return null;
|
|
|
|
| 92 |
_safeNotify();
|
| 93 |
},
|
| 94 |
onError: (e) {
|
| 95 |
+
_error = friendlyError(e);
|
| 96 |
_safeNotify();
|
| 97 |
},
|
| 98 |
cancelOnError: false,
|
app/lib/ui/features/share/views/share_screen.dart
CHANGED
|
@@ -133,10 +133,11 @@ class _ShareViewState extends State<_ShareView> {
|
|
| 133 |
children: [
|
| 134 |
const PhosphorIcon(PhosphorIconsRegular.cloudSlash, size: 48),
|
| 135 |
const SizedBox(height: 14),
|
| 136 |
-
Text('Saved
|
| 137 |
const SizedBox(height: 8),
|
| 138 |
const Text(
|
| 139 |
-
"We'll process this
|
|
|
|
| 140 |
textAlign: TextAlign.center,
|
| 141 |
),
|
| 142 |
const SizedBox(height: 20),
|
|
|
|
| 133 |
children: [
|
| 134 |
const PhosphorIcon(PhosphorIconsRegular.cloudSlash, size: 48),
|
| 135 |
const SizedBox(height: 14),
|
| 136 |
+
Text('Saved — will process shortly', style: theme.textTheme.titleLarge),
|
| 137 |
const SizedBox(height: 8),
|
| 138 |
const Text(
|
| 139 |
+
"We'll process this as soon as Cachy is reachable — the free "
|
| 140 |
+
'server can take ~30s to wake up.',
|
| 141 |
textAlign: TextAlign.center,
|
| 142 |
),
|
| 143 |
const SizedBox(height: 20),
|
app/test/api_exception_test.dart
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 2 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 3 |
+
|
| 4 |
+
void main() {
|
| 5 |
+
test('friendly messages never leak bodies or tracebacks', () {
|
| 6 |
+
expect(ApiException(500, '{"detail":"X","traceback":"Trace..."}').friendlyMessage,
|
| 7 |
+
'Something went wrong on our side. Try again in a moment.');
|
| 8 |
+
expect(ApiException(429, '{"error":"quota"}').friendlyMessage,
|
| 9 |
+
"You've hit today's limit. It resets at midnight UTC.");
|
| 10 |
+
expect(ApiException(401, 'x').friendlyMessage,
|
| 11 |
+
'Session expired — please sign in again.');
|
| 12 |
+
expect(ApiException(404, 'x').friendlyMessage,
|
| 13 |
+
"That card isn't there anymore.");
|
| 14 |
+
for (final code in [400, 401, 404, 429, 500, 503]) {
|
| 15 |
+
final msg = ApiException(code, 'traceback secret').friendlyMessage;
|
| 16 |
+
expect(msg.contains('traceback'), isFalse);
|
| 17 |
+
expect(msg.contains('secret'), isFalse);
|
| 18 |
+
}
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
test('friendlyError handles non-Api exceptions', () {
|
| 22 |
+
expect(friendlyError(Exception('SocketException: conn refused')),
|
| 23 |
+
"Can't reach Cachy. Check your connection.");
|
| 24 |
+
});
|
| 25 |
+
}
|
app/test/bulk_move_test.dart
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dart:convert';
|
| 2 |
+
|
| 3 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 4 |
+
import 'package:http/http.dart' as http;
|
| 5 |
+
import 'package:http/testing.dart';
|
| 6 |
+
import 'package:shared_preferences/shared_preferences.dart';
|
| 7 |
+
import 'package:cachy/data/repositories/card_repository.dart';
|
| 8 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 9 |
+
import 'package:cachy/data/services/local_store.dart';
|
| 10 |
+
import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
|
| 11 |
+
|
| 12 |
+
/// Builds a VM over a real repo whose MockClient serves the given cards and
|
| 13 |
+
/// records every move POST as cardId -> collection_id.
|
| 14 |
+
Future<(LibraryViewModel, Map<String, String?>)> _vm(List<String> ids) async {
|
| 15 |
+
SharedPreferences.setMockInitialValues({});
|
| 16 |
+
final store = await LocalStore.open();
|
| 17 |
+
final moves = <String, String?>{};
|
| 18 |
+
final mock = MockClient((req) async {
|
| 19 |
+
final path = req.url.path;
|
| 20 |
+
if (path == '/cards') {
|
| 21 |
+
return http.Response(
|
| 22 |
+
jsonEncode([for (final id in ids) {'card_id': id, 'state': 'ready'}]),
|
| 23 |
+
200,
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
final move = RegExp(r'^/collections/cards/(.+)/move$').firstMatch(path);
|
| 27 |
+
if (move != null) {
|
| 28 |
+
moves[move.group(1)!] = jsonDecode(req.body)['collection_id'] as String?;
|
| 29 |
+
return http.Response('{}', 200);
|
| 30 |
+
}
|
| 31 |
+
return http.Response('[]', 200);
|
| 32 |
+
});
|
| 33 |
+
final api = ApiClient(baseUrl: 'http://x', client: mock);
|
| 34 |
+
final repo = CardRepository(api: api, store: store);
|
| 35 |
+
return (LibraryViewModel(repository: repo), moves);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
void main() {
|
| 39 |
+
TestWidgetsFlutterBinding.ensureInitialized();
|
| 40 |
+
|
| 41 |
+
test('bulkMove moves every selected card and clears selection', () async {
|
| 42 |
+
final (vm, moves) = await _vm(['a', 'b', 'c']);
|
| 43 |
+
await vm.load();
|
| 44 |
+
vm.toggleSelection('a');
|
| 45 |
+
vm.toggleSelection('c');
|
| 46 |
+
expect(vm.selectionActive, isTrue);
|
| 47 |
+
|
| 48 |
+
await vm.bulkMove('folder-1');
|
| 49 |
+
|
| 50 |
+
expect(moves, {'a': 'folder-1', 'c': 'folder-1'});
|
| 51 |
+
expect(vm.selectionActive, isFalse);
|
| 52 |
+
});
|
| 53 |
+
}
|
app/test/library_wakeup_test.dart
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 2 |
+
import 'package:http/http.dart' as http;
|
| 3 |
+
import 'package:http/testing.dart';
|
| 4 |
+
import 'package:shared_preferences/shared_preferences.dart';
|
| 5 |
+
import 'package:cachy/data/repositories/card_repository.dart';
|
| 6 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 7 |
+
import 'package:cachy/data/services/local_store.dart';
|
| 8 |
+
import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
|
| 9 |
+
|
| 10 |
+
/// Real repository driven by a MockClient that "naps" (throws) a fixed number
|
| 11 |
+
/// of times before answering — exactly the sleeping-HF-Space cold start.
|
| 12 |
+
Future<LibraryViewModel> _vm(int naps, {int status = 200}) async {
|
| 13 |
+
SharedPreferences.setMockInitialValues({}); // empty cache => list() rethrows
|
| 14 |
+
final store = await LocalStore.open();
|
| 15 |
+
var calls = 0;
|
| 16 |
+
final mock = MockClient((req) async {
|
| 17 |
+
calls++;
|
| 18 |
+
if (calls <= naps) {
|
| 19 |
+
if (status == 200) throw http.ClientException('nap');
|
| 20 |
+
return http.Response('waking', status);
|
| 21 |
+
}
|
| 22 |
+
return http.Response('[]', 200);
|
| 23 |
+
});
|
| 24 |
+
final api = ApiClient(baseUrl: 'http://x', client: mock);
|
| 25 |
+
final repo = CardRepository(api: api, store: store);
|
| 26 |
+
return LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
void main() {
|
| 30 |
+
TestWidgetsFlutterBinding.ensureInitialized();
|
| 31 |
+
|
| 32 |
+
test('connection nap enters waking state, then recovers', () async {
|
| 33 |
+
final vm = await _vm(2);
|
| 34 |
+
final seen = <LibraryStatus>[];
|
| 35 |
+
vm.addListener(() => seen.add(vm.status));
|
| 36 |
+
await vm.load();
|
| 37 |
+
expect(seen, contains(LibraryStatus.waking));
|
| 38 |
+
expect(vm.status, LibraryStatus.empty);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
test('503 while waking is retried too', () async {
|
| 42 |
+
final vm = await _vm(2, status: 503);
|
| 43 |
+
await vm.load();
|
| 44 |
+
expect(vm.status, LibraryStatus.empty);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
test('persistent failure lands on error after max attempts', () async {
|
| 48 |
+
final vm = await _vm(99);
|
| 49 |
+
await vm.load();
|
| 50 |
+
expect(vm.status, LibraryStatus.error);
|
| 51 |
+
});
|
| 52 |
+
}
|
app/test/local_store_test.dart
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 2 |
+
import 'package:shared_preferences/shared_preferences.dart';
|
| 3 |
+
import 'package:cachy/data/services/local_store.dart';
|
| 4 |
+
|
| 5 |
+
void main() {
|
| 6 |
+
TestWidgetsFlutterBinding.ensureInitialized();
|
| 7 |
+
|
| 8 |
+
test('clearCardCache removes all cached cards and the index', () async {
|
| 9 |
+
SharedPreferences.setMockInitialValues({});
|
| 10 |
+
final store = await LocalStore.open();
|
| 11 |
+
await store.cacheCard('c1', {'id': 'c1'});
|
| 12 |
+
await store.cacheCard('c2', {'id': 'c2'});
|
| 13 |
+
expect(store.cachedCardIds(), hasLength(2));
|
| 14 |
+
|
| 15 |
+
final removed = await store.clearCardCache();
|
| 16 |
+
expect(removed, 2);
|
| 17 |
+
expect(store.cachedCardIds(), isEmpty);
|
| 18 |
+
expect(store.readCard('c1'), isNull);
|
| 19 |
+
});
|
| 20 |
+
}
|
app/test/reduced_motion_test.dart
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:flutter/material.dart';
|
| 2 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 3 |
+
import 'package:cachy/ui/core/theme.dart';
|
| 4 |
+
|
| 5 |
+
void main() {
|
| 6 |
+
testWidgets('motionEnabled follows MediaQuery.disableAnimations', (tester) async {
|
| 7 |
+
late bool enabled;
|
| 8 |
+
late Duration gated;
|
| 9 |
+
await tester.pumpWidget(MediaQuery(
|
| 10 |
+
data: const MediaQueryData(disableAnimations: true),
|
| 11 |
+
child: Builder(builder: (context) {
|
| 12 |
+
enabled = context.motionEnabled;
|
| 13 |
+
gated = context.gated(Motion.medium);
|
| 14 |
+
return const SizedBox();
|
| 15 |
+
}),
|
| 16 |
+
));
|
| 17 |
+
expect(enabled, isFalse);
|
| 18 |
+
expect(gated, Duration.zero);
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
testWidgets('motion on by default', (tester) async {
|
| 22 |
+
late bool enabled;
|
| 23 |
+
late Duration gated;
|
| 24 |
+
await tester.pumpWidget(MediaQuery(
|
| 25 |
+
data: const MediaQueryData(),
|
| 26 |
+
child: Builder(builder: (context) {
|
| 27 |
+
enabled = context.motionEnabled;
|
| 28 |
+
gated = context.gated(Motion.medium);
|
| 29 |
+
return const SizedBox();
|
| 30 |
+
}),
|
| 31 |
+
));
|
| 32 |
+
expect(enabled, isTrue);
|
| 33 |
+
expect(gated, Motion.medium);
|
| 34 |
+
});
|
| 35 |
+
}
|
app/web/index.html
CHANGED
|
@@ -69,7 +69,7 @@
|
|
| 69 |
stroke: #1C1917;
|
| 70 |
}
|
| 71 |
.glyph-reel-group {
|
| 72 |
-
animation: reelDrop 0.72s cubic-bezier(0.
|
| 73 |
}
|
| 74 |
@keyframes reelDrop {
|
| 75 |
0% { transform: translateY(-44px); }
|
|
|
|
| 69 |
stroke: #1C1917;
|
| 70 |
}
|
| 71 |
.glyph-reel-group {
|
| 72 |
+
animation: reelDrop 0.72s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
| 73 |
}
|
| 74 |
@keyframes reelDrop {
|
| 75 |
0% { transform: translateY(-44px); }
|
docs/planning/plans/2026-07-10-backend-auth-quotas.md
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Auth + Quotas + Hardening Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Replace the spoofable `x-owner-id` header with verified Firebase ID tokens, add per-user/per-IP daily quotas with degrade-to-fallback, and lock down admin/debug/error surfaces.
|
| 6 |
+
|
| 7 |
+
**Architecture:** A FastAPI dependency `get_owner` verifies the bearer token via `firebase_admin.auth.verify_id_token` and yields the uid; every route swaps its header param for this dependency. Quotas live in a new `usage` table keyed `(owner_id, day, kind)`; past-quota card creation flags the job `degraded` so the worker takes the existing paragraph-fallback path instead of failing. A temporary `/auth/claim` migrates legacy name-keyed rows.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** FastAPI, SQLAlchemy async + aiosqlite, firebase-admin, pytest + pytest-asyncio + httpx.
|
| 10 |
+
|
| 11 |
+
## Global Constraints
|
| 12 |
+
|
| 13 |
+
- Spec: `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`.
|
| 14 |
+
- Python style: type hints + docstrings on every function; explicit error handling (no `except: pass`); `pathlib.Path`; config only via `app.config.Settings` (pydantic-settings, `.env`).
|
| 15 |
+
- Quota defaults (env-overridable): cards 10/day, chat 30/day, connections-refresh 3/day, per-IP cards 30/day.
|
| 16 |
+
- `x-owner-id` header support is removed entirely; legacy name survives only as `/auth/claim`'s body parameter.
|
| 17 |
+
- Every external dependency optional / graceful degradation preserved: if `FIREBASE_PROJECT_ID` is unset, `get_owner` must raise 503 with "auth not configured" (never crash at import).
|
| 18 |
+
- Run tests with `cd backend && .venv/bin/pytest` after every task.
|
| 19 |
+
- Do not commit unless the user asked; each task's Commit step is conditional on that standing permission being granted at execution time.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
### Task 1: Restore the pytest harness
|
| 24 |
+
|
| 25 |
+
**Files:**
|
| 26 |
+
- Create: `backend/tests/__init__.py` (empty)
|
| 27 |
+
- Create: `backend/tests/conftest.py`
|
| 28 |
+
- Create: `backend/tests/test_health.py`
|
| 29 |
+
|
| 30 |
+
**Interfaces:**
|
| 31 |
+
- Produces: `client` async fixture (httpx.AsyncClient against the app, fresh in-memory-style temp SQLite per test) used by every later test.
|
| 32 |
+
|
| 33 |
+
- [ ] **Step 1: Write conftest**
|
| 34 |
+
|
| 35 |
+
```python
|
| 36 |
+
"""Shared fixtures: temp-file SQLite DB + httpx client bound to the app.
|
| 37 |
+
|
| 38 |
+
The worker loop is not started (tests drive functions directly); lifespan is
|
| 39 |
+
bypassed by calling db.init_db() ourselves against a temp database file.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import asyncio
|
| 45 |
+
from collections.abc import AsyncIterator
|
| 46 |
+
from pathlib import Path
|
| 47 |
+
|
| 48 |
+
import pytest
|
| 49 |
+
import pytest_asyncio
|
| 50 |
+
from httpx import ASGITransport, AsyncClient
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest_asyncio.fixture
|
| 54 |
+
async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
|
| 55 |
+
"""App-bound HTTP client with an isolated database per test."""
|
| 56 |
+
db_path = tmp_path / "test.db"
|
| 57 |
+
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
|
| 58 |
+
# Settings and the engine are cached at import; reset both.
|
| 59 |
+
from app import config
|
| 60 |
+
config.get_settings.cache_clear()
|
| 61 |
+
from app.store import db as store_db
|
| 62 |
+
await store_db.dispose_db()
|
| 63 |
+
store_db.reset_engine() # added in Step 2 if absent
|
| 64 |
+
await store_db.init_db()
|
| 65 |
+
|
| 66 |
+
from app.main import app
|
| 67 |
+
transport = ASGITransport(app=app)
|
| 68 |
+
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
| 69 |
+
yield c
|
| 70 |
+
await store_db.dispose_db()
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
- [ ] **Step 2: Ensure `reset_engine` exists in `backend/app/store/db.py`**
|
| 74 |
+
|
| 75 |
+
Read `db.py`'s engine setup. If the engine/sessionmaker are module-level singletons built from `get_settings().database_url` at import, add:
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
def reset_engine() -> None:
|
| 79 |
+
"""Rebuild the engine/sessionmaker from current settings (tests swap DATABASE_URL)."""
|
| 80 |
+
global _engine, _session_factory
|
| 81 |
+
_engine = create_async_engine(get_settings().database_url, future=True)
|
| 82 |
+
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Match the actual variable names used in `db.py` (`_engine` / `_session_factory` may differ — mirror what `dispose_db()` touches).
|
| 86 |
+
|
| 87 |
+
- [ ] **Step 3: Write the smoke test**
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
"""Harness smoke test: the app answers /health on an isolated DB."""
|
| 91 |
+
|
| 92 |
+
import pytest
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_health(client) -> None:
|
| 97 |
+
resp = await client.get("/health")
|
| 98 |
+
assert resp.status_code == 200
|
| 99 |
+
assert resp.json()["status"] == "ok"
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
- [ ] **Step 4: Run**
|
| 103 |
+
|
| 104 |
+
Run: `cd backend && .venv/bin/pytest tests/test_health.py -v` — Expected: PASS. If `pytest-asyncio` mode errors appear, add to `backend/pyproject.toml` under `[tool.pytest.ini_options]`: `asyncio_mode = "auto"` (then the `@pytest.mark.asyncio` markers are optional but harmless).
|
| 105 |
+
|
| 106 |
+
- [ ] **Step 5: Commit** — `test: restore pytest harness with isolated per-test DB`
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
### Task 2: `get_owner` — Firebase token verification dependency
|
| 111 |
+
|
| 112 |
+
**Files:**
|
| 113 |
+
- Modify: `backend/pyproject.toml` (add `"firebase-admin>=6.5"` to dependencies)
|
| 114 |
+
- Modify: `backend/app/config.py` (add `firebase_project_id: str = ""`)
|
| 115 |
+
- Create: `backend/app/auth.py`
|
| 116 |
+
- Create: `backend/tests/test_auth.py`
|
| 117 |
+
|
| 118 |
+
**Interfaces:**
|
| 119 |
+
- Produces: `async def get_owner(authorization: str | None = Header(None)) -> str` — FastAPI dependency returning the verified uid; raises HTTPException 401 (missing/invalid token) or 503 (auth unconfigured). Also `OwnerDep = Annotated[str, Depends(get_owner)]` for route signatures.
|
| 120 |
+
|
| 121 |
+
- [ ] **Step 1: Write failing tests**
|
| 122 |
+
|
| 123 |
+
```python
|
| 124 |
+
"""get_owner: valid token -> uid; garbage/missing -> 401; unconfigured -> 503."""
|
| 125 |
+
|
| 126 |
+
from unittest.mock import patch
|
| 127 |
+
|
| 128 |
+
import pytest
|
| 129 |
+
from fastapi import HTTPException
|
| 130 |
+
|
| 131 |
+
from app.auth import get_owner
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@pytest.mark.asyncio
|
| 135 |
+
async def test_missing_header_401() -> None:
|
| 136 |
+
with pytest.raises(HTTPException) as exc:
|
| 137 |
+
await get_owner(authorization=None)
|
| 138 |
+
assert exc.value.status_code == 401
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@pytest.mark.asyncio
|
| 142 |
+
async def test_valid_token_returns_uid(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 143 |
+
monkeypatch.setenv("FIREBASE_PROJECT_ID", "demo-project")
|
| 144 |
+
from app import config
|
| 145 |
+
config.get_settings.cache_clear()
|
| 146 |
+
with patch("app.auth._verify", return_value={"uid": "user-123"}):
|
| 147 |
+
uid = await get_owner(authorization="Bearer sometoken")
|
| 148 |
+
assert uid == "user-123"
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@pytest.mark.asyncio
|
| 152 |
+
async def test_invalid_token_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 153 |
+
monkeypatch.setenv("FIREBASE_PROJECT_ID", "demo-project")
|
| 154 |
+
from app import config
|
| 155 |
+
config.get_settings.cache_clear()
|
| 156 |
+
with patch("app.auth._verify", side_effect=ValueError("bad token")):
|
| 157 |
+
with pytest.raises(HTTPException) as exc:
|
| 158 |
+
await get_owner(authorization="Bearer garbage")
|
| 159 |
+
assert exc.value.status_code == 401
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@pytest.mark.asyncio
|
| 163 |
+
async def test_unconfigured_503(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 164 |
+
monkeypatch.delenv("FIREBASE_PROJECT_ID", raising=False)
|
| 165 |
+
from app import config
|
| 166 |
+
config.get_settings.cache_clear()
|
| 167 |
+
with pytest.raises(HTTPException) as exc:
|
| 168 |
+
await get_owner(authorization="Bearer sometoken")
|
| 169 |
+
assert exc.value.status_code == 503
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
- [ ] **Step 2: Run to verify failure** — `cd backend && .venv/bin/pytest tests/test_auth.py -v` — Expected: FAIL, `ModuleNotFoundError: app.auth`.
|
| 173 |
+
|
| 174 |
+
- [ ] **Step 3: Implement `backend/app/auth.py`**
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
"""Verified identity: Firebase ID token -> uid.
|
| 178 |
+
|
| 179 |
+
The client sends `Authorization: Bearer <ID token>`; we verify the signature
|
| 180 |
+
against Google's public certs (firebase-admin handles fetching/rotation).
|
| 181 |
+
No service-account secret is needed for verification — only the project id.
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
from __future__ import annotations
|
| 185 |
+
|
| 186 |
+
import logging
|
| 187 |
+
from typing import Annotated
|
| 188 |
+
|
| 189 |
+
from fastapi import Depends, Header, HTTPException
|
| 190 |
+
|
| 191 |
+
from app.config import get_settings
|
| 192 |
+
|
| 193 |
+
log = logging.getLogger("app.auth")
|
| 194 |
+
|
| 195 |
+
_initialized = False
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _verify(token: str) -> dict:
|
| 199 |
+
"""Verify a Firebase ID token, initializing the SDK lazily (once)."""
|
| 200 |
+
global _initialized
|
| 201 |
+
import firebase_admin
|
| 202 |
+
from firebase_admin import auth as fb_auth
|
| 203 |
+
|
| 204 |
+
if not _initialized:
|
| 205 |
+
firebase_admin.initialize_app(
|
| 206 |
+
options={"projectId": get_settings().firebase_project_id}
|
| 207 |
+
)
|
| 208 |
+
_initialized = True
|
| 209 |
+
return fb_auth.verify_id_token(token)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def get_owner(authorization: str | None = Header(None)) -> str:
|
| 213 |
+
"""FastAPI dependency: the verified Firebase uid of the caller."""
|
| 214 |
+
if not get_settings().firebase_project_id:
|
| 215 |
+
raise HTTPException(status_code=503, detail="auth not configured")
|
| 216 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 217 |
+
raise HTTPException(status_code=401, detail="missing bearer token")
|
| 218 |
+
token = authorization.removeprefix("Bearer ").strip()
|
| 219 |
+
try:
|
| 220 |
+
decoded = _verify(token)
|
| 221 |
+
except Exception as exc: # firebase raises several exc types; all mean 401
|
| 222 |
+
log.info("token verification failed: %s: %s", type(exc).__name__, exc)
|
| 223 |
+
raise HTTPException(status_code=401, detail="invalid or expired token")
|
| 224 |
+
return str(decoded["uid"])
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
OwnerDep = Annotated[str, Depends(get_owner)]
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
Add to `backend/app/config.py` inside `Settings` (near the storage block):
|
| 231 |
+
|
| 232 |
+
```python
|
| 233 |
+
# auth — Firebase project id; token verification needs no secret.
|
| 234 |
+
firebase_project_id: str = ""
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
Add `"firebase-admin>=6.5",` to `dependencies` in `backend/pyproject.toml`, then `cd backend && .venv/bin/pip install -e .`.
|
| 238 |
+
|
| 239 |
+
- [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_auth.py -v` — Expected: 4 PASS.
|
| 240 |
+
|
| 241 |
+
- [ ] **Step 5: Commit** — `feat: verified Firebase identity dependency (get_owner)`
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
### Task 3: Swap every `x_owner_id` header for `OwnerDep`
|
| 246 |
+
|
| 247 |
+
**Files:**
|
| 248 |
+
- Modify: `backend/app/api/cards.py`, `catalog.py`, `collections.py`, `concepts.py`, `connections.py`, `feed.py`, `graph.py`, `library_chat.py`, `search.py` (≈56 sites)
|
| 249 |
+
- Create: `backend/tests/test_auth_routes.py`
|
| 250 |
+
|
| 251 |
+
**Interfaces:**
|
| 252 |
+
- Consumes: `OwnerDep` from Task 2.
|
| 253 |
+
- Produces: every data route requires auth; tests override `get_owner` via `app.dependency_overrides` (this is also the pattern all later route tests use).
|
| 254 |
+
|
| 255 |
+
- [ ] **Step 1: Write failing tests**
|
| 256 |
+
|
| 257 |
+
```python
|
| 258 |
+
"""Routes reject anonymous callers and scope rows to the verified uid."""
|
| 259 |
+
|
| 260 |
+
import pytest
|
| 261 |
+
|
| 262 |
+
from app.auth import get_owner
|
| 263 |
+
from app.main import app
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@pytest.mark.asyncio
|
| 267 |
+
async def test_cards_requires_auth(client) -> None:
|
| 268 |
+
resp = await client.get("/cards")
|
| 269 |
+
# 401 (bad token) or 503 (auth unconfigured in test env) — never 200.
|
| 270 |
+
assert resp.status_code in (401, 503)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@pytest.mark.asyncio
|
| 274 |
+
async def test_owner_scoping(client) -> None:
|
| 275 |
+
app.dependency_overrides[get_owner] = lambda: "uid-a"
|
| 276 |
+
try:
|
| 277 |
+
created = await client.post("/cards", json={"url": "https://example.com/a"})
|
| 278 |
+
assert created.status_code in (200, 201)
|
| 279 |
+
mine = await client.get("/cards")
|
| 280 |
+
assert mine.status_code == 200
|
| 281 |
+
|
| 282 |
+
app.dependency_overrides[get_owner] = lambda: "uid-b"
|
| 283 |
+
theirs = await client.get("/cards")
|
| 284 |
+
assert theirs.status_code == 200
|
| 285 |
+
assert theirs.json() == [] # uid-b sees nothing of uid-a's
|
| 286 |
+
finally:
|
| 287 |
+
app.dependency_overrides.clear()
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
- [ ] **Step 2: Run to verify failure** — `cd backend && .venv/bin/pytest tests/test_auth_routes.py -v` — Expected: `test_cards_requires_auth` FAILS (anonymous GET /cards currently returns 200).
|
| 291 |
+
|
| 292 |
+
- [ ] **Step 3: Mechanical swap in all 9 API modules**
|
| 293 |
+
|
| 294 |
+
In each file: add `from app.auth import OwnerDep`; replace every parameter
|
| 295 |
+
`x_owner_id: Annotated[str | None, Header()] = None` with `owner_id: OwnerDep`
|
| 296 |
+
and every body use of `x_owner_id` with `owner_id`. Delete now-unused `Header` imports. Where routes special-cased `owner_id is None` (e.g. `cards.py:241`), the branch is dead — owner is always set now; remove the conditional and always filter by owner. Grep afterwards: `grep -rn "x_owner_id" backend/app` must return nothing.
|
| 297 |
+
|
| 298 |
+
The SSE stream route (`/cards/{id}/stream`) gets the same `OwnerDep`.
|
| 299 |
+
|
| 300 |
+
- [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS.
|
| 301 |
+
|
| 302 |
+
- [ ] **Step 5: Commit** — `feat: all data routes require verified owner identity`
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
### Task 4: Usage table + quota dependency
|
| 307 |
+
|
| 308 |
+
**Files:**
|
| 309 |
+
- Modify: `backend/app/store/db.py` (UsageRow + helpers; add `degraded` to JobRow; add both columns to the lightweight migration map near line 383)
|
| 310 |
+
- Modify: `backend/app/config.py` (quota settings)
|
| 311 |
+
- Create: `backend/app/quota.py`
|
| 312 |
+
- Create: `backend/tests/test_quota.py`
|
| 313 |
+
|
| 314 |
+
**Interfaces:**
|
| 315 |
+
- Produces:
|
| 316 |
+
- `db.UsageRow` (`owner_id: str, day: str, kind: str, count: int`, PK = first three)
|
| 317 |
+
- `async def db.spend_usage(session, *, owner_id: str, kind: str, limit: int) -> tuple[bool, int]` — atomically increments and returns `(allowed, used_after)`; increments even when over limit is NOT performed (count stays at limit).
|
| 318 |
+
- `quota.spend(kind: str, limit_attr: str)` — dependency factory raising 429 `{"error": "quota", "kind": ..., "used": ..., "limit": ..., "resets_at": ...}`.
|
| 319 |
+
- `quota.card_budget(owner_id, request, session) -> bool` — non-raising check for card creation (True = within quota, False = degrade), which also enforces the per-IP cap (raising 429 only for the IP cap).
|
| 320 |
+
- `JobRow.degraded: bool` column.
|
| 321 |
+
|
| 322 |
+
- [ ] **Step 1: Write failing tests**
|
| 323 |
+
|
| 324 |
+
```python
|
| 325 |
+
"""Quota accounting: daily counters, limits, per-IP cap, UTC rollover."""
|
| 326 |
+
|
| 327 |
+
import pytest
|
| 328 |
+
|
| 329 |
+
from app.store import db
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@pytest.mark.asyncio
|
| 333 |
+
async def test_spend_usage_counts_and_caps(client) -> None: # client fixture builds the DB
|
| 334 |
+
async with db.session() as s:
|
| 335 |
+
for i in range(3):
|
| 336 |
+
allowed, used = await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
|
| 337 |
+
assert allowed and used == i + 1
|
| 338 |
+
allowed, used = await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
|
| 339 |
+
assert not allowed and used == 3
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@pytest.mark.asyncio
|
| 343 |
+
async def test_usage_is_per_owner_and_per_kind(client) -> None:
|
| 344 |
+
async with db.session() as s:
|
| 345 |
+
await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
|
| 346 |
+
allowed, used = await db.spend_usage(s, owner_id="u2", kind="chat", limit=3)
|
| 347 |
+
assert allowed and used == 1
|
| 348 |
+
allowed, used = await db.spend_usage(s, owner_id="u1", kind="cards", limit=3)
|
| 349 |
+
assert allowed and used == 1
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `spend_usage` not defined.
|
| 353 |
+
|
| 354 |
+
- [ ] **Step 3: Implement**
|
| 355 |
+
|
| 356 |
+
`db.py` additions:
|
| 357 |
+
|
| 358 |
+
```python
|
| 359 |
+
class UsageRow(Base):
|
| 360 |
+
"""Daily metered usage. One row per (owner, UTC day, kind); owner_id also
|
| 361 |
+
stores "ip:<addr>" rows for the anonymous-farming IP cap."""
|
| 362 |
+
|
| 363 |
+
__tablename__ = "usage"
|
| 364 |
+
|
| 365 |
+
owner_id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 366 |
+
day: Mapped[str] = mapped_column(String, primary_key=True) # "YYYY-MM-DD" UTC
|
| 367 |
+
kind: Mapped[str] = mapped_column(String, primary_key=True)
|
| 368 |
+
count: Mapped[int] = mapped_column(Integer, default=0)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _today() -> str:
|
| 372 |
+
"""Current UTC day key."""
|
| 373 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
async def spend_usage(
|
| 377 |
+
db_session: AsyncSession, *, owner_id: str, kind: str, limit: int
|
| 378 |
+
) -> tuple[bool, int]:
|
| 379 |
+
"""Increment today's counter unless at limit. Returns (allowed, used_after)."""
|
| 380 |
+
day = _today()
|
| 381 |
+
row = await db_session.get(UsageRow, (owner_id, day, kind))
|
| 382 |
+
if row is None:
|
| 383 |
+
row = UsageRow(owner_id=owner_id, day=day, kind=kind, count=0)
|
| 384 |
+
db_session.add(row)
|
| 385 |
+
if row.count >= limit:
|
| 386 |
+
return False, row.count
|
| 387 |
+
row.count += 1
|
| 388 |
+
await db_session.commit()
|
| 389 |
+
return True, row.count
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
`JobRow` gains `degraded: Mapped[bool] = mapped_column(Boolean, default=False)`; add `"degraded": "BOOLEAN DEFAULT 0"` to the jobs entry of the additive-migration map (the dict near db.py:383 that backfills missing columns).
|
| 393 |
+
|
| 394 |
+
`config.py` additions:
|
| 395 |
+
|
| 396 |
+
```python
|
| 397 |
+
# quotas (per UTC day)
|
| 398 |
+
quota_cards_per_day: int = 10
|
| 399 |
+
quota_chat_per_day: int = 30
|
| 400 |
+
quota_connections_refresh_per_day: int = 3
|
| 401 |
+
quota_ip_cards_per_day: int = 30
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
`backend/app/quota.py`:
|
| 405 |
+
|
| 406 |
+
```python
|
| 407 |
+
"""Per-user daily quotas. Chat-style routes raise 429; card creation degrades
|
| 408 |
+
instead (see cards.py) so a save never hard-fails."""
|
| 409 |
+
|
| 410 |
+
from __future__ import annotations
|
| 411 |
+
|
| 412 |
+
from datetime import datetime, time, timezone
|
| 413 |
+
|
| 414 |
+
from fastapi import Depends, HTTPException, Request
|
| 415 |
+
|
| 416 |
+
from app.auth import get_owner
|
| 417 |
+
from app.config import get_settings
|
| 418 |
+
from app.store import db
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _resets_at() -> str:
|
| 422 |
+
"""ISO timestamp of the next UTC midnight (quota reset)."""
|
| 423 |
+
now = datetime.now(timezone.utc)
|
| 424 |
+
tomorrow = datetime.combine(now.date(), time.min, tzinfo=timezone.utc)
|
| 425 |
+
return tomorrow.replace(day=now.day).isoformat() # replaced in Step 3b
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def spend(kind: str, limit_attr: str):
|
| 429 |
+
"""Dependency factory: spend one unit of `kind` or raise 429."""
|
| 430 |
+
|
| 431 |
+
async def _dep(owner_id: str = Depends(get_owner)) -> str:
|
| 432 |
+
limit = getattr(get_settings(), limit_attr)
|
| 433 |
+
async with db.session() as s:
|
| 434 |
+
allowed, used = await db.spend_usage(
|
| 435 |
+
s, owner_id=owner_id, kind=kind, limit=limit
|
| 436 |
+
)
|
| 437 |
+
if not allowed:
|
| 438 |
+
raise HTTPException(
|
| 439 |
+
status_code=429,
|
| 440 |
+
detail={
|
| 441 |
+
"error": "quota", "kind": kind,
|
| 442 |
+
"used": used, "limit": limit, "resets_at": _resets_at(),
|
| 443 |
+
},
|
| 444 |
+
)
|
| 445 |
+
return owner_id
|
| 446 |
+
|
| 447 |
+
return _dep
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
async def card_budget(owner_id: str, request: Request) -> bool:
|
| 451 |
+
"""Card-creation budget. Enforces the per-IP cap (429) and returns whether
|
| 452 |
+
the owner still has AI budget today (False -> degrade, never fail)."""
|
| 453 |
+
settings = get_settings()
|
| 454 |
+
ip = request.client.host if request.client else "unknown"
|
| 455 |
+
async with db.session() as s:
|
| 456 |
+
ip_ok, _ = await db.spend_usage(
|
| 457 |
+
s, owner_id=f"ip:{ip}", kind="cards", limit=settings.quota_ip_cards_per_day
|
| 458 |
+
)
|
| 459 |
+
if not ip_ok:
|
| 460 |
+
raise HTTPException(status_code=429, detail={
|
| 461 |
+
"error": "quota", "kind": "ip", "limit": settings.quota_ip_cards_per_day,
|
| 462 |
+
"used": settings.quota_ip_cards_per_day, "resets_at": _resets_at(),
|
| 463 |
+
})
|
| 464 |
+
allowed, _ = await db.spend_usage(
|
| 465 |
+
s, owner_id=owner_id, kind="cards", limit=settings.quota_cards_per_day
|
| 466 |
+
)
|
| 467 |
+
return allowed
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
- [ ] **Step 3b: Fix `_resets_at`** — the naive `.replace(day=...)` is wrong at month end. Use:
|
| 471 |
+
|
| 472 |
+
```python
|
| 473 |
+
from datetime import timedelta
|
| 474 |
+
|
| 475 |
+
def _resets_at() -> str:
|
| 476 |
+
now = datetime.now(timezone.utc)
|
| 477 |
+
tomorrow = datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=timezone.utc)
|
| 478 |
+
return tomorrow.isoformat()
|
| 479 |
+
```
|
| 480 |
+
|
| 481 |
+
- [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_quota.py -v` — Expected: PASS.
|
| 482 |
+
|
| 483 |
+
- [ ] **Step 5: Commit** — `feat: usage table + daily quota accounting`
|
| 484 |
+
|
| 485 |
+
---
|
| 486 |
+
|
| 487 |
+
### Task 5: Wire quotas into routes; degrade card creation
|
| 488 |
+
|
| 489 |
+
**Files:**
|
| 490 |
+
- Modify: `backend/app/api/cards.py` (create route + chat + rabbithole), `backend/app/api/library_chat.py`, `backend/app/api/connections.py`
|
| 491 |
+
- Modify: `backend/app/pipeline/worker.py` + `backend/app/pipeline/structuring.py` (degraded path)
|
| 492 |
+
- Create: `backend/app/api/me.py` (`GET /me/quota`), register in `main.py`
|
| 493 |
+
- Create: `backend/tests/test_quota_routes.py`
|
| 494 |
+
|
| 495 |
+
**Interfaces:**
|
| 496 |
+
- Consumes: `quota.spend`, `quota.card_budget`, `JobRow.degraded` from Task 4.
|
| 497 |
+
- Produces: `structure(bundle, transcript, caption, force_fallback: bool = False)` — when True returns `_paragraph_fallback(...)` immediately, with `degraded=True, degraded_reason="quota"`. `GET /me/quota` → `{"cards": {"used": n, "limit": n}, "chat": {...}, "resets_at": iso}`.
|
| 498 |
+
|
| 499 |
+
- [ ] **Step 1: Write failing tests**
|
| 500 |
+
|
| 501 |
+
```python
|
| 502 |
+
"""Quota wiring: chat 429s past limit; card creation degrades; /me/quota reports."""
|
| 503 |
+
|
| 504 |
+
import pytest
|
| 505 |
+
|
| 506 |
+
from app.auth import get_owner
|
| 507 |
+
from app.main import app
|
| 508 |
+
from app.store import db
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
@pytest.fixture(autouse=True)
|
| 512 |
+
def _small_limits(monkeypatch):
|
| 513 |
+
monkeypatch.setenv("QUOTA_CARDS_PER_DAY", "1")
|
| 514 |
+
monkeypatch.setenv("QUOTA_CHAT_PER_DAY", "1")
|
| 515 |
+
from app import config
|
| 516 |
+
config.get_settings.cache_clear()
|
| 517 |
+
yield
|
| 518 |
+
config.get_settings.cache_clear()
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
@pytest.fixture
|
| 522 |
+
def as_user():
|
| 523 |
+
app.dependency_overrides[get_owner] = lambda: "uid-q"
|
| 524 |
+
yield
|
| 525 |
+
app.dependency_overrides.clear()
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
@pytest.mark.asyncio
|
| 529 |
+
async def test_card_creation_degrades_past_quota(client, as_user) -> None:
|
| 530 |
+
r1 = await client.post("/cards", json={"url": "https://example.com/1"})
|
| 531 |
+
assert r1.status_code in (200, 201)
|
| 532 |
+
r2 = await client.post("/cards", json={"url": "https://example.com/2"})
|
| 533 |
+
assert r2.status_code in (200, 201) # degrade, never fail
|
| 534 |
+
async with db.session() as s:
|
| 535 |
+
jobs = (await s.execute(db.select(db.JobRow).order_by(db.JobRow.created_at))).scalars().all()
|
| 536 |
+
assert [j.degraded for j in jobs] == [False, True]
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@pytest.mark.asyncio
|
| 540 |
+
async def test_me_quota(client, as_user) -> None:
|
| 541 |
+
resp = await client.get("/me/quota")
|
| 542 |
+
assert resp.status_code == 200
|
| 543 |
+
body = resp.json()
|
| 544 |
+
assert body["cards"]["limit"] == 1 and "resets_at" in body
|
| 545 |
+
```
|
| 546 |
+
|
| 547 |
+
(If `db.select` isn't re-exported, import `select` from sqlalchemy in the test.)
|
| 548 |
+
|
| 549 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL (`degraded` never True; /me/quota 404).
|
| 550 |
+
|
| 551 |
+
- [ ] **Step 3: Implement**
|
| 552 |
+
|
| 553 |
+
`cards.py` create route: add `request: Request` param; after resolving the cache-miss path and before creating the JobRow, call `within = await quota.card_budget(owner_id, request)`; create the job with `degraded=not within`; include `"quota_degraded": not within` in the response body.
|
| 554 |
+
|
| 555 |
+
Chat routes: append `dependencies=[Depends(quota.spend("chat", "quota_chat_per_day"))]` to the route decorators of card chat, rabbithole (in `cards.py`) and library chat (`library_chat.py`). Connections refresh: inside the handler, only when `refresh=True`, call the spend dependency logic directly:
|
| 556 |
+
|
| 557 |
+
```python
|
| 558 |
+
if refresh:
|
| 559 |
+
await quota.spend("connections_refresh", "quota_connections_refresh_per_day")(owner_id)
|
| 560 |
+
```
|
| 561 |
+
|
| 562 |
+
`structuring.py`: change signature to `def structure(bundle: str, transcript: str = "", caption: str = "", force_fallback: bool = False) -> "StructuredCard":` and as the first statement:
|
| 563 |
+
|
| 564 |
+
```python
|
| 565 |
+
if force_fallback:
|
| 566 |
+
log.info("structuring: quota-degraded card -> paragraph fallback")
|
| 567 |
+
return _paragraph_fallback(bundle, transcript, caption, reason="quota")
|
| 568 |
+
```
|
| 569 |
+
|
| 570 |
+
Match `_paragraph_fallback`'s real signature (read it first; pass whatever it actually takes, setting `degraded_reason="quota"` however the non-forced fallback path does).
|
| 571 |
+
|
| 572 |
+
`worker.py` `_run_job`: where `structure(...)` is called, pass `force_fallback=job.degraded`.
|
| 573 |
+
|
| 574 |
+
`me.py`:
|
| 575 |
+
|
| 576 |
+
```python
|
| 577 |
+
"""The caller's own quota status — powers the profile meter."""
|
| 578 |
+
|
| 579 |
+
from __future__ import annotations
|
| 580 |
+
|
| 581 |
+
from fastapi import APIRouter
|
| 582 |
+
|
| 583 |
+
from app.auth import OwnerDep
|
| 584 |
+
from app.config import get_settings
|
| 585 |
+
from app.quota import _resets_at
|
| 586 |
+
from app.store import db
|
| 587 |
+
|
| 588 |
+
router = APIRouter(prefix="/me", tags=["me"])
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
@router.get("/quota")
|
| 592 |
+
async def my_quota(owner_id: OwnerDep) -> dict:
|
| 593 |
+
"""Today's used/limit per metered kind."""
|
| 594 |
+
settings = get_settings()
|
| 595 |
+
day = db._today()
|
| 596 |
+
out: dict = {"resets_at": _resets_at()}
|
| 597 |
+
async with db.session() as s:
|
| 598 |
+
for kind, limit in (
|
| 599 |
+
("cards", settings.quota_cards_per_day),
|
| 600 |
+
("chat", settings.quota_chat_per_day),
|
| 601 |
+
):
|
| 602 |
+
row = await s.get(db.UsageRow, (owner_id, day, kind))
|
| 603 |
+
out[kind] = {"used": row.count if row else 0, "limit": limit}
|
| 604 |
+
return out
|
| 605 |
+
```
|
| 606 |
+
|
| 607 |
+
Register in `main.py`: `from app.api import me` + `app.include_router(me.router)`.
|
| 608 |
+
|
| 609 |
+
- [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS.
|
| 610 |
+
|
| 611 |
+
- [ ] **Step 5: Commit** — `feat: quotas wired — chat 429s, cards degrade, /me/quota`
|
| 612 |
+
|
| 613 |
+
---
|
| 614 |
+
|
| 615 |
+
### Task 6: `/auth/claim` — legacy name migration
|
| 616 |
+
|
| 617 |
+
**Files:**
|
| 618 |
+
- Modify: `backend/app/store/db.py` (ClaimRow + `claim_owner` helper)
|
| 619 |
+
- Create: `backend/app/api/auth_routes.py`, register in `main.py`
|
| 620 |
+
- Create: `backend/tests/test_claim.py`
|
| 621 |
+
|
| 622 |
+
**Interfaces:**
|
| 623 |
+
- Produces: `POST /auth/claim {"name": str}` → 200 `{"claimed": <row count>}` or 409 if the name was already claimed. `db.claim_owner(session, *, name: str, uid: str) -> int | None` (None = already claimed by someone else).
|
| 624 |
+
|
| 625 |
+
- [ ] **Step 1: Write failing tests**
|
| 626 |
+
|
| 627 |
+
```python
|
| 628 |
+
"""First-claim-wins migration of legacy name-keyed rows."""
|
| 629 |
+
|
| 630 |
+
import pytest
|
| 631 |
+
|
| 632 |
+
from app.auth import get_owner
|
| 633 |
+
from app.main import app
|
| 634 |
+
from app.store import db
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
async def _seed_legacy_card(name: str) -> None:
|
| 638 |
+
async with db.session() as s:
|
| 639 |
+
s.add(db.CardRow(owner_id=name, url=f"https://example.com/{name}", state="ready"))
|
| 640 |
+
await s.commit()
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
@pytest.mark.asyncio
|
| 644 |
+
async def test_claim_repoints_rows(client) -> None:
|
| 645 |
+
await _seed_legacy_card("Vatsal")
|
| 646 |
+
app.dependency_overrides[get_owner] = lambda: "uid-new"
|
| 647 |
+
try:
|
| 648 |
+
resp = await client.post("/auth/claim", json={"name": "Vatsal"})
|
| 649 |
+
assert resp.status_code == 200 and resp.json()["claimed"] >= 1
|
| 650 |
+
again = await client.post("/auth/claim", json={"name": "Vatsal"})
|
| 651 |
+
assert again.status_code == 200 # same uid re-claiming is a no-op success
|
| 652 |
+
app.dependency_overrides[get_owner] = lambda: "uid-thief"
|
| 653 |
+
stolen = await client.post("/auth/claim", json={"name": "Vatsal"})
|
| 654 |
+
assert stolen.status_code == 409
|
| 655 |
+
finally:
|
| 656 |
+
app.dependency_overrides.clear()
|
| 657 |
+
```
|
| 658 |
+
|
| 659 |
+
Adjust `_seed_legacy_card` to CardRow's actual required columns (read the model; fill mandatory fields minimally).
|
| 660 |
+
|
| 661 |
+
- [ ] **Step 2: Run to verify failure** — Expected: 404 on /auth/claim.
|
| 662 |
+
|
| 663 |
+
- [ ] **Step 3: Implement**
|
| 664 |
+
|
| 665 |
+
`db.py`:
|
| 666 |
+
|
| 667 |
+
```python
|
| 668 |
+
class ClaimRow(Base):
|
| 669 |
+
"""Legacy display-name -> uid claims; first claim wins."""
|
| 670 |
+
|
| 671 |
+
__tablename__ = "claims"
|
| 672 |
+
|
| 673 |
+
name: Mapped[str] = mapped_column(String, primary_key=True)
|
| 674 |
+
uid: Mapped[str] = mapped_column(String, nullable=False)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
async def claim_owner(db_session: AsyncSession, *, name: str, uid: str) -> int | None:
|
| 678 |
+
"""Re-point every legacy row owned by `name` to `uid`. None = taken."""
|
| 679 |
+
existing = await db_session.get(ClaimRow, name)
|
| 680 |
+
if existing is not None:
|
| 681 |
+
return 0 if existing.uid == uid else None
|
| 682 |
+
db_session.add(ClaimRow(name=name, uid=uid))
|
| 683 |
+
total = 0
|
| 684 |
+
for model in (CardRow, CollectionRow, ConversationRow, ConnectionRow):
|
| 685 |
+
res = await db_session.execute(
|
| 686 |
+
update(model).where(model.owner_id == name).values(owner_id=uid)
|
| 687 |
+
)
|
| 688 |
+
total += res.rowcount or 0
|
| 689 |
+
await db_session.commit()
|
| 690 |
+
return total
|
| 691 |
+
```
|
| 692 |
+
|
| 693 |
+
`auth_routes.py`:
|
| 694 |
+
|
| 695 |
+
```python
|
| 696 |
+
"""Temporary migration endpoint (delete ~1 month after auth ships)."""
|
| 697 |
+
|
| 698 |
+
from __future__ import annotations
|
| 699 |
+
|
| 700 |
+
from fastapi import APIRouter, HTTPException
|
| 701 |
+
from pydantic import BaseModel
|
| 702 |
+
|
| 703 |
+
from app.auth import OwnerDep
|
| 704 |
+
from app.store import db
|
| 705 |
+
|
| 706 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
class ClaimRequest(BaseModel):
|
| 710 |
+
name: str
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
@router.post("/claim")
|
| 714 |
+
async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
|
| 715 |
+
"""Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
|
| 716 |
+
name = req.name.strip()
|
| 717 |
+
if not name:
|
| 718 |
+
raise HTTPException(status_code=422, detail="name required")
|
| 719 |
+
async with db.session() as s:
|
| 720 |
+
claimed = await db.claim_owner(s, name=name, uid=owner_id)
|
| 721 |
+
if claimed is None:
|
| 722 |
+
raise HTTPException(status_code=409, detail="name already claimed")
|
| 723 |
+
return {"claimed": claimed}
|
| 724 |
+
```
|
| 725 |
+
|
| 726 |
+
- [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_claim.py -v` — Expected: PASS.
|
| 727 |
+
|
| 728 |
+
- [ ] **Step 5: Commit** — `feat: /auth/claim legacy library migration (first-claim-wins)`
|
| 729 |
+
|
| 730 |
+
---
|
| 731 |
+
|
| 732 |
+
### Task 7: Hardening — admin token, clean 500s, CORS
|
| 733 |
+
|
| 734 |
+
**Files:**
|
| 735 |
+
- Modify: `backend/app/main.py`, `backend/app/config.py`
|
| 736 |
+
- Create: `backend/tests/test_hardening.py`
|
| 737 |
+
|
| 738 |
+
**Interfaces:**
|
| 739 |
+
- Consumes: nothing new. Produces: `Settings.admin_token: str = ""`, `Settings.cors_origins: str = ""` (comma-separated).
|
| 740 |
+
|
| 741 |
+
- [ ] **Step 1: Write failing tests**
|
| 742 |
+
|
| 743 |
+
```python
|
| 744 |
+
"""Admin/debug gated; 500s never leak tracebacks."""
|
| 745 |
+
|
| 746 |
+
import pytest
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
@pytest.mark.asyncio
|
| 750 |
+
async def test_admin_requires_token(client, monkeypatch) -> None:
|
| 751 |
+
monkeypatch.setenv("ADMIN_TOKEN", "s3cret")
|
| 752 |
+
from app import config
|
| 753 |
+
config.get_settings.cache_clear()
|
| 754 |
+
assert (await client.get("/admin/stats")).status_code == 401
|
| 755 |
+
assert (await client.get("/debug/jobs")).status_code == 401
|
| 756 |
+
ok = await client.get("/admin/stats", headers={"x-admin-token": "s3cret"})
|
| 757 |
+
assert ok.status_code == 200
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
@pytest.mark.asyncio
|
| 761 |
+
async def test_admin_disabled_when_unset(client, monkeypatch) -> None:
|
| 762 |
+
monkeypatch.delenv("ADMIN_TOKEN", raising=False)
|
| 763 |
+
from app import config
|
| 764 |
+
config.get_settings.cache_clear()
|
| 765 |
+
assert (await client.get("/debug/jobs")).status_code == 401
|
| 766 |
+
```
|
| 767 |
+
|
| 768 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL (currently 200 without token).
|
| 769 |
+
|
| 770 |
+
- [ ] **Step 3: Implement**
|
| 771 |
+
|
| 772 |
+
`config.py`: add `admin_token: str = ""` and `cors_origins: str = ""`.
|
| 773 |
+
|
| 774 |
+
`main.py`:
|
| 775 |
+
|
| 776 |
+
```python
|
| 777 |
+
from fastapi import Depends, Header, HTTPException
|
| 778 |
+
|
| 779 |
+
async def require_admin(x_admin_token: str | None = Header(None)) -> None:
|
| 780 |
+
"""Gate for owner-only endpoints; unset ADMIN_TOKEN disables them entirely."""
|
| 781 |
+
expected = get_settings().admin_token
|
| 782 |
+
if not expected or x_admin_token != expected:
|
| 783 |
+
raise HTTPException(status_code=401, detail="admin token required")
|
| 784 |
+
```
|
| 785 |
+
|
| 786 |
+
Add `dependencies=[Depends(require_admin)]` to the `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck` decorators. Replace the 500 handler body: keep the full-traceback `log.error`, return only `{"detail": "internal error"}` (no exception text, no traceback). CORS: `allow_origins=[o.strip() for o in get_settings().cors_origins.split(",") if o.strip()] or ["http://localhost:8000"]` — set the real Space origin via env in deployment. Import `get_settings` from `app.config` at top of main.py.
|
| 787 |
+
|
| 788 |
+
- [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS. Also verify: `grep -rn "traceback" backend/app/main.py` shows logging only, not response content.
|
| 789 |
+
|
| 790 |
+
- [ ] **Step 5: Commit** — `feat: admin token gate, clean 500s, restricted CORS`
|
docs/planning/plans/2026-07-10-flutter-auth.md
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flutter Auth Integration Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Firebase identity in the app — anonymous sign-in after onboarding+name, optional Google login screen ("Or use without login…"), bearer-token transport, real sign-out, quota meter, and legacy-library claim.
|
| 6 |
+
|
| 7 |
+
**Architecture:** An `AuthService` wraps FirebaseAuth (anonymous start, Google link/sign-in, token access). `ApiClient` gains a token provider injected at construction and attaches `Authorization: Bearer` to every request with one 401-retry-after-refresh. `RootGate` gains a login step between onboarding/name and the shell. Profile gets an account section + quota meter.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Flutter, firebase_core, firebase_auth, google_sign_in, provider, flutter_test with mocked services (no Firebase in unit tests).
|
| 10 |
+
|
| 11 |
+
## Global Constraints
|
| 12 |
+
|
| 13 |
+
- Spec: `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`. Backend plan (`2026-07-10-backend-auth-quotas.md`) must be deployed for end-to-end flows; unit tests here never hit the network.
|
| 14 |
+
- Login screen comes AFTER onboarding + name screen. Primary: "Continue with Google". Below, small quiet text: "Or use without login…". The typed name stays the greeting; uid is identity.
|
| 15 |
+
- Anonymous users see a persistent profile banner: "Your library isn't backed up — sign in with Google", plus warning copy that data may be lost.
|
| 16 |
+
- Dart style: provider/ChangeNotifier, business logic outside widgets, strict null safety, brand tokens from `brand.dart`/`theme.dart`.
|
| 17 |
+
- Run `cd app && flutter test` after every task; `flutter analyze` must stay clean.
|
| 18 |
+
- The user's standing rule: no commits unless asked in-session; Commit steps are conditional on that permission.
|
| 19 |
+
|
| 20 |
+
**One-time owner setup (NOT part of this plan; do first, manually):** create Firebase project → enable Anonymous + Google providers → `flutterfire configure` (writes `app/lib/firebase_options.dart` + `app/android/app/google-services.json`) → register release SHA-1 → set `FIREBASE_PROJECT_ID` on the HF Space. Tasks below assume `firebase_options.dart` exists.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
### Task 1: AuthService — testable Firebase wrapper
|
| 25 |
+
|
| 26 |
+
**Files:**
|
| 27 |
+
- Modify: `app/pubspec.yaml` (add `firebase_core: ^3.6.0`, `firebase_auth: ^5.3.1`, `google_sign_in: ^6.2.1`)
|
| 28 |
+
- Create: `app/lib/data/services/auth_service.dart`
|
| 29 |
+
- Create: `app/test/auth_service_test.dart`
|
| 30 |
+
- Modify: `app/lib/main.dart` (Firebase.initializeApp before runApp)
|
| 31 |
+
|
| 32 |
+
**Interfaces:**
|
| 33 |
+
- Produces:
|
| 34 |
+
|
| 35 |
+
```dart
|
| 36 |
+
/// Wraps FirebaseAuth so the rest of the app never imports firebase directly
|
| 37 |
+
/// (and tests can fake it).
|
| 38 |
+
abstract class AuthService {
|
| 39 |
+
Stream<AuthUser?> get userChanges;
|
| 40 |
+
AuthUser? get currentUser;
|
| 41 |
+
Future<AuthUser> signInAnonymously();
|
| 42 |
+
Future<AuthUser> signInWithGoogle(); // links when currently anonymous
|
| 43 |
+
Future<String?> idToken({bool forceRefresh = false});
|
| 44 |
+
Future<void> signOut();
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
class AuthUser {
|
| 48 |
+
const AuthUser({required this.uid, required this.isAnonymous, this.email, this.displayName, this.photoUrl});
|
| 49 |
+
final String uid;
|
| 50 |
+
final bool isAnonymous;
|
| 51 |
+
final String? email;
|
| 52 |
+
final String? displayName;
|
| 53 |
+
final String? photoUrl;
|
| 54 |
+
}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
- [ ] **Step 1: Write the failing test** (against a `FakeAuthService` used by all later tests, plus the linking contract)
|
| 58 |
+
|
| 59 |
+
```dart
|
| 60 |
+
import 'dart:async';
|
| 61 |
+
|
| 62 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 63 |
+
import 'package:cachy/data/services/auth_service.dart';
|
| 64 |
+
|
| 65 |
+
/// Deterministic in-memory AuthService for widget/unit tests.
|
| 66 |
+
class FakeAuthService implements AuthService {
|
| 67 |
+
AuthUser? _user;
|
| 68 |
+
final _controller = StreamController<AuthUser?>.broadcast();
|
| 69 |
+
String tokenValue = 'fake-token';
|
| 70 |
+
|
| 71 |
+
@override
|
| 72 |
+
Stream<AuthUser?> get userChanges => _controller.stream;
|
| 73 |
+
@override
|
| 74 |
+
AuthUser? get currentUser => _user;
|
| 75 |
+
|
| 76 |
+
@override
|
| 77 |
+
Future<AuthUser> signInAnonymously() async {
|
| 78 |
+
_user = const AuthUser(uid: 'anon-1', isAnonymous: true);
|
| 79 |
+
_controller.add(_user);
|
| 80 |
+
return _user!;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
@override
|
| 84 |
+
Future<AuthUser> signInWithGoogle() async {
|
| 85 |
+
// Linking keeps the uid when the current user is anonymous.
|
| 86 |
+
final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
|
| 87 |
+
_user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
|
| 88 |
+
_controller.add(_user);
|
| 89 |
+
return _user!;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@override
|
| 93 |
+
Future<String?> idToken({bool forceRefresh = false}) async =>
|
| 94 |
+
_user == null ? null : tokenValue;
|
| 95 |
+
|
| 96 |
+
@override
|
| 97 |
+
Future<void> signOut() async {
|
| 98 |
+
_user = null;
|
| 99 |
+
_controller.add(null);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
void main() {
|
| 104 |
+
test('google sign-in after anonymous keeps the uid (link semantics)', () async {
|
| 105 |
+
final auth = FakeAuthService();
|
| 106 |
+
final anon = await auth.signInAnonymously();
|
| 107 |
+
final linked = await auth.signInWithGoogle();
|
| 108 |
+
expect(linked.uid, anon.uid);
|
| 109 |
+
expect(linked.isAnonymous, isFalse);
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
test('no token when signed out', () async {
|
| 113 |
+
final auth = FakeAuthService();
|
| 114 |
+
expect(await auth.idToken(), isNull);
|
| 115 |
+
await auth.signInAnonymously();
|
| 116 |
+
expect(await auth.idToken(), 'fake-token');
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Save `FakeAuthService` in the test file now; Task 3 moves it to `app/test/fakes.dart` for reuse.
|
| 122 |
+
|
| 123 |
+
- [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/auth_service_test.dart` — Expected: FAIL, `auth_service.dart` missing.
|
| 124 |
+
|
| 125 |
+
- [ ] **Step 3: Implement `auth_service.dart`**
|
| 126 |
+
|
| 127 |
+
```dart
|
| 128 |
+
/// Identity layer: Firebase anonymous-first with optional Google upgrade.
|
| 129 |
+
/// The uid is the backend `owner_id`; linking preserves it so no data moves.
|
| 130 |
+
library;
|
| 131 |
+
|
| 132 |
+
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
| 133 |
+
import 'package:google_sign_in/google_sign_in.dart';
|
| 134 |
+
|
| 135 |
+
class AuthUser {
|
| 136 |
+
const AuthUser({
|
| 137 |
+
required this.uid,
|
| 138 |
+
required this.isAnonymous,
|
| 139 |
+
this.email,
|
| 140 |
+
this.displayName,
|
| 141 |
+
this.photoUrl,
|
| 142 |
+
});
|
| 143 |
+
final String uid;
|
| 144 |
+
final bool isAnonymous;
|
| 145 |
+
final String? email;
|
| 146 |
+
final String? displayName;
|
| 147 |
+
final String? photoUrl;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
abstract class AuthService {
|
| 151 |
+
Stream<AuthUser?> get userChanges;
|
| 152 |
+
AuthUser? get currentUser;
|
| 153 |
+
Future<AuthUser> signInAnonymously();
|
| 154 |
+
Future<AuthUser> signInWithGoogle();
|
| 155 |
+
Future<String?> idToken({bool forceRefresh = false});
|
| 156 |
+
Future<void> signOut();
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
class FirebaseAuthService implements AuthService {
|
| 160 |
+
FirebaseAuthService({fb.FirebaseAuth? auth, GoogleSignIn? google})
|
| 161 |
+
: _auth = auth ?? fb.FirebaseAuth.instance,
|
| 162 |
+
_google = google ?? GoogleSignIn();
|
| 163 |
+
|
| 164 |
+
final fb.FirebaseAuth _auth;
|
| 165 |
+
final GoogleSignIn _google;
|
| 166 |
+
|
| 167 |
+
AuthUser? _map(fb.User? u) => u == null
|
| 168 |
+
? null
|
| 169 |
+
: AuthUser(
|
| 170 |
+
uid: u.uid,
|
| 171 |
+
isAnonymous: u.isAnonymous,
|
| 172 |
+
email: u.email,
|
| 173 |
+
displayName: u.displayName,
|
| 174 |
+
photoUrl: u.photoURL,
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
@override
|
| 178 |
+
Stream<AuthUser?> get userChanges => _auth.userChanges().map(_map);
|
| 179 |
+
|
| 180 |
+
@override
|
| 181 |
+
AuthUser? get currentUser => _map(_auth.currentUser);
|
| 182 |
+
|
| 183 |
+
@override
|
| 184 |
+
Future<AuthUser> signInAnonymously() async {
|
| 185 |
+
final cred = await _auth.signInAnonymously();
|
| 186 |
+
return _map(cred.user)!;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
@override
|
| 190 |
+
Future<AuthUser> signInWithGoogle() async {
|
| 191 |
+
final account = await _google.signIn();
|
| 192 |
+
if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
|
| 193 |
+
final gAuth = await account.authentication;
|
| 194 |
+
final credential = fb.GoogleAuthProvider.credential(
|
| 195 |
+
idToken: gAuth.idToken,
|
| 196 |
+
accessToken: gAuth.accessToken,
|
| 197 |
+
);
|
| 198 |
+
final current = _auth.currentUser;
|
| 199 |
+
fb.UserCredential cred;
|
| 200 |
+
if (current != null && current.isAnonymous) {
|
| 201 |
+
try {
|
| 202 |
+
cred = await current.linkWithCredential(credential); // uid preserved
|
| 203 |
+
} on fb.FirebaseAuthException catch (e) {
|
| 204 |
+
if (e.code != 'credential-already-in-use') rethrow;
|
| 205 |
+
// Google account already has a Cachy identity — switch to it.
|
| 206 |
+
cred = await _auth.signInWithCredential(credential);
|
| 207 |
+
}
|
| 208 |
+
} else {
|
| 209 |
+
cred = await _auth.signInWithCredential(credential);
|
| 210 |
+
}
|
| 211 |
+
return _map(cred.user)!;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
@override
|
| 215 |
+
Future<String?> idToken({bool forceRefresh = false}) =>
|
| 216 |
+
_auth.currentUser?.getIdToken(forceRefresh) ?? Future.value(null);
|
| 217 |
+
|
| 218 |
+
@override
|
| 219 |
+
Future<void> signOut() async {
|
| 220 |
+
await _google.signOut();
|
| 221 |
+
await _auth.signOut();
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
`main.dart`: before `runApp`, add
|
| 227 |
+
|
| 228 |
+
```dart
|
| 229 |
+
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
with imports `package:firebase_core/firebase_core.dart` and `firebase_options.dart`; provide `AuthService` alongside the existing providers: `Provider<AuthService>(create: (_) => FirebaseAuthService())`.
|
| 233 |
+
|
| 234 |
+
- [ ] **Step 4: Run** — `cd app && flutter test test/auth_service_test.dart && flutter analyze` — Expected: PASS, no new analyzer issues.
|
| 235 |
+
|
| 236 |
+
- [ ] **Step 5: Commit** — `feat: AuthService — anonymous-first Firebase identity with Google linking`
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
### Task 2: Bearer token transport in ApiClient (with one 401 retry)
|
| 241 |
+
|
| 242 |
+
**Files:**
|
| 243 |
+
- Modify: `app/lib/data/services/api_client.dart`
|
| 244 |
+
- Create: `app/test/api_client_auth_test.dart`
|
| 245 |
+
|
| 246 |
+
**Interfaces:**
|
| 247 |
+
- Consumes: `AuthService.idToken` (Task 1).
|
| 248 |
+
- Produces: `ApiClient({..., Future<String?> Function({bool forceRefresh})? tokenProvider})`; private `Future<http.Response> _send(...)` used by all verbs; `_ownerHeader` and every `x-owner-id` reference deleted.
|
| 249 |
+
|
| 250 |
+
- [ ] **Step 1: Write the failing test** (MockClient counts auth headers + retry behavior)
|
| 251 |
+
|
| 252 |
+
```dart
|
| 253 |
+
import 'dart:convert';
|
| 254 |
+
|
| 255 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 256 |
+
import 'package:http/http.dart' as http;
|
| 257 |
+
import 'package:http/testing.dart';
|
| 258 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 259 |
+
|
| 260 |
+
void main() {
|
| 261 |
+
test('attaches bearer token to requests', () async {
|
| 262 |
+
String? seenAuth;
|
| 263 |
+
final mock = MockClient((req) async {
|
| 264 |
+
seenAuth = req.headers['authorization'];
|
| 265 |
+
return http.Response(jsonEncode([]), 200);
|
| 266 |
+
});
|
| 267 |
+
final api = ApiClient(
|
| 268 |
+
baseUrl: 'http://x',
|
| 269 |
+
client: mock,
|
| 270 |
+
tokenProvider: ({bool forceRefresh = false}) async => 'tok-1',
|
| 271 |
+
);
|
| 272 |
+
await api.listCards();
|
| 273 |
+
expect(seenAuth, 'Bearer tok-1');
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
test('one forced-refresh retry on 401', () async {
|
| 277 |
+
var calls = 0;
|
| 278 |
+
final mock = MockClient((req) async {
|
| 279 |
+
calls++;
|
| 280 |
+
if (req.headers['authorization'] == 'Bearer stale') {
|
| 281 |
+
return http.Response('unauthorized', 401);
|
| 282 |
+
}
|
| 283 |
+
return http.Response(jsonEncode([]), 200);
|
| 284 |
+
});
|
| 285 |
+
var fresh = false;
|
| 286 |
+
final api = ApiClient(
|
| 287 |
+
baseUrl: 'http://x',
|
| 288 |
+
client: mock,
|
| 289 |
+
tokenProvider: ({bool forceRefresh = false}) async {
|
| 290 |
+
if (forceRefresh) fresh = true;
|
| 291 |
+
return fresh ? 'fresh' : 'stale';
|
| 292 |
+
},
|
| 293 |
+
);
|
| 294 |
+
final cards = await api.listCards();
|
| 295 |
+
expect(cards, isEmpty);
|
| 296 |
+
expect(calls, 2); // 401 then success — exactly one retry
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
- [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/api_client_auth_test.dart` — Expected: FAIL, no `tokenProvider` parameter.
|
| 302 |
+
|
| 303 |
+
- [ ] **Step 3: Implement in `api_client.dart`**
|
| 304 |
+
|
| 305 |
+
Constructor gains `this.tokenProvider`; field `final Future<String?> Function({bool forceRefresh})? tokenProvider;`. Replace `_ownerHeader` with:
|
| 306 |
+
|
| 307 |
+
```dart
|
| 308 |
+
Future<Map<String, String>> _authHeader({bool forceRefresh = false}) async {
|
| 309 |
+
final token = await tokenProvider?.call(forceRefresh: forceRefresh);
|
| 310 |
+
if (token == null || token.isEmpty) return const {};
|
| 311 |
+
return {'authorization': 'Bearer $token'};
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/// All verbs funnel through here: auth header + one refresh-retry on 401.
|
| 315 |
+
Future<http.Response> _send(
|
| 316 |
+
Future<http.Response> Function(Map<String, String> headers) go, {
|
| 317 |
+
Map<String, String> extra = const {},
|
| 318 |
+
}) async {
|
| 319 |
+
var resp = await go({...extra, ...await _authHeader()});
|
| 320 |
+
if (resp.statusCode == 401 && tokenProvider != null) {
|
| 321 |
+
resp = await go({...extra, ...await _authHeader(forceRefresh: true)});
|
| 322 |
+
}
|
| 323 |
+
return resp;
|
| 324 |
+
}
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
Then mechanically rewrite each call site, e.g.:
|
| 328 |
+
|
| 329 |
+
```dart
|
| 330 |
+
Future<List<Card>> listCards({...}) async {
|
| 331 |
+
final resp = await _send((h) => _client.get(_uri('/cards', {...}), headers: h));
|
| 332 |
+
return _decodeList(resp).map(Card.fromJson).toList();
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
Future<CreateCardResult> createCard(String url) async {
|
| 336 |
+
final resp = await _send(
|
| 337 |
+
(h) => _client.post(_uri('/cards'), headers: h, body: jsonEncode({'url': url})),
|
| 338 |
+
extra: const {'content-type': 'application/json'},
|
| 339 |
+
);
|
| 340 |
+
...
|
| 341 |
+
}
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
Apply to every method that used `_ownerHeader` (grep: `_ownerHeader` must return zero hits afterwards). `streamCard` (SSE): add the awaited auth header to `request.headers` before `_client.send(request)` (no retry loop needed — the reader screen re-subscribes on error). In `main.dart`/composition root, construct `ApiClient(..., tokenProvider: ({bool forceRefresh = false}) => context.read<AuthService>().idToken(forceRefresh: forceRefresh))` — wire via the existing repository setup (pass AuthService into wherever ApiClient is built today).
|
| 345 |
+
|
| 346 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
|
| 347 |
+
|
| 348 |
+
- [ ] **Step 5: Commit** — `feat: bearer-token transport with single 401 refresh-retry`
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
### Task 3: Login screen after onboarding + RootGate wiring
|
| 353 |
+
|
| 354 |
+
**Files:**
|
| 355 |
+
- Create: `app/lib/ui/features/onboarding/views/login_screen.dart`
|
| 356 |
+
- Modify: `app/lib/ui/core/root_gate.dart` (insert login step after name; read its current step logic first and mirror its pattern)
|
| 357 |
+
- Modify: `app/lib/ui/core/app_controller.dart` (expose `authUser`, `signInWithGoogle()`, `continueAnonymously()`, listening to `AuthService.userChanges`)
|
| 358 |
+
- Create: `app/test/fakes.dart` (move `FakeAuthService` here)
|
| 359 |
+
- Create: `app/test/login_screen_test.dart`
|
| 360 |
+
|
| 361 |
+
**Interfaces:**
|
| 362 |
+
- Consumes: `AuthService` (Task 1).
|
| 363 |
+
- Produces: `LoginScreen({required VoidCallback onDone})`; AppController: `AuthUser? get authUser`, `bool get needsLogin` (seen onboarding + has name + no firebase user), `Future<void> signInWithGoogle()`, `Future<void> continueAnonymously()`.
|
| 364 |
+
|
| 365 |
+
- [ ] **Step 1: Write the failing widget test**
|
| 366 |
+
|
| 367 |
+
```dart
|
| 368 |
+
import 'package:flutter/material.dart';
|
| 369 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 370 |
+
import 'package:provider/provider.dart';
|
| 371 |
+
import 'package:cachy/data/services/auth_service.dart';
|
| 372 |
+
import 'package:cachy/ui/features/onboarding/views/login_screen.dart';
|
| 373 |
+
|
| 374 |
+
import 'fakes.dart';
|
| 375 |
+
|
| 376 |
+
void main() {
|
| 377 |
+
testWidgets('login screen: Google primary, quiet anonymous path', (tester) async {
|
| 378 |
+
final auth = FakeAuthService();
|
| 379 |
+
var done = 0;
|
| 380 |
+
await tester.pumpWidget(
|
| 381 |
+
Provider<AuthService>.value(
|
| 382 |
+
value: auth,
|
| 383 |
+
child: MaterialApp(home: LoginScreen(onDone: () => done++)),
|
| 384 |
+
),
|
| 385 |
+
);
|
| 386 |
+
expect(find.text('Continue with Google'), findsOneWidget);
|
| 387 |
+
expect(find.text('Or use without login…'), findsOneWidget);
|
| 388 |
+
|
| 389 |
+
await tester.tap(find.text('Or use without login…'));
|
| 390 |
+
await tester.pumpAndSettle();
|
| 391 |
+
expect(auth.currentUser?.isAnonymous, isTrue);
|
| 392 |
+
expect(done, 1);
|
| 393 |
+
});
|
| 394 |
+
}
|
| 395 |
+
```
|
| 396 |
+
|
| 397 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `login_screen.dart` missing.
|
| 398 |
+
|
| 399 |
+
- [ ] **Step 3: Implement `login_screen.dart`**
|
| 400 |
+
|
| 401 |
+
Visual language mirrors `name_screen.dart` (same radial gradient scaffold, Fraunces headline via theme, `Brand` tokens — no direct GoogleFonts). Core structure:
|
| 402 |
+
|
| 403 |
+
```dart
|
| 404 |
+
/// Login gate, shown after onboarding + name. Google is primary; anonymous is
|
| 405 |
+
/// a quiet escape hatch with an honest data-loss caveat.
|
| 406 |
+
library;
|
| 407 |
+
|
| 408 |
+
import 'package:flutter/material.dart';
|
| 409 |
+
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 410 |
+
import 'package:provider/provider.dart';
|
| 411 |
+
|
| 412 |
+
import '../../../../data/services/auth_service.dart';
|
| 413 |
+
import '../../../core/brand.dart';
|
| 414 |
+
import '../../../core/widgets/responsive_center.dart';
|
| 415 |
+
|
| 416 |
+
class LoginScreen extends StatefulWidget {
|
| 417 |
+
const LoginScreen({super.key, required this.onDone});
|
| 418 |
+
final VoidCallback onDone;
|
| 419 |
+
|
| 420 |
+
@override
|
| 421 |
+
State<LoginScreen> createState() => _LoginScreenState();
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
class _LoginScreenState extends State<LoginScreen> {
|
| 425 |
+
bool _busy = false;
|
| 426 |
+
String? _error;
|
| 427 |
+
|
| 428 |
+
Future<void> _run(Future<void> Function() action) async {
|
| 429 |
+
setState(() { _busy = true; _error = null; });
|
| 430 |
+
try {
|
| 431 |
+
await action();
|
| 432 |
+
if (mounted) widget.onDone();
|
| 433 |
+
} catch (_) {
|
| 434 |
+
if (mounted) {
|
| 435 |
+
setState(() => _error = "Couldn't sign in. Check your connection and try again.");
|
| 436 |
+
}
|
| 437 |
+
} finally {
|
| 438 |
+
if (mounted) setState(() => _busy = false);
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
@override
|
| 443 |
+
Widget build(BuildContext context) {
|
| 444 |
+
final theme = Theme.of(context);
|
| 445 |
+
final scheme = theme.colorScheme;
|
| 446 |
+
final auth = context.read<AuthService>();
|
| 447 |
+
return Scaffold(
|
| 448 |
+
backgroundColor: scheme.surface,
|
| 449 |
+
body: SafeArea(
|
| 450 |
+
child: ResponsiveCenter(
|
| 451 |
+
child: Padding(
|
| 452 |
+
padding: const EdgeInsets.symmetric(horizontal: 28),
|
| 453 |
+
child: Column(
|
| 454 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 455 |
+
children: [
|
| 456 |
+
const SizedBox(height: 48),
|
| 457 |
+
const CachyGlyph(size: 56),
|
| 458 |
+
const SizedBox(height: 32),
|
| 459 |
+
Text('Keep your\nlibrary safe.',
|
| 460 |
+
style: theme.textTheme.displaySmall),
|
| 461 |
+
const SizedBox(height: 16),
|
| 462 |
+
Text(
|
| 463 |
+
'Sign in so your cards follow you to any device — and survive a reinstall.',
|
| 464 |
+
style: theme.textTheme.bodyLarge?.copyWith(
|
| 465 |
+
color: scheme.onSurfaceVariant, height: 1.5),
|
| 466 |
+
),
|
| 467 |
+
if (_error != null) ...[
|
| 468 |
+
const SizedBox(height: 16),
|
| 469 |
+
Text(_error!, style: TextStyle(color: scheme.error)),
|
| 470 |
+
],
|
| 471 |
+
const Spacer(),
|
| 472 |
+
FilledButton.icon(
|
| 473 |
+
onPressed: _busy ? null : () => _run(() async { await auth.signInWithGoogle(); }),
|
| 474 |
+
icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo, size: 20),
|
| 475 |
+
label: const Text('Continue with Google'),
|
| 476 |
+
style: FilledButton.styleFrom(
|
| 477 |
+
minimumSize: const Size.fromHeight(56),
|
| 478 |
+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
| 479 |
+
),
|
| 480 |
+
),
|
| 481 |
+
const SizedBox(height: 14),
|
| 482 |
+
Center(
|
| 483 |
+
child: TextButton(
|
| 484 |
+
onPressed: _busy ? null : () => _run(() async { await auth.signInAnonymously(); }),
|
| 485 |
+
child: Text(
|
| 486 |
+
'Or use without login…',
|
| 487 |
+
style: theme.textTheme.bodySmall?.copyWith(
|
| 488 |
+
color: scheme.onSurfaceVariant,
|
| 489 |
+
decoration: TextDecoration.underline,
|
| 490 |
+
),
|
| 491 |
+
),
|
| 492 |
+
),
|
| 493 |
+
),
|
| 494 |
+
const SizedBox(height: 6),
|
| 495 |
+
Center(
|
| 496 |
+
child: Text(
|
| 497 |
+
'Without an account, your library lives only on this device.',
|
| 498 |
+
textAlign: TextAlign.center,
|
| 499 |
+
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
|
| 500 |
+
),
|
| 501 |
+
),
|
| 502 |
+
const SizedBox(height: 24),
|
| 503 |
+
],
|
| 504 |
+
),
|
| 505 |
+
),
|
| 506 |
+
),
|
| 507 |
+
),
|
| 508 |
+
);
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
```
|
| 512 |
+
|
| 513 |
+
`app_controller.dart`: inject `AuthService`, subscribe to `userChanges` (notifyListeners), add the three members from Interfaces. `root_gate.dart`: current gate is onboarding → name → shell; make it onboarding → name → login → shell, keyed on `needsLogin` (a returning signed-in user skips the login screen because `currentUser != null`).
|
| 514 |
+
|
| 515 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
|
| 516 |
+
|
| 517 |
+
- [ ] **Step 5: Commit** — `feat: post-onboarding login screen (Google primary, anonymous quiet path)`
|
| 518 |
+
|
| 519 |
+
---
|
| 520 |
+
|
| 521 |
+
### Task 4: Profile account section — banner, real sign-out, quota meter, claim
|
| 522 |
+
|
| 523 |
+
**Files:**
|
| 524 |
+
- Modify: `app/lib/ui/features/profile/views/profile_screen.dart`
|
| 525 |
+
- Modify: `app/lib/data/services/api_client.dart` (+`me()` quota fetch, +`claim(name)`)
|
| 526 |
+
- Modify: `app/lib/ui/core/app_controller.dart` (`logout()` also calls `AuthService.signOut()`)
|
| 527 |
+
- Create: `app/test/profile_account_test.dart`
|
| 528 |
+
|
| 529 |
+
**Interfaces:**
|
| 530 |
+
- Consumes: `AuthService`, `AppController.authUser` (Task 3), backend `GET /me/quota` + `POST /auth/claim`.
|
| 531 |
+
- Produces: `ApiClient.quota() -> Future<QuotaStatus>` where
|
| 532 |
+
|
| 533 |
+
```dart
|
| 534 |
+
class QuotaStatus {
|
| 535 |
+
const QuotaStatus({required this.cardsUsed, required this.cardsLimit, required this.chatUsed, required this.chatLimit});
|
| 536 |
+
final int cardsUsed, cardsLimit, chatUsed, chatLimit;
|
| 537 |
+
}
|
| 538 |
+
```
|
| 539 |
+
|
| 540 |
+
and `ApiClient.claimLegacyLibrary(String name) -> Future<int>` (claimed row count; throws ApiException 409 when taken).
|
| 541 |
+
|
| 542 |
+
- [ ] **Step 1: Write the failing test**
|
| 543 |
+
|
| 544 |
+
```dart
|
| 545 |
+
import 'dart:convert';
|
| 546 |
+
|
| 547 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 548 |
+
import 'package:http/http.dart' as http;
|
| 549 |
+
import 'package:http/testing.dart';
|
| 550 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 551 |
+
|
| 552 |
+
void main() {
|
| 553 |
+
test('quota() parses /me/quota', () async {
|
| 554 |
+
final mock = MockClient((req) async {
|
| 555 |
+
expect(req.url.path, '/me/quota');
|
| 556 |
+
return http.Response(jsonEncode({
|
| 557 |
+
'cards': {'used': 3, 'limit': 10},
|
| 558 |
+
'chat': {'used': 1, 'limit': 30},
|
| 559 |
+
'resets_at': '2026-07-11T00:00:00+00:00',
|
| 560 |
+
}), 200);
|
| 561 |
+
});
|
| 562 |
+
final api = ApiClient(baseUrl: 'http://x', client: mock);
|
| 563 |
+
final q = await api.quota();
|
| 564 |
+
expect(q.cardsUsed, 3);
|
| 565 |
+
expect(q.cardsLimit, 10);
|
| 566 |
+
});
|
| 567 |
+
|
| 568 |
+
test('claimLegacyLibrary returns claimed count', () async {
|
| 569 |
+
final mock = MockClient((req) async => http.Response(jsonEncode({'claimed': 7}), 200));
|
| 570 |
+
final api = ApiClient(baseUrl: 'http://x', client: mock);
|
| 571 |
+
expect(await api.claimLegacyLibrary('Vatsal'), 7);
|
| 572 |
+
});
|
| 573 |
+
}
|
| 574 |
+
```
|
| 575 |
+
|
| 576 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `quota`/`claimLegacyLibrary` undefined.
|
| 577 |
+
|
| 578 |
+
- [ ] **Step 3: Implement**
|
| 579 |
+
|
| 580 |
+
`api_client.dart` additions (using `_send` from Task 2):
|
| 581 |
+
|
| 582 |
+
```dart
|
| 583 |
+
class QuotaStatus {
|
| 584 |
+
const QuotaStatus({required this.cardsUsed, required this.cardsLimit, required this.chatUsed, required this.chatLimit});
|
| 585 |
+
final int cardsUsed;
|
| 586 |
+
final int cardsLimit;
|
| 587 |
+
final int chatUsed;
|
| 588 |
+
final int chatLimit;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
Future<QuotaStatus> quota() async {
|
| 592 |
+
final resp = await _send((h) => _client.get(_uri('/me/quota'), headers: h));
|
| 593 |
+
final json = _decodeMap(resp);
|
| 594 |
+
int pick(String kind, String field) =>
|
| 595 |
+
((json[kind] as Map<String, dynamic>?)?[field] as num?)?.toInt() ?? 0;
|
| 596 |
+
return QuotaStatus(
|
| 597 |
+
cardsUsed: pick('cards', 'used'),
|
| 598 |
+
cardsLimit: pick('cards', 'limit'),
|
| 599 |
+
chatUsed: pick('chat', 'used'),
|
| 600 |
+
chatLimit: pick('chat', 'limit'),
|
| 601 |
+
);
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
Future<int> claimLegacyLibrary(String name) async {
|
| 605 |
+
final resp = await _send(
|
| 606 |
+
(h) => _client.post(_uri('/auth/claim'), headers: h, body: jsonEncode({'name': name})),
|
| 607 |
+
extra: const {'content-type': 'application/json'},
|
| 608 |
+
);
|
| 609 |
+
return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
|
| 610 |
+
}
|
| 611 |
+
```
|
| 612 |
+
|
| 613 |
+
`profile_screen.dart` Account section replaces the current sign-out-only block:
|
| 614 |
+
- Signed-in (Google): row with photo/initial avatar, displayName, email; "Sign out" tile below (existing confirm dialog; copy updated to "Your cards stay safe in your account.").
|
| 615 |
+
- Anonymous: banner tile (primary-tinted container, not a snackbar): title "Your library isn't backed up", subtitle "Sign in with Google — if you uninstall or clear data, your cards are gone.", trailing FilledButton "Sign in" → `context.read<AppController>().signInWithGoogle()`; on success and when `LocalStore.userName` is set, offer the claim dialog: "Restore my old library" → `api.claimLegacyLibrary(name)`; on 409 show "That name was already claimed."
|
| 616 |
+
- Quota meter: under Library section, a `_Tile`-style row "AI usage today" with subtitle `"${q.cardsUsed}/${q.cardsLimit} cards · ${q.chatUsed}/${q.chatLimit} chats"`, loaded via `FutureBuilder(api.quota())`, hidden on error (quota is a nicety, never a blocker).
|
| 617 |
+
- `AppController.logout()`: call `await _auth.signOut()` in addition to the existing `LocalStore.clearUser()`.
|
| 618 |
+
|
| 619 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
|
| 620 |
+
|
| 621 |
+
- [ ] **Step 5: Manual end-to-end check (requires deployed backend + Firebase setup)**
|
| 622 |
+
|
| 623 |
+
Run `cd app && flutter run -d chrome --dart-define=CACHY_API_BASE=http://localhost:8000` with the backend running and `FIREBASE_PROJECT_ID` set. Verify: onboarding → name → login screen appears once → "Or use without login…" enters the shell → profile shows the not-backed-up banner → Google sign-in links (library unchanged) → banner replaced by account row → sign out returns to onboarding gate.
|
| 624 |
+
|
| 625 |
+
- [ ] **Step 6: Commit** — `feat: profile account section — banner, quota meter, legacy claim, real sign-out`
|
docs/planning/plans/2026-07-10-ui-trust-polish.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI Trust & Polish Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Close the four P1 trust leaks from the 2026-07-10 critique (fake cache-clear, dead Move-to-Folder, raw errors, cold-start cliff) plus the P2 polish items (reduced motion, onboarding brand pass, bounce easing).
|
| 6 |
+
|
| 7 |
+
**Architecture:** Pure Flutter changes, independent of the auth plans. Error mapping happens once in `ApiException`; move-to-folder reuses the existing `/collections/cards/{id}/move` endpoint and `ApiClient.moveCardToCollection`; cold-start becomes a distinct library/share status driven by retry-with-backoff in the repository layer.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Flutter, provider, flutter_animate, flutter_test.
|
| 10 |
+
|
| 11 |
+
## Global Constraints
|
| 12 |
+
|
| 13 |
+
- Spec: "UI trust & polish fixes" section of `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`; critique snapshot `.impeccable/critique/2026-07-10T11-11-16Z__app-lib.md`.
|
| 14 |
+
- Brand rules: all type through `brand.dart` tokens (no direct GoogleFonts outside it), motion 150–260ms ease-out, no bounce/overshoot, glass tokens shared.
|
| 15 |
+
- Never surface `e.toString()`, `ApiException(...)` bodies, or server tracebacks in UI copy.
|
| 16 |
+
- `cd app && flutter test && flutter analyze` after every task.
|
| 17 |
+
- No commits unless the user has granted it in-session; Commit steps are conditional on that.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
### Task 1: Real "Clear offline cache"
|
| 22 |
+
|
| 23 |
+
**Files:**
|
| 24 |
+
- Modify: `app/lib/data/services/local_store.dart`
|
| 25 |
+
- Modify: `app/lib/ui/features/profile/views/profile_screen.dart:251-256`
|
| 26 |
+
- Create: `app/test/local_store_test.dart`
|
| 27 |
+
|
| 28 |
+
**Interfaces:**
|
| 29 |
+
- Produces: `Future<int> LocalStore.clearCardCache()` — removes every cached card + index, returns removed count.
|
| 30 |
+
|
| 31 |
+
- [ ] **Step 1: Write the failing test**
|
| 32 |
+
|
| 33 |
+
```dart
|
| 34 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 35 |
+
import 'package:shared_preferences/shared_preferences.dart';
|
| 36 |
+
import 'package:cachy/data/services/local_store.dart';
|
| 37 |
+
|
| 38 |
+
void main() {
|
| 39 |
+
TestWidgetsFlutterBinding.ensureInitialized();
|
| 40 |
+
|
| 41 |
+
test('clearCardCache removes all cached cards and the index', () async {
|
| 42 |
+
SharedPreferences.setMockInitialValues({});
|
| 43 |
+
final store = await LocalStore.open();
|
| 44 |
+
await store.cacheCard('c1', {'id': 'c1'});
|
| 45 |
+
await store.cacheCard('c2', {'id': 'c2'});
|
| 46 |
+
expect(store.cachedCardIds(), hasLength(2));
|
| 47 |
+
|
| 48 |
+
final removed = await store.clearCardCache();
|
| 49 |
+
expect(removed, 2);
|
| 50 |
+
expect(store.cachedCardIds(), isEmpty);
|
| 51 |
+
expect(store.readCard('c1'), isNull);
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
- [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/local_store_test.dart` — Expected: FAIL, `clearCardCache` undefined.
|
| 57 |
+
|
| 58 |
+
- [ ] **Step 3: Implement**
|
| 59 |
+
|
| 60 |
+
`local_store.dart`, in the Card cache section:
|
| 61 |
+
|
| 62 |
+
```dart
|
| 63 |
+
/// Remove every cached card and the index. Returns how many were removed.
|
| 64 |
+
Future<int> clearCardCache() async {
|
| 65 |
+
final ids = cachedCardIds();
|
| 66 |
+
for (final id in ids) {
|
| 67 |
+
await _prefs.remove('$_cardPrefix$id');
|
| 68 |
+
}
|
| 69 |
+
await _prefs.remove(_indexKey);
|
| 70 |
+
return ids.length;
|
| 71 |
+
}
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
`profile_screen.dart` `_confirmClear`, replace the lying success block:
|
| 75 |
+
|
| 76 |
+
```dart
|
| 77 |
+
if (ok == true && mounted) {
|
| 78 |
+
final removed = await context.read<LocalStore>().clearCardCache();
|
| 79 |
+
if (mounted) {
|
| 80 |
+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
| 81 |
+
content: Text(removed == 0
|
| 82 |
+
? 'Nothing cached yet'
|
| 83 |
+
: 'Cleared $removed offline ${removed == 1 ? 'card' : 'cards'}'),
|
| 84 |
+
));
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
If `LocalStore` isn't in the provider graph, expose it via `CardRepository` (e.g. `context.read<CardRepository>().store.clearCardCache()`) — check how the repository holds it and use the existing path; do not add a new provider if one route already exists.
|
| 90 |
+
|
| 91 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
|
| 92 |
+
|
| 93 |
+
- [ ] **Step 5: Commit** — `fix: Clear offline cache actually clears (and reports the count)`
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
### Task 2: Friendly error mapping at the ApiException boundary
|
| 98 |
+
|
| 99 |
+
**Files:**
|
| 100 |
+
- Modify: `app/lib/data/services/api_client.dart` (ApiException gains `friendlyMessage`)
|
| 101 |
+
- Modify: `app/lib/ui/features/actions/view_models/actions_view_model.dart:73` and every VM assigning `e.toString()` to a user-visible error field (grep `toString()` under `app/lib/ui/**/view_models/` and `app/lib/ui/**/views/` for snackbar/error uses)
|
| 102 |
+
- Create: `app/test/api_exception_test.dart`
|
| 103 |
+
|
| 104 |
+
**Interfaces:**
|
| 105 |
+
- Produces: `String ApiException.friendlyMessage` and `String friendlyError(Object e)` helper (top-level in api_client.dart) — the ONLY strings VMs may store in user-visible error fields.
|
| 106 |
+
|
| 107 |
+
- [ ] **Step 1: Write the failing test**
|
| 108 |
+
|
| 109 |
+
```dart
|
| 110 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 111 |
+
import 'package:cachy/data/services/api_client.dart';
|
| 112 |
+
|
| 113 |
+
void main() {
|
| 114 |
+
test('friendly messages never leak bodies or tracebacks', () {
|
| 115 |
+
expect(ApiException(500, '{"detail":"X","traceback":"Trace..."}').friendlyMessage,
|
| 116 |
+
'Something went wrong on our side. Try again in a moment.');
|
| 117 |
+
expect(ApiException(429, '{"error":"quota"}').friendlyMessage,
|
| 118 |
+
"You've hit today's limit. It resets at midnight UTC.");
|
| 119 |
+
expect(ApiException(401, 'x').friendlyMessage,
|
| 120 |
+
'Session expired — please sign in again.');
|
| 121 |
+
expect(ApiException(404, 'x').friendlyMessage,
|
| 122 |
+
"That card isn't there anymore.");
|
| 123 |
+
for (final code in [400, 401, 404, 429, 500, 503]) {
|
| 124 |
+
final msg = ApiException(code, 'traceback secret').friendlyMessage;
|
| 125 |
+
expect(msg.contains('traceback'), isFalse);
|
| 126 |
+
expect(msg.contains('secret'), isFalse);
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
test('friendlyError handles non-Api exceptions', () {
|
| 131 |
+
expect(friendlyError(Exception('SocketException: conn refused')),
|
| 132 |
+
"Can't reach Cachy. Check your connection.");
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `friendlyMessage` undefined.
|
| 138 |
+
|
| 139 |
+
- [ ] **Step 3: Implement**
|
| 140 |
+
|
| 141 |
+
In `api_client.dart`:
|
| 142 |
+
|
| 143 |
+
```dart
|
| 144 |
+
class ApiException implements Exception {
|
| 145 |
+
ApiException(this.statusCode, this.message);
|
| 146 |
+
final int statusCode;
|
| 147 |
+
final String message; // raw body — for logs only, never for UI
|
| 148 |
+
|
| 149 |
+
/// What users see. Raw bodies (which may include server details) never
|
| 150 |
+
/// leave the data layer.
|
| 151 |
+
String get friendlyMessage => switch (statusCode) {
|
| 152 |
+
401 || 403 => 'Session expired — please sign in again.',
|
| 153 |
+
404 => "That card isn't there anymore.",
|
| 154 |
+
429 => "You've hit today's limit. It resets at midnight UTC.",
|
| 155 |
+
>= 500 => 'Something went wrong on our side. Try again in a moment.',
|
| 156 |
+
_ => "That didn't work. Try again.",
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
@override
|
| 160 |
+
String toString() => 'ApiException($statusCode): $message';
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/// UI-safe message for any thrown object.
|
| 164 |
+
String friendlyError(Object e) => switch (e) {
|
| 165 |
+
ApiException api => api.friendlyMessage,
|
| 166 |
+
_ => "Can't reach Cachy. Check your connection.",
|
| 167 |
+
};
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
Swap every user-visible assignment, e.g. `actions_view_model.dart:73`: `_error = e.toString();` → `_error = friendlyError(e);` (keep a `debugPrint('$e')` or logger call if the original context logged). Do the same wherever `SnackBar(content: Text('...$error'))` shows a VM error built from `toString()` (library bulk delete already shows `vm.error` — that's now friendly automatically).
|
| 171 |
+
|
| 172 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "toString()" app/lib/ui | grep -i "error\|snack"` — Expected: tests PASS; grep only shows non-UI/logging uses.
|
| 173 |
+
|
| 174 |
+
- [ ] **Step 5: Commit** — `fix: friendly error copy everywhere — raw exceptions never reach UI`
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
### Task 3: Cold-start "Waking Cachy up" state
|
| 179 |
+
|
| 180 |
+
**Files:**
|
| 181 |
+
- Modify: `app/lib/ui/features/library/view_models/library_view_model.dart` (new status + retry loop)
|
| 182 |
+
- Modify: `app/lib/ui/features/library/views/library_screen.dart` (`_body` renders the waking state)
|
| 183 |
+
- Create: `app/test/library_wakeup_test.dart`
|
| 184 |
+
|
| 185 |
+
**Interfaces:**
|
| 186 |
+
- Consumes: `friendlyError` (Task 2).
|
| 187 |
+
- Produces: `LibraryStatus.waking` enum value; `LibraryViewModel.load()` retries up to 6 times / 10s apart on connection-shaped failures before settling on `error`; `int get wakeAttempt`.
|
| 188 |
+
|
| 189 |
+
- [ ] **Step 1: Write the failing test**
|
| 190 |
+
|
| 191 |
+
Read `library_view_model.dart` first for its repository interface, then adapt this shape (fake repo that fails N times then succeeds):
|
| 192 |
+
|
| 193 |
+
```dart
|
| 194 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 195 |
+
import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
|
| 196 |
+
|
| 197 |
+
import 'fakes.dart'; // FakeCardRepository: fails with SocketException-ish twice, then returns []
|
| 198 |
+
|
| 199 |
+
void main() {
|
| 200 |
+
test('connection failure enters waking state, then recovers', () async {
|
| 201 |
+
final repo = FakeCardRepository(failuresBeforeSuccess: 2);
|
| 202 |
+
final vm = LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
|
| 203 |
+
final seen = <LibraryStatus>[];
|
| 204 |
+
vm.addListener(() => seen.add(vm.status));
|
| 205 |
+
await vm.load();
|
| 206 |
+
expect(seen, contains(LibraryStatus.waking));
|
| 207 |
+
expect(vm.status, anyOf(LibraryStatus.empty, LibraryStatus.ready));
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
test('persistent failure lands on error after max attempts', () async {
|
| 211 |
+
final repo = FakeCardRepository(failuresBeforeSuccess: 99);
|
| 212 |
+
final vm = LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
|
| 213 |
+
await vm.load();
|
| 214 |
+
expect(vm.status, LibraryStatus.error);
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
Write `FakeCardRepository` in `app/test/fakes.dart` implementing only the members `LibraryViewModel` actually calls (check the VM; typically `list()` and the offline flag).
|
| 220 |
+
|
| 221 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `LibraryStatus.waking` undefined.
|
| 222 |
+
|
| 223 |
+
- [ ] **Step 3: Implement**
|
| 224 |
+
|
| 225 |
+
In `library_view_model.dart`: add `waking` to `LibraryStatus`; constructor param `this.wakeRetryDelay = const Duration(seconds: 10)`; wrap the load's fetch:
|
| 226 |
+
|
| 227 |
+
```dart
|
| 228 |
+
static const _maxWakeAttempts = 6;
|
| 229 |
+
int _wakeAttempt = 0;
|
| 230 |
+
int get wakeAttempt => _wakeAttempt;
|
| 231 |
+
|
| 232 |
+
Future<List<model.Card>> _fetchWithWake() async {
|
| 233 |
+
for (_wakeAttempt = 0; ; _wakeAttempt++) {
|
| 234 |
+
try {
|
| 235 |
+
return await repository.list();
|
| 236 |
+
} catch (e) {
|
| 237 |
+
final connectionShaped = e is! ApiException; // socket/timeouts, not HTTP
|
| 238 |
+
if (!connectionShaped || _wakeAttempt >= _maxWakeAttempts) rethrow;
|
| 239 |
+
_status = LibraryStatus.waking;
|
| 240 |
+
notifyListeners();
|
| 241 |
+
await Future<void>.delayed(wakeRetryDelay);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
and call `_fetchWithWake()` where `load()` currently calls the repository (keep the existing offline-cache fallback: if the cache has cards while waking fails, show cached cards with the offline chip instead — reuse the current offline path).
|
| 248 |
+
|
| 249 |
+
`library_screen.dart` `_body`, add before the error case:
|
| 250 |
+
|
| 251 |
+
```dart
|
| 252 |
+
case LibraryStatus.waking:
|
| 253 |
+
return _scrollable(
|
| 254 |
+
EmptyState(
|
| 255 |
+
showGlyph: true,
|
| 256 |
+
title: 'Waking Cachy up…',
|
| 257 |
+
message: 'The free server naps when idle. First load takes about '
|
| 258 |
+
'30 seconds — your library is on its way.',
|
| 259 |
+
),
|
| 260 |
+
);
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
- [ ] **Step 4: Share flow cold-start copy**
|
| 264 |
+
|
| 265 |
+
The share pipeline already degrades to `ShareStatus.queuedOffline` on connection failure (`share_screen.dart:129-149`) — that state doubles as the cold-start path for captures. Update its copy so a sleeping server doesn't read as "you're offline":
|
| 266 |
+
|
| 267 |
+
- Title: `'Saved offline'` → `'Saved — will process shortly'`
|
| 268 |
+
- Body: `"We'll process this reel as soon as you're back online."` → `"We'll process this as soon as Cachy is reachable — the free server can take ~30s to wake up."`
|
| 269 |
+
|
| 270 |
+
- [ ] **Step 5: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
|
| 271 |
+
|
| 272 |
+
- [ ] **Step 6: Commit** — `feat: cold-start waking state with auto-retry instead of instant failure`
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
### Task 4: Move to Folder — replace both stubs
|
| 277 |
+
|
| 278 |
+
**Files:**
|
| 279 |
+
- Modify: `app/lib/ui/features/library/view_models/library_view_model.dart` (`bulkMove`)
|
| 280 |
+
- Create: `app/lib/ui/features/collections/views/folder_picker_sheet.dart`
|
| 281 |
+
- Modify: `app/lib/ui/core/home_shell.dart:483-485` and `app/lib/ui/features/library/views/library_screen.dart:315-319` (call the picker; also dedupe `_confirmBulkDelete` into one shared helper `confirmBulkDelete(BuildContext, LibraryViewModel)` living in `library_view_model.dart`'s file or a small `library_dialogs.dart`)
|
| 282 |
+
- Create: `app/test/bulk_move_test.dart`
|
| 283 |
+
|
| 284 |
+
**Interfaces:**
|
| 285 |
+
- Consumes: `ApiClient.moveCardToCollection` + `listCollections`/`createCollection` (already exist).
|
| 286 |
+
- Produces: `Future<void> LibraryViewModel.bulkMove(String? collectionId)` — moves all selected cards, clears selection, refreshes; `Future<void> showFolderPicker(BuildContext context, LibraryViewModel vm)` — adaptive sheet listing folders + "New folder…" row.
|
| 287 |
+
|
| 288 |
+
- [ ] **Step 1: Write the failing test**
|
| 289 |
+
|
| 290 |
+
```dart
|
| 291 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 292 |
+
import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
|
| 293 |
+
|
| 294 |
+
import 'fakes.dart'; // FakeCardRepository records moveCardToCollection calls
|
| 295 |
+
|
| 296 |
+
void main() {
|
| 297 |
+
test('bulkMove moves every selected card and clears selection', () async {
|
| 298 |
+
final repo = FakeCardRepository(cards: ['a', 'b', 'c']);
|
| 299 |
+
final vm = LibraryViewModel(repository: repo);
|
| 300 |
+
await vm.load();
|
| 301 |
+
vm.toggleSelected('a');
|
| 302 |
+
vm.toggleSelected('c');
|
| 303 |
+
|
| 304 |
+
await vm.bulkMove('folder-1');
|
| 305 |
+
|
| 306 |
+
expect(repo.moves, {'a': 'folder-1', 'c': 'folder-1'});
|
| 307 |
+
expect(vm.selectionActive, isFalse);
|
| 308 |
+
});
|
| 309 |
+
}
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
Match the VM's real selection API names (`toggleSelected`/`selectionActive` — read the VM first and mirror; extend `FakeCardRepository` with a `moves` map).
|
| 313 |
+
|
| 314 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, `bulkMove` undefined.
|
| 315 |
+
|
| 316 |
+
- [ ] **Step 3: Implement**
|
| 317 |
+
|
| 318 |
+
`library_view_model.dart`:
|
| 319 |
+
|
| 320 |
+
```dart
|
| 321 |
+
/// Move every selected card into [collectionId] (null = remove from folders).
|
| 322 |
+
Future<void> bulkMove(String? collectionId) async {
|
| 323 |
+
final ids = List<String>.from(selectedIds);
|
| 324 |
+
try {
|
| 325 |
+
for (final id in ids) {
|
| 326 |
+
await repository.moveCardToCollection(id, collectionId);
|
| 327 |
+
}
|
| 328 |
+
clearSelection();
|
| 329 |
+
await refresh();
|
| 330 |
+
} catch (e) {
|
| 331 |
+
_error = friendlyError(e);
|
| 332 |
+
notifyListeners();
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
(If the repository lacks `moveCardToCollection`, add a one-line passthrough to `api.moveCardToCollection`.)
|
| 338 |
+
|
| 339 |
+
`folder_picker_sheet.dart` — adaptive modal (reuse `showAdaptiveModal`) listing collections from `ApiClient.listCollections()`, one `ListTile` per folder (folder icon, name), a divider, then "New folder…" which prompts a name via `AlertDialog` + `createCollection`, then moves. Selecting any row: `await vm.bulkMove(entry.id); Navigator.pop(ctx);` with a confirmation snackbar `Moved N cards to "<name>"`.
|
| 340 |
+
|
| 341 |
+
Replace both stubs: `onMoveToFolder: () => showFolderPicker(context, vm)`. Delete the duplicated `_confirmBulkDelete` from `home_shell.dart` and `library_screen.dart`; both call the new shared `confirmBulkDelete`.
|
| 342 |
+
|
| 343 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "coming soon" app/lib` — Expected: tests PASS, grep empty.
|
| 344 |
+
|
| 345 |
+
- [ ] **Step 5: Commit** — `feat: bulk Move to Folder (replaces coming-soon stubs); dedupe bulk-delete dialog`
|
| 346 |
+
|
| 347 |
+
---
|
| 348 |
+
|
| 349 |
+
### Task 5: Reduced motion honored everywhere
|
| 350 |
+
|
| 351 |
+
**Files:**
|
| 352 |
+
- Modify: `app/lib/ui/core/theme.dart` (motion gate helper)
|
| 353 |
+
- Modify: all 16 `.animate()` call sites + `AnimatedScale` durations (grep `\.animate(` and `Motion.fast`/`Motion.spring` under `app/lib`)
|
| 354 |
+
- Create: `app/test/reduced_motion_test.dart`
|
| 355 |
+
|
| 356 |
+
**Interfaces:**
|
| 357 |
+
- Produces: `extension MotionGate on BuildContext { bool get motionEnabled; Duration gated(Duration d); }` in theme.dart.
|
| 358 |
+
|
| 359 |
+
- [ ] **Step 1: Write the failing test**
|
| 360 |
+
|
| 361 |
+
```dart
|
| 362 |
+
import 'package:flutter/material.dart';
|
| 363 |
+
import 'package:flutter_test/flutter_test.dart';
|
| 364 |
+
import 'package:cachy/ui/core/theme.dart';
|
| 365 |
+
|
| 366 |
+
void main() {
|
| 367 |
+
testWidgets('motionEnabled follows MediaQuery.disableAnimations', (tester) async {
|
| 368 |
+
late bool enabled;
|
| 369 |
+
late Duration gated;
|
| 370 |
+
await tester.pumpWidget(MediaQuery(
|
| 371 |
+
data: const MediaQueryData(disableAnimations: true),
|
| 372 |
+
child: Builder(builder: (context) {
|
| 373 |
+
enabled = context.motionEnabled;
|
| 374 |
+
gated = context.gated(Motion.medium);
|
| 375 |
+
return const SizedBox();
|
| 376 |
+
}),
|
| 377 |
+
));
|
| 378 |
+
expect(enabled, isFalse);
|
| 379 |
+
expect(gated, Duration.zero);
|
| 380 |
+
});
|
| 381 |
+
}
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
- [ ] **Step 2: Run to verify failure** — Expected: FAIL, extension undefined.
|
| 385 |
+
|
| 386 |
+
- [ ] **Step 3: Implement**
|
| 387 |
+
|
| 388 |
+
`theme.dart`:
|
| 389 |
+
|
| 390 |
+
```dart
|
| 391 |
+
/// Reduced-motion gate: animations collapse to zero duration when the OS asks.
|
| 392 |
+
extension MotionGate on BuildContext {
|
| 393 |
+
bool get motionEnabled => !MediaQuery.of(this).disableAnimations;
|
| 394 |
+
Duration gated(Duration d) => motionEnabled ? d : Duration.zero;
|
| 395 |
+
}
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
Then sweep call sites:
|
| 399 |
+
- flutter_animate chains: `.animate()` → wrap the whole chain: `motionEnabled ? child.animate()...fadeIn(...) : child` OR simpler global switch in `main.dart` root build: `Animate.restartOnHotReload = true;` is unrelated — instead set `child.animate(autoPlay: context.motionEnabled)` where supported, else the conditional wrap. Use the conditional wrap; it's explicit and testable.
|
| 400 |
+
- `AnimatedScale`/`AnimatedContainer` durations: `duration: Motion.fast` → `duration: context.gated(Motion.fast)`.
|
| 401 |
+
- Also change `Motion.spring = Curves.easeOutBack` → `Curves.easeOutCubic` (bounce ban) and in `app/web/index.html` replace `cubic-bezier(0.34, 1.56, 0.64, 1)` with `cubic-bezier(0.22, 1, 0.36, 1)`.
|
| 402 |
+
|
| 403 |
+
- [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "easeOutBack" app/` — Expected: PASS, grep empty.
|
| 404 |
+
|
| 405 |
+
- [ ] **Step 5: Commit** — `fix: honor reduced-motion everywhere; retire bounce easing`
|
| 406 |
+
|
| 407 |
+
---
|
| 408 |
+
|
| 409 |
+
### Task 6: Onboarding brand pass + stale comments
|
| 410 |
+
|
| 411 |
+
**Files:**
|
| 412 |
+
- Modify: `app/lib/ui/features/onboarding/views/onboarding_screen.dart`
|
| 413 |
+
- Modify: `app/lib/ui/features/onboarding/views/name_screen.dart`
|
| 414 |
+
- Modify: `app/lib/ui/features/library/views/library_screen.dart:1-7` (doc comment)
|
| 415 |
+
|
| 416 |
+
**Interfaces:** none new — visual conformance only.
|
| 417 |
+
|
| 418 |
+
- [ ] **Step 1: Apply the brand pass**
|
| 419 |
+
|
| 420 |
+
`onboarding_screen.dart`:
|
| 421 |
+
- `_LogoBadge`: replace the lightning-in-a-box with the real mark: `Row(children: [const CachyGlyph(size: 30), const SizedBox(width: 10), Text('cachy', style: Brand.wordmarkStyle(20, color: scheme.onSurface))])` — delete the Container/lightning entirely.
|
| 422 |
+
- Every `GoogleFonts.fraunces(... fontWeight: FontWeight.w800 ...)` headline → `theme.textTheme.displaySmall` / `displayMedium` (Brand's w600 serif), keeping the accent-colored `TextSpan`s; drop the glow `Shadow` on "Captured." (calm over clever). Remove the `google_fonts` import.
|
| 423 |
+
- File doc comment: delete "adapted from Insightr (demo)" wording — describe what it is now.
|
| 424 |
+
|
| 425 |
+
`name_screen.dart`: headline `GoogleFonts.fraunces(...)` → `theme.textTheme.displaySmall`; remove the glow shadow and the `google_fonts` import.
|
| 426 |
+
|
| 427 |
+
`library_screen.dart` header comment: "Two segments — Cards and To-do" → "Three segments — Cards, Concepts, Catalog".
|
| 428 |
+
|
| 429 |
+
- [ ] **Step 2: Verify**
|
| 430 |
+
|
| 431 |
+
Run: `cd app && flutter analyze && flutter test` — Expected: clean, all PASS. Then `grep -rn "GoogleFonts" app/lib | grep -v core/brand.dart` — Expected: empty.
|
| 432 |
+
|
| 433 |
+
- [ ] **Step 3: Visual check** — `cd app && flutter run -d chrome` , clear browser storage to re-trigger onboarding; confirm the glyph mark, w600 headlines, no glow.
|
| 434 |
+
|
| 435 |
+
- [ ] **Step 4: Commit** — `polish: onboarding on-brand (glyph mark, w600 serif, no glow); fix stale comments`
|
docs/planning/plans/2026-07-10-v2-on-device-ai.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# V2 On-Device AI + Distribution Implementation Plan (Roadmap)
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** This is a V2 roadmap plan. Prerequisites: V1 plans (`2026-07-10-backend-auth-quotas.md`, `2026-07-10-flutter-auth.md`) executed and deployed. Before executing any task here, RE-VALIDATE the runtime choice (MediaPipe LLM Inference / `flutter_gemma` / model availability) — the on-device LLM ecosystem changes monthly. Then expand each task into full TDD steps via superpowers:writing-plans.
|
| 4 |
+
|
| 5 |
+
**Goal:** Quota-exhausted users with an installed local model get structured cards generated on their own phone — zero server AI spend. Plus Play Store readiness and sustainability hooks.
|
| 6 |
+
|
| 7 |
+
**Spec:** `docs/superpowers/specs/2026-07-10-v2-on-device-ai-design.md`
|
| 8 |
+
|
| 9 |
+
## Phase A — Backend bundle round-trip (independent, testable now-ish)
|
| 10 |
+
|
| 11 |
+
### Task A1: Persist raw bundle on degraded jobs
|
| 12 |
+
- `cards.raw_bundle: TEXT NULL` column + additive migration entry.
|
| 13 |
+
- Worker: when `job.degraded`, store the extraction bundle text on the card before paragraph fallback.
|
| 14 |
+
- Test: degraded job → `raw_bundle` set; normal job → NULL.
|
| 15 |
+
|
| 16 |
+
### Task A2: `GET /cards/{id}/bundle`
|
| 17 |
+
- Owner-scoped (OwnerDep). 200 `{bundle, transcript, caption}` when stored; 404 otherwise.
|
| 18 |
+
- Test: owner gets bundle; other uid 404; non-degraded card 404.
|
| 19 |
+
|
| 20 |
+
### Task A3: `POST /cards/{id}/structure`
|
| 21 |
+
- Owner-scoped. Body = client-generated structured card JSON.
|
| 22 |
+
- Validate with the SAME Pydantic models the LLM path uses (`structuring` validation) — reject invalid with 422.
|
| 23 |
+
- On accept: replace paragraph content, clear `raw_bundle`, re-run `_embed_card`, bump card state.
|
| 24 |
+
- Test: valid payload upgrades card + clears bundle; schema-garbage 422; foreign owner 404.
|
| 25 |
+
|
| 26 |
+
## Phase B — Flutter local AI
|
| 27 |
+
|
| 28 |
+
### Task B1: `LocalAiService` interface + fake
|
| 29 |
+
- Mirror the `AuthService` pattern: abstract interface, `FakeLocalAiService` for tests.
|
| 30 |
+
- API: `status` (notInstalled|downloading(progress)|ready|error), `download()`, `delete()`, `Future<Map<String,dynamic>?> structureBundle(String bundle, {String transcript, String caption})` (null = generation failed/invalid JSON).
|
| 31 |
+
|
| 32 |
+
### Task B2: Model download manager
|
| 33 |
+
- Resumable download of Gemma 3 1B int4 (~550 MB) to app storage, sha256 check, Wi-Fi-recommended warning dialog.
|
| 34 |
+
- State machine unit-tested against the fake HTTP layer.
|
| 35 |
+
|
| 36 |
+
### Task B3: Inference integration
|
| 37 |
+
- MediaPipe LLM Inference via platform channel (or `flutter_gemma` if healthy at build time). Android only; feature-gate everywhere else.
|
| 38 |
+
- Prompt: short instruction + one few-shot example + strict "JSON only" suffix; client-side JSON parse + block-schema validation; invalid → return null.
|
| 39 |
+
|
| 40 |
+
### Task B4: Wire the degrade path
|
| 41 |
+
- Card returns `quota_degraded` → if `LocalAiService.status == ready`: fetch `/bundle`, run `structureBundle`, POST `/structure`, refresh card. Progress UI in reader ("Generating on your phone…"), cancellable.
|
| 42 |
+
- Any failure → paragraph card remains, no error dialog (silent grace, log only).
|
| 43 |
+
- Widget test with fakes: degraded card + ready model → upgraded card; model returns null → paragraph kept.
|
| 44 |
+
|
| 45 |
+
### Task B5: Profile "Offline AI" section
|
| 46 |
+
- Download/enable/disable/delete UI, size on disk, honest copy ("~550 MB, runs on your phone, slower than cloud").
|
| 47 |
+
- Quota chip second state: "Generating on your phone".
|
| 48 |
+
|
| 49 |
+
## Phase C — Distribution & sustainability
|
| 50 |
+
|
| 51 |
+
### Task C1: Privacy policy page at `/privacy` on the Space; link from Profile → About.
|
| 52 |
+
### Task C2: Play Store submission — Data Safety form, target API check, store listing (screenshots from web build), $25 account.
|
| 53 |
+
### Task C3: Sponsors/Ko-fi link in Profile → About ("Keep Cachy free"); apply GitHub Student Pack + Cerebras/Groq edu credits.
|
| 54 |
+
### Task C4 (calendar ~1 month post-V1): delete `/auth/claim` + `claims` table + client claim flow.
|
| 55 |
+
|
| 56 |
+
## Phase D — Private media (thumbnails/keyframes off the public HF dataset)
|
| 57 |
+
|
| 58 |
+
Closes a real privacy gap: onboarding promises "Only you see your cards" but thumbnails currently sit in a **public** HF dataset repo. Spec: `docs/superpowers/specs/2026-07-10-v2-on-device-ai-design.md` Part 3.
|
| 59 |
+
|
| 60 |
+
### Task D1: Flip the HF dataset repo to private
|
| 61 |
+
- Manual, one-time: HF dataset settings → private. No code change. Verify with an unauthenticated raw-URL fetch attempt (must fail after the flip).
|
| 62 |
+
|
| 63 |
+
### Task D2: Owner-checked media proxy endpoint
|
| 64 |
+
- `GET /media/{card_id}/{filename}` — `OwnerDep`-gated (card's `owner_id` must match caller), streams bytes from the private HF dataset via `hf_api_key` (`hf_hub_download` or HF HTTP API), correct `content-type` by extension, `Cache-Control: private, max-age=3600`.
|
| 65 |
+
- Test: 404 for non-owned cards, 200 + correct bytes for owned cards, 401 unauthenticated.
|
| 66 |
+
|
| 67 |
+
### Task D3: Emit proxy paths instead of raw HF URLs
|
| 68 |
+
- Update wherever media refs are written into card JSON (`store/media.py` / `worker.py`) to emit `/media/{card_id}/{filename}` instead of the absolute HF dataset URL.
|
| 69 |
+
- Confirm `ApiClient.resolveMedia` needs no change (it already joins bare paths onto `baseUrl`; only absolute-URL passthrough becomes dead code for new cards).
|
| 70 |
+
- Regression: existing cards written before this change carry old absolute HF URLs — keep a fallback branch (client or backend) so pre-migration cards still resolve, or run a one-time backfill rewriting stored refs to the new scheme.
|
| 71 |
+
|
| 72 |
+
## Order & gates
|
| 73 |
+
|
| 74 |
+
- A before B (B needs the endpoints). C anytime after V1. D anytime after V1 (independent of A/B/C) — recommended right after V2's on-device work, per user sequencing preference; could move earlier since it's a live privacy gap, revisit if that becomes urgent.
|
| 75 |
+
- Gate before B3: benchmark chosen model on one real mid-range Android phone — if structuring takes >2 min or JSON validity <~70%, drop to paragraph-only and re-evaluate model choice.
|
| 76 |
+
- Gate before D1: confirm no other consumer (embed codes shared externally, cached CDN links, etc.) depends on the media repo being public before flipping it private.
|
docs/planning/specs/2026-07-10-public-distribution-auth-quotas-design.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cachy Public Distribution: Auth, Quotas, Backend Hardening
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-07-10
|
| 4 |
+
**Status:** Approved
|
| 5 |
+
**Scope:** V1 for distributing Cachy to non-technical users (GitHub APK + hosted web) while shared free-tier AI keys survive. On-device model, Play Store, payments, email auth are explicitly out of scope (V2+).
|
| 6 |
+
|
| 7 |
+
## Problem
|
| 8 |
+
|
| 9 |
+
Cachy today identifies users by a self-declared name sent as the `x-owner-id` header. The backend trusts it blindly:
|
| 10 |
+
|
| 11 |
+
- Anyone can read any user's library by guessing a name (`curl -H "x-owner-id: Vatsal"`).
|
| 12 |
+
- Name collisions silently merge libraries.
|
| 13 |
+
- Per-user quotas are unenforceable (attacker rotates header values), so shared free-tier API keys (Cerebras, Groq, Gemini pool) cannot be protected.
|
| 14 |
+
- `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck` are unauthenticated; 500 responses leak tracebacks; CORS is `*`.
|
| 15 |
+
|
| 16 |
+
## Decision Summary
|
| 17 |
+
|
| 18 |
+
| Decision | Choice |
|
| 19 |
+
|---|---|
|
| 20 |
+
| Identity | Firebase Auth: anonymous sign-in on first launch, optional upgrade to Google Sign-In via `linkWithCredential` (same uid, data preserved) |
|
| 21 |
+
| Token transport | `Authorization: Bearer <Firebase ID token>` on every API request |
|
| 22 |
+
| Backend verification | `firebase-admin` `verify_id_token`; `owner_id` = verified `uid` |
|
| 23 |
+
| Quota policy | Per-user daily quotas; past card quota → degrade to existing paragraph-fallback path (card still saves), never hard-fail |
|
| 24 |
+
| Quota storage | `usage(owner_id, day, kind, count)` table in existing DB (SQLite / Neon) |
|
| 25 |
+
| Abuse defense | Per-IP daily cap on card creation (same usage table, keyed by IP) |
|
| 26 |
+
| Admin/debug routes | Gated by `X-Admin-Token` matching env secret |
|
| 27 |
+
| Migration | Temporary `/auth/claim` endpoint: authed user claims rows whose `owner_id` equals their old display name, first-claim-wins; removed after ~1 month |
|
| 28 |
+
|
| 29 |
+
### Why Firebase Auth (alternatives considered)
|
| 30 |
+
|
| 31 |
+
- **Firebase Auth (chosen):** anonymous→Google linking is a native feature; free unlimited for anonymous + Google providers; backend verification is one call. Google lock-in acceptable — stack is already Google-centric (Gemini keys, Android target).
|
| 32 |
+
- **Supabase Auth:** comparable free tier but anonymous→permanent linking is manual, and it adds a new vendor.
|
| 33 |
+
- **Hand-rolled JWT (Google Identity Services direct):** no new dependency but we own token issuance, refresh, revocation — auth bugs become breaches. Rejected.
|
| 34 |
+
|
| 35 |
+
## Architecture
|
| 36 |
+
|
| 37 |
+
### 1. Client auth flow (Flutter)
|
| 38 |
+
|
| 39 |
+
- Packages: `firebase_auth`, `google_sign_in` (+ `firebase_core`).
|
| 40 |
+
- **First launch:** onboarding (including the existing name screen) runs first; the login screen comes after it:
|
| 41 |
+
- Primary button: "Continue with Google" (`signInWithCredential`).
|
| 42 |
+
- Below, small quiet text link: "Or use without login…" → `signInAnonymously()`.
|
| 43 |
+
- Either path yields a stable Firebase `uid`.
|
| 44 |
+
- The name entered during onboarding is kept for both paths — it stays the display/greeting name (local + stored server-side as profile field), but is no longer the identity; `uid` is.
|
| 45 |
+
- **Anonymous upgrade:** Profile screen shows a persistent banner while anonymous: "Your library isn't backed up — sign in with Google." Tapping runs `linkWithCredential(GoogleAuthProvider credential)` — uid unchanged, no data migration.
|
| 46 |
+
- **Warning copy:** anonymous users are told data may be lost if the app is uninstalled / data cleared.
|
| 47 |
+
- Display name: everyone types a name during onboarding (kept as greeting/profile name); Google linking may additionally surface the Google profile name/avatar. Name is never identity.
|
| 48 |
+
|
| 49 |
+
### 2. Token transport (ApiClient)
|
| 50 |
+
|
| 51 |
+
- `_ownerHeader` in `app/lib/data/services/api_client.dart` is replaced by an auth header provider: every request attaches `Authorization: Bearer <ID token>` from `FirebaseAuth.instance.currentUser.getIdToken()` (SDK caches + auto-refreshes; cheap to call per request).
|
| 52 |
+
- SSE stream request (`streamCard`) attaches the same header.
|
| 53 |
+
|
| 54 |
+
### 3. Backend verification (FastAPI)
|
| 55 |
+
|
| 56 |
+
- New dependency `get_owner(request) -> str`:
|
| 57 |
+
- Extracts bearer token, verifies via `firebase_admin.auth.verify_id_token` (public JWKS; needs only the Firebase project ID configured via env).
|
| 58 |
+
- Returns `uid`; raises 401 on missing/invalid/expired token.
|
| 59 |
+
- Every route currently reading `x-owner-id` swaps to `Depends(get_owner)`. The `owner_id` column semantics are unchanged — only its source becomes trustworthy.
|
| 60 |
+
- `x-owner-id` header support is removed entirely (no fallback); the legacy name only survives as the body parameter of the temporary `/auth/claim` endpoint below.
|
| 61 |
+
|
| 62 |
+
### 4. Migration of existing name-based data
|
| 63 |
+
|
| 64 |
+
- `POST /auth/claim {name: str}` (authenticated): re-points all rows with `owner_id == name` to the caller's `uid`, if that name has not already been claimed. First-claim-wins; a `claims` table records name→uid to prevent double claims.
|
| 65 |
+
- Client: after first sign-in, if a legacy local `userName` exists, offer "Restore my old library" which calls this endpoint.
|
| 66 |
+
- Endpoint is temporary; delete after ~1 month.
|
| 67 |
+
|
| 68 |
+
### 5. Quotas
|
| 69 |
+
|
| 70 |
+
- Table: `usage(owner_id TEXT, day TEXT, kind TEXT, count INT, PRIMARY KEY(owner_id, day, kind))`. Day = UTC date string.
|
| 71 |
+
- FastAPI dependency factory `spend_quota(kind, daily_limit)` applied to expensive routes:
|
| 72 |
+
- `POST /cards` (AI card creation): **10/day**
|
| 73 |
+
- Card chat + library chat + rabbithole: **30/day** combined
|
| 74 |
+
- `GET /connections?refresh=true`: **3/day**
|
| 75 |
+
- Limits are env-configurable (`QUOTA_CARDS_PER_DAY`, etc.).
|
| 76 |
+
- **Degrade, don't fail (cards):** past quota, `POST /cards` still succeeds but the job row is flagged `degraded=1`; the worker skips LLM structuring and uses the existing paragraph-fallback path. This flag is also the V2 hook — a device with an installed local model can fetch the raw bundle and structure it client-side.
|
| 77 |
+
- **Chat past quota:** 429 with structured body `{error: "quota", kind, used, limit, resets_at}`; UI renders "recharges tomorrow" state.
|
| 78 |
+
- Quota status: expensive-route responses include `quota: {used, limit}`; plus `GET /me/quota` for the profile meter.
|
| 79 |
+
- **Anon-farming defense:** card creation also increments a per-IP counter (same table, `owner_id = "ip:<addr>"`), capped at e.g. 30/day/IP (env-configurable). Prevents scripted fresh anonymous uids from draining keys.
|
| 80 |
+
|
| 81 |
+
### 6. Backend hardening
|
| 82 |
+
|
| 83 |
+
- `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck`: require `X-Admin-Token` header equal to `ADMIN_TOKEN` env secret (404/401 otherwise).
|
| 84 |
+
- Global 500 handler: full traceback to server logs only; client gets `{"detail": "internal error"}`.
|
| 85 |
+
- CORS: `allow_origins` restricted to the hosted Space origin + `http://localhost:*` dev origins (native APK traffic is unaffected by CORS).
|
| 86 |
+
|
| 87 |
+
### 7. UI changes (Flutter)
|
| 88 |
+
|
| 89 |
+
- **Login screen** (new, shown after onboarding/name screen): Google button + small "Or use without login…" link; brand styling consistent with `brand.dart` tokens.
|
| 90 |
+
- **Profile screen:** account section — avatar/name/email when linked; "Sign in with Google" banner when anonymous; quota meter ("7/10 AI cards today"); sign out.
|
| 91 |
+
- **Share/pipeline UI:** quota chip; degraded card shows "AI recharges tomorrow".
|
| 92 |
+
|
| 93 |
+
### 8. Error handling
|
| 94 |
+
|
| 95 |
+
- 401 from API → client forces token refresh once, retries; still 401 → return to login screen.
|
| 96 |
+
- Firebase unreachable at launch → app opens read-only from repository cache with "reconnecting" banner.
|
| 97 |
+
- Quota 429 → friendly states everywhere; raw errors never shown.
|
| 98 |
+
- `verify_id_token` failures log the reason server-side (expired vs malformed vs wrong project).
|
| 99 |
+
|
| 100 |
+
### 9. Testing
|
| 101 |
+
|
| 102 |
+
- `backend/tests/` is deleted on the current branch — restore the pytest harness first.
|
| 103 |
+
- Backend: `get_owner` (valid/expired/forged/missing token, mocked verifier); quota spend/rollover/degrade flag; per-IP cap; admin-token gate; `/auth/claim` first-claim-wins.
|
| 104 |
+
- Flutter: auth controller transitions (anonymous → linked); quota meter widget; 401-retry logic in ApiClient.
|
| 105 |
+
|
| 106 |
+
## UI trust & polish fixes (from 2026-07-10 whole-app critique, score 26/40)
|
| 107 |
+
|
| 108 |
+
Ship with this release; full report in `.impeccable/critique/2026-07-10T11-11-16Z__app-lib.md`.
|
| 109 |
+
|
| 110 |
+
**P1 — trust (all four required):**
|
| 111 |
+
1. **Real cache clear**: `_confirmClear` (profile_screen.dart) currently shows "Offline cache cleared" without clearing anything. Wire to the local store's actual clear, or remove the tile.
|
| 112 |
+
2. **Move to Folder**: implement the bulk-selection "Move to Folder" action (collections + move endpoint already exist); replace both "coming soon" stubs (home_shell.dart, library_screen.dart). Dedupe the duplicated bulk-delete dialog while there.
|
| 113 |
+
3. **Friendly errors**: map `ApiException` → human strings at the repository boundary; never surface `e.toString()` in snackbars/error states (pairs with server-side traceback removal above).
|
| 114 |
+
4. **Cold-start state**: HF Space sleeps; first connect must show "Waking Cachy up — first load takes ~30s" with auto-retry (library + share view models) instead of "Can't reach Cachy".
|
| 115 |
+
|
| 116 |
+
**P2 — polish:**
|
| 117 |
+
5. **Reduced motion**: honor `MediaQuery.disableAnimations` via one shared gate covering all flutter_animate/AnimatedScale usages.
|
| 118 |
+
6. **Onboarding brand pass**: replace lightning `_LogoBadge` with CachyGlyph, Fraunces w800 → w600, route type through `Brand` instead of direct GoogleFonts (onboarding_screen.dart, name_screen.dart). This screen precedes the new login screen, so it's touched anyway.
|
| 119 |
+
7. **Motion curve**: `Motion.spring` easeOutBack → easeOutCubic (and the matching bounce cubic-bezier in app/web/index.html splash).
|
| 120 |
+
|
| 121 |
+
**Minors (fold in where files are already open):** stale doc comments (library_screen.dart header), audit stray hex literals outside tokens, dev password constant gets a `ponytail:` note or moves behind the admin token.
|
| 122 |
+
|
| 123 |
+
## One-time owner setup (~30 min)
|
| 124 |
+
|
| 125 |
+
1. Create Firebase project; enable Anonymous + Google sign-in providers.
|
| 126 |
+
2. Android: add `google-services.json`, register release-keystore SHA-1.
|
| 127 |
+
3. Web: Firebase JS config in Flutter web init.
|
| 128 |
+
4. HF Space env: `FIREBASE_PROJECT_ID` (token verification needs no service-account secret for `verify_id_token` with JWKS), `ADMIN_TOKEN`, quota env vars.
|
| 129 |
+
|
| 130 |
+
## Out of scope (V2+)
|
| 131 |
+
|
| 132 |
+
- On-device quantized model (opt-in "offline AI" past quota) — the `degraded` job flag + raw-bundle fetch is the designed extension point.
|
| 133 |
+
- Play Store / App Store distribution, payments, email/password auth, multi-device conflict resolution beyond what Firebase uid sync gives for free.
|
docs/planning/specs/2026-07-10-v2-on-device-ai-design.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cachy V2: On-Device AI + Distribution Growth
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-07-10
|
| 4 |
+
**Status:** Draft (V2 — build after the V1 auth/quota release ships and stabilizes)
|
| 5 |
+
**Depends on:** `2026-07-10-public-distribution-auth-quotas-design.md` (V1) — specifically the `degraded` job flag, quota system, and Firebase identity.
|
| 6 |
+
|
| 7 |
+
## Problem
|
| 8 |
+
|
| 9 |
+
V1 caps each user at N AI cards/day so shared free-tier keys survive. Past quota, cards degrade to paragraph fallback. Power users want more without the developer paying for it. V1's answer was "wait until tomorrow"; V2's answer is: **bring your own compute** — an optional quantized model running on the user's phone.
|
| 10 |
+
|
| 11 |
+
Also collected here: the rest of the deferred V2 items (store distribution, sustainability, claim-endpoint removal) so V2 has one source of truth.
|
| 12 |
+
|
| 13 |
+
## Part 1 — On-device AI (headline)
|
| 14 |
+
|
| 15 |
+
### Decision summary
|
| 16 |
+
|
| 17 |
+
| Decision | Choice | Why |
|
| 18 |
+
|---|---|---|
|
| 19 |
+
| Split point | Server extracts (download, whisper, OCR → text bundle); device structures (bundle → card JSON) | Extraction needs yt-dlp/ffmpeg — impossible on phone. Structuring is pure text→JSON — exactly what a small LLM can do |
|
| 20 |
+
| Trigger | Quota-degraded cards, when local model installed & enabled | Reuses V1's `degraded` flag; zero new quota logic |
|
| 21 |
+
| Runtime | MediaPipe LLM Inference API (Android), via `flutter_gemma` or direct platform channel | Google-maintained, handles quantized Gemma models on-device, no NDK build of llama.cpp needed. Re-validate at build time — this space moves fast |
|
| 22 |
+
| Model | Gemma 3 1B, int4 quantized (~550 MB) | Best small-model JSON-following per size; MediaPipe-native. Fallback candidate: Qwen2.5-1.5B-Instruct q4 |
|
| 23 |
+
| Platforms | Android APK only | Web can't (practically); iOS not distributed yet |
|
| 24 |
+
| Opt-in UX | Profile → "Offline AI" section: explicit download (~550 MB warning, Wi-Fi recommended), enable/disable toggle, delete model | Never surprise-download half a gigabyte |
|
| 25 |
+
| Output guard | Client validates generated JSON against the card block schema; invalid → paragraph fallback (same as server) | Small models fail JSON sometimes; the card must always render |
|
| 26 |
+
|
| 27 |
+
### Architecture
|
| 28 |
+
|
| 29 |
+
1. **Server persists the bundle for degraded jobs.** Worker, on a `degraded` job, stores the extraction bundle (transcript + OCR text + caption — text only, a few KB) on the card row (`cards.raw_bundle` TEXT, nullable) instead of discarding it after paragraph fallback.
|
| 30 |
+
2. **New endpoint** `GET /cards/{id}/bundle` (auth: owner only) → `{"bundle": str, "transcript": str, "caption": str}`; 404 when no stored bundle.
|
| 31 |
+
3. **New endpoint** `POST /cards/{id}/structure` (auth: owner only) → accepts client-generated `{blocks: [...], one_liner, tags, ...}`, validated server-side with the **same** Pydantic validation the LLM path uses (never trust device output), replaces the paragraph card's content, clears `raw_bundle`, re-embeds.
|
| 32 |
+
4. **Flutter `LocalAiService`:** model download manager (resumable, checksum), inference wrapper (`structureBundle(bundle) -> Map?`), prompt tuned for 1B models (short system prompt, few-shot single example, strict JSON instruction).
|
| 33 |
+
5. **Flow:** card comes back `quota_degraded` → app checks Local AI enabled → fetches bundle → structures on-device (progress UI: "Generating on your phone…") → POSTs result → card upgrades in place. Failure at any step → paragraph card stays (user never worse off).
|
| 34 |
+
|
| 35 |
+
### UX rules
|
| 36 |
+
|
| 37 |
+
- Quota chip gains a second state: past quota + model installed → "Generating on your phone" instead of "AI recharges tomorrow".
|
| 38 |
+
- Generation runs foreground-visible (reader shows progress), cancellable. No silent battery drain.
|
| 39 |
+
- Settings shows model size on disk, last-used, delete button.
|
| 40 |
+
|
| 41 |
+
### Explicitly rejected
|
| 42 |
+
|
| 43 |
+
- Shipping the whole pipeline on-device (yt-dlp on Android: no).
|
| 44 |
+
- Auto-downloading the model for everyone.
|
| 45 |
+
- Trusting device JSON without server-side validation (auth'd users could inject arbitrary blocks otherwise).
|
| 46 |
+
|
| 47 |
+
## Part 2 — Distribution & sustainability (V2 grab-bag)
|
| 48 |
+
|
| 49 |
+
1. **Play Store**: $25 one-time, release signing already exists; needs privacy policy page (host on the HF Space at `/privacy`), Data Safety form (declares: account IDs, user content stored server-side), target API compliance. Do after auth ships — store review with anonymous+Google auth is straightforward.
|
| 50 |
+
2. **Sustainability hooks**: GitHub Sponsors / Ko-fi link in Profile → About ("Keep Cachy free"); apply for GitHub Student Pack + Cerebras/Groq startup/edu credits to raise free ceilings.
|
| 51 |
+
3. **Cleanup task**: delete `/auth/claim` endpoint + `claims` table ~1 month after V1 auth ships (calendar it).
|
| 52 |
+
4. **Deferred still**: email/password auth, payments, iOS, multi-device conflict resolution. Not in V2 either unless users demand.
|
| 53 |
+
|
| 54 |
+
## Part 3 — Private media (thumbnails/keyframes off the public HF dataset)
|
| 55 |
+
|
| 56 |
+
### Problem
|
| 57 |
+
|
| 58 |
+
Thumbnails and keyframes for every user's saved content currently live in a **public** HF dataset repo (`hf_media_repo` in `config.py`). The onboarding name screen promises "Your library stays private. Only you see your cards" — false for images today. Backend currently returns HF dataset URLs directly to the client (`resolveMedia` in `api_client.dart` joins bare paths, or passes through absolute URLs).
|
| 59 |
+
|
| 60 |
+
### Decision
|
| 61 |
+
|
| 62 |
+
Flip the HF dataset repo to **private**, add an owner-checked proxy endpoint, keep everything else (HF storage, `hf_api_key`) as-is. No new vendor — R2/S3 migration stays a "later, if bandwidth ever hurts" option, not part of this task (ponytail: don't add infrastructure for a problem that doesn't exist yet at this scale).
|
| 63 |
+
|
| 64 |
+
### Architecture
|
| 65 |
+
|
| 66 |
+
1. **Flip `hf_media_repo` to private** in the HF dataset settings (one-time manual step, no code).
|
| 67 |
+
2. **New endpoint** `GET /media/{card_id}/{filename}` — `OwnerDep`-gated (must own the card, verified via `CardRow.owner_id`), streams bytes from the private HF dataset using the existing `hf_api_key` (server-side `hf_hub_download` or the HF `hfh` HTTP API), sets correct `content-type` from the file extension.
|
| 68 |
+
3. **Backend media writer** (wherever thumbnails/keyframes are currently persisted, likely `store/media.py`) stores media under a `{card_id}/...` path scheme if not already, so the proxy route can validate ownership before any HF fetch.
|
| 69 |
+
4. **Client**: `ApiClient.resolveMedia` continues to just join bare paths onto `baseUrl` — no change needed if the backend now returns `/media/{card_id}/{filename}` instead of absolute HF URLs (check `structuring.py`/`worker.py` for where media refs are written into card JSON; update to emit the proxy path instead of the raw HF URL).
|
| 70 |
+
5. **Caching**: proxy response gets `Cache-Control: private, max-age=3600` (browser/client caches per-session; still requires re-auth on a fresh session, avoiding stale public exposure).
|
| 71 |
+
|
| 72 |
+
### Testing
|
| 73 |
+
|
| 74 |
+
- Backend: proxy route 404s for non-owned cards; 200 + correct bytes for owned cards; unauthenticated request 401.
|
| 75 |
+
- Manual: confirm the HF dataset repo is actually private (attempt an unauthenticated raw HF URL fetch — must fail) after the flip.
|
| 76 |
+
- Regression: existing cards' media still resolves after the path-scheme change (migrate old absolute-URL refs or keep a fallback branch in `resolveMedia`/backend for pre-migration cards).
|
| 77 |
+
|
| 78 |
+
## Success criteria
|
| 79 |
+
|
| 80 |
+
- A quota-exhausted user with the model installed gets a structured (not paragraph) card, fully offline of AI providers, in <2 min on a mid-range phone.
|
| 81 |
+
- Server AI spend for that card: zero.
|
| 82 |
+
- Users without the model see zero change.
|
| 83 |
+
- No thumbnail or keyframe is fetchable without a valid owner session (public dataset browsing no longer exposes any user's media).
|
| 84 |
+
|
| 85 |
+
## Testing
|
| 86 |
+
|
| 87 |
+
- Backend: bundle persisted only for degraded jobs; `/bundle` owner-scoped 404/200; `/structure` rejects schema-invalid payloads, upgrades card, clears bundle.
|
| 88 |
+
- Flutter: `LocalAiService` faked in tests (interface like `AuthService`); download-manager state machine (idle→downloading→ready→error) unit-tested; JSON-invalid model output → paragraph kept.
|
| 89 |
+
- Manual: one real mid-range Android device before release (emulators lie about inference speed).
|