From 3989dcf5ced5b11138c649de06864bc57af88492 Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 19:05:05 +0400 Subject: [PATCH 1/8] feat: refactor application structure and implement new features - Introduced providers for database and services using Riverpod for better state management. - Removed legacy services: FetchService, HistoryService, and HookService. - Added new widgets for common UI elements and a scanner page for barcode scanning. - Implemented a bottom sheet for logging food portions with meal type selection. - Updated pubspec.yaml with new dependencies and versioning. - Added comprehensive tests for database operations, services, and article extraction. - Enhanced the app description to reflect its new purpose as a personal kcal tracker and meal maker. --- .github/workflows/cicd.yml | 28 +- PLAN.md | 75 + README.md | 39 +- analysis_options.yaml | 39 +- android/app/build.gradle | 6 +- android/app/src/main/AndroidManifest.xml | 4 +- lib/app/app.dart | 73 + lib/app/router.dart | 132 + lib/app/theme.dart | 74 + lib/data/db/database.dart | 150 + lib/data/db/database.g.dart | 3068 ++++++++++++++++++++ lib/data/meal_type.dart | 29 + lib/data/services/anthropic_service.dart | 324 +++ lib/data/services/article_extractor.dart | 93 + lib/data/services/off_service.dart | 99 + lib/data/services/settings_service.dart | 73 + lib/features/groceries/groceries_page.dart | 220 ++ lib/features/kitchen/kitchen_page.dart | 422 +++ lib/features/meals/meals_page.dart | 303 ++ lib/features/reader/reader_page.dart | 143 + lib/features/reader/summary_page.dart | 222 ++ lib/features/settings/settings_page.dart | 180 ++ lib/features/track/track_page.dart | 453 +++ lib/main.dart | 27 +- lib/model/article.dart | 15 - lib/model/article_controller.dart | 17 - lib/page/generation_page.dart | 134 - lib/page/history_page.dart | 89 - lib/page/images_page.dart | 40 - lib/page/search_page.dart | 121 - lib/page/settings_page.dart | 138 - lib/page/simplify_page.dart | 95 - lib/page/view_page.dart | 47 - lib/page/welcome_page.dart | 124 - lib/providers.dart | 138 + lib/services/fetch_service.dart | 29 - lib/services/history_service.dart | 101 - lib/services/hook_service.dart | 71 - lib/view/article_view.dart | 120 - lib/widgets/common.dart | 76 + lib/widgets/log_portion_sheet.dart | 169 ++ lib/widgets/scanner_page.dart | 75 + pubspec.yaml | 113 +- test/app_test.dart | 79 + test/data/database_test.dart | 93 + test/services/anthropic_service_test.dart | 98 + test/services/article_extractor_test.dart | 49 + test/services/off_service_test.dart | 80 + 48 files changed, 7087 insertions(+), 1300 deletions(-) create mode 100644 PLAN.md create mode 100644 lib/app/app.dart create mode 100644 lib/app/router.dart create mode 100644 lib/app/theme.dart create mode 100644 lib/data/db/database.dart create mode 100644 lib/data/db/database.g.dart create mode 100644 lib/data/meal_type.dart create mode 100644 lib/data/services/anthropic_service.dart create mode 100644 lib/data/services/article_extractor.dart create mode 100644 lib/data/services/off_service.dart create mode 100644 lib/data/services/settings_service.dart create mode 100644 lib/features/groceries/groceries_page.dart create mode 100644 lib/features/kitchen/kitchen_page.dart create mode 100644 lib/features/meals/meals_page.dart create mode 100644 lib/features/reader/reader_page.dart create mode 100644 lib/features/reader/summary_page.dart create mode 100644 lib/features/settings/settings_page.dart create mode 100644 lib/features/track/track_page.dart delete mode 100644 lib/model/article.dart delete mode 100644 lib/model/article_controller.dart delete mode 100644 lib/page/generation_page.dart delete mode 100644 lib/page/history_page.dart delete mode 100644 lib/page/images_page.dart delete mode 100644 lib/page/search_page.dart delete mode 100644 lib/page/settings_page.dart delete mode 100644 lib/page/simplify_page.dart delete mode 100644 lib/page/view_page.dart delete mode 100644 lib/page/welcome_page.dart create mode 100644 lib/providers.dart delete mode 100644 lib/services/fetch_service.dart delete mode 100644 lib/services/history_service.dart delete mode 100644 lib/services/hook_service.dart delete mode 100644 lib/view/article_view.dart create mode 100644 lib/widgets/common.dart create mode 100644 lib/widgets/log_portion_sheet.dart create mode 100644 lib/widgets/scanner_page.dart create mode 100644 test/app_test.dart create mode 100644 test/data/database_test.dart create mode 100644 test/services/anthropic_service_test.dart create mode 100644 test/services/article_extractor_test.dart create mode 100644 test/services/off_service_test.dart diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index b0c9ea2..e7629a1 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,7 +1,7 @@ -name: build +name: ci on: [ pull_request ] jobs: - build-android: + check-and-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -11,17 +11,15 @@ jobs: java-version: '21' - uses: subosito/flutter-action@v2 with: - flutter-version: '3.22.0' channel: stable - - name: Build apk - run: | - flutter pub get - dart pub outdated - dart format - flutter build appbundle --debug - - - - - - + cache: true + - name: Install dependencies + run: flutter pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed lib test + - name: Analyze + run: flutter analyze + - name: Test + run: flutter test + - name: Build APK + run: flutter build apk --debug diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ad0a1c8 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,75 @@ +# Readly 3.0 — Overhaul Plan + +Transform Readly from a single-purpose "article summarizer" into a personal **kcal tracker + meal maker**, with the article synthesis kept as a side feature. Personal app: no accounts, no backend, everything on-device. + +## Architecture decisions + +| Area | Choice | Why | +|---|---|---| +| UI framework | Flutter (Material 3, seed-color theme, rounded 20–28px radii) | Existing codebase, modern look for free | +| State management | `flutter_riverpod` 3.x (no codegen) | Reactive, testable, minimal boilerplate | +| Navigation | `go_router` + `StatefulShellRoute` (5-tab `NavigationBar`) | Preserves per-tab state, deep-linkable | +| Local database | `drift` (SQLite) + `drift_flutter` | Type-safe, reactive streams drive the UI, testable | +| Barcode scanning | `mobile_scanner` 7.x | Maintained, CameraX-based | +| Food data | Open Food Facts API v2 (plain `http`, `User-Agent` set) | Free, no key, FR products well covered | +| AI | Anthropic Messages API via raw HTTP (`claude-opus-4-8`), BYOK key in `flutter_secure_storage` | `chat_gpt_sdk` is dead; no official Dart SDK → raw HTTP + SSE streaming; structured outputs (`output_config.format`) for meals/groceries | +| Article extraction | In-app: fetch page + strip text with `html` package | Removes dependency on the (likely dead) `readly.lightin.io` readability API | +| Fonts | `google_fonts` (Outfit / Manrope style) | Clean rounded look without bundling font files | +| Lint/CI | `flutter_lints` 6 + stricter rules; GitHub Actions: analyze → test → build | There was no analyze/test step at all | + +## Pages (5 tabs + settings) + +1. **Track** (home) — today's kcal ring vs. daily goal, meal log (breakfast/lunch/dinner/snack), quick-add, barcode-scan-to-log (OFF lookup → portion → kcal), log straight from pantry. +2. **Kitchen** — pantry stock. Scan or manual add; each item stores OFF nutrition (kcal/100 g, macros) and a "quantity left" slider (0–100 %). Edit/delete. +3. **Meals** — AI meal maker. Sends pantry (with quantities left), remaining kcal today and language to Claude → 3 low-effort meal suggestions (title, time, kcal, steps, used + missing ingredients). Missing ingredients → one-tap add to groceries. "I made it" → logs kcal. +4. **Groceries** — checklist. Manual add + AI proposition (pantry + recent consumption → suggested purchases with reasons). Check off, clear done. +5. **Read** — legacy feature. Paste URL or Android share-sheet → extract text → stream Claude summary → saved history. + +**Settings** (gear on every tab): Anthropic API key (secure storage), summary/meal language, daily kcal goal. + +## Data model (drift tables) + +- `PantryItems` — barcode?, name, brand?, imageUrl?, kcalPer100g?, proteins/carbs/sugars/fats per 100 g?, packageQuantity?, amountLeft (0..1), addedAt, updatedAt +- `ConsumptionEntries` — name, kcal, mealType, pantryItemId?, grams?, loggedAt +- `ShoppingItems` — name, note? (AI reason), done, source (manual/ai), addedAt +- `Articles` — url, title, summary, createdAt (synthesis history) + +## Task list + +### Phase 0 — Toolchain & hygiene +- [x] Audit existing code (1 200 lines, 11 files) and Android config +- [ ] Rewrite `pubspec.yaml`: drop `chat_gpt_sdk`, `animated_image_list`; add riverpod, go_router, drift(+dev,build_runner), mobile_scanner, google_fonts; bump SDK to `^3.12.0`, version `3.0.0` +- [ ] Stricter `analysis_options.yaml` (flutter_lints 6 + extra rules) +- [ ] Android: add `CAMERA` permission, verify minSdk/compileSdk for mobile_scanner +- [ ] CI: analyze + test + release-build APK, current Flutter, drop `dart format` misuse + +### Phase 1 — Core plumbing +- [ ] Theme (Material 3, light+dark, rounded shapes, google_fonts) +- [ ] Router (5-branch StatefulShellRoute + /settings + /scan) +- [ ] Drift database + DAOs + codegen +- [ ] SettingsService (API key secure, goal/language prefs) +- [ ] AnthropicService: SSE streaming (`summarize`) + structured-output (`suggestMeals`, `suggestGroceries`) against `claude-opus-4-8` +- [ ] OpenFoodFactsService: product by barcode (v2 API, staging-safe parsing) +- [ ] ArticleExtractor: fetch URL → title + readable text +- [ ] Share-intent hook → Read tab + +### Phase 2 — Features +- [ ] Track page (ring painter, grouped log, quick add sheet, scan-to-log flow, log-from-pantry) +- [ ] Kitchen page (list, scan-to-add flow, manual add/edit sheet, amount-left slider, delete) +- [ ] Meals page (suggestion cards, missing→groceries, "I made it" → log) +- [ ] Groceries page (checklist, manual add, AI proposition, clear done) +- [ ] Read page (URL field, history list, streaming summary view) +- [ ] Settings page + +### Phase 3 — Quality +- [ ] Unit tests: OFF response parsing, SSE stream parsing, AI JSON parsing, kcal math, article extraction +- [ ] Widget test: app boots, 5 tabs navigate +- [ ] `flutter analyze` clean, `dart format` clean +- [ ] `flutter build apk` passes +- [ ] Delete dead code (old pages/services), update README + +### Later / ideas (not in this pass) +- [ ] Decrement pantry quantities automatically when a meal is cooked +- [ ] Weekly kcal/macro charts +- [ ] Off-line queue for OFF lookups +- [ ] iOS share-extension re-test (Android is the target device) diff --git a/README.md b/README.md index 9b470ba..46d4b50 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,35 @@ -# readly +# Readly -A new Flutter project. +Personal Android app: **kcal tracker + AI meal maker**, with article summarization as a side feature. Built for one user — no accounts, no backend, everything on-device. -## Getting Started +## Features -This project is a starting point for a Flutter application. +- **Track** — daily kcal ring vs. your goal. Log by barcode scan (Open Food Facts), from your pantry, or quick-add. +- **Kitchen** — scan what you have at home; estimate how much is left in each package with a slider. +- **Meals** — Claude suggests 3 healthy, low-effort meals from what you actually own and your remaining kcal. Missing ingredients go to the grocery list in one tap. +- **Groceries** — checklist with AI purchase propositions based on your pantry and eating habits. +- **Read** — share any web page to Readly (or paste a URL) and get a streamed summary. -A few resources to get you started if this is your first Flutter project: +## Stack -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +Flutter (Material 3) · Riverpod · go_router · drift (SQLite) · mobile_scanner · Open Food Facts API v2 · Anthropic Messages API (`claude-opus-4-8`, bring your own key). -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Setup + +```sh +flutter pub get +dart run build_runner build # drift codegen (after schema changes) +flutter run +``` + +Then add your Anthropic API key in the app's settings to enable the AI features. + +## Checks + +```sh +dart format lib test +flutter analyze +flutter test +``` + +See `PLAN.md` for the overhaul plan and remaining ideas. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..07ac7f7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,28 +1,19 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - "**/*.g.dart" + language: + strict-casts: true + strict-inference: true + linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + - always_declare_return_types + - avoid_dynamic_calls + - directives_ordering + - prefer_final_locals + - prefer_single_quotes + - require_trailing_commas + - unawaited_futures + - unnecessary_parenthesis diff --git a/android/app/build.gradle b/android/app/build.gradle index 381b0ee..e0f02fa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,12 +34,12 @@ android { ndkVersion "28.2.13676358" compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = "11" } sourceSets { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d56525..906ba43 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - - + + createState() => _ReadlyAppState(); +} + +class _ReadlyAppState extends State { + late final GoRouter _router; + StreamSubscription? _shareSubscription; + + @override + void initState() { + super.initState(); + _router = createRouter(); + _initShareIntents(); + } + + Future _initShareIntents() async { + final handler = ShareHandlerPlatform.instance; + _shareSubscription = handler.sharedMediaStream.listen( + _handleShare, + // Platform without share support (tests, desktop) — ignore. + onError: (Object _) {}, + ); + try { + final initial = await handler.getInitialSharedMedia(); + if (initial != null) _handleShare(initial); + } on Exception { + // Platform without share support (tests, desktop) — ignore. + } + } + + void _handleShare(SharedMedia media) { + final url = ReadlyApp.extractUrl(media.content ?? ''); + if (url == null) return; + _router.go('/read'); + _router.push( + Uri(path: '/read/summary', queryParameters: {'url': url}).toString(), + ); + } + + @override + void dispose() { + unawaited(_shareSubscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Readly', + debugShowCheckedModeBanner: false, + theme: buildTheme(Brightness.light), + darkTheme: buildTheme(Brightness.dark), + routerConfig: _router, + ); + } +} diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..1950ecf --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../features/groceries/groceries_page.dart'; +import '../features/kitchen/kitchen_page.dart'; +import '../features/meals/meals_page.dart'; +import '../features/reader/reader_page.dart'; +import '../features/reader/summary_page.dart'; +import '../features/settings/settings_page.dart'; +import '../features/track/track_page.dart'; +import '../widgets/scanner_page.dart'; + +final rootNavigatorKey = GlobalKey(); + +GoRouter createRouter() { + return GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: '/track', + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, shell) => AppShell(shell: shell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/track', + builder: (context, state) => const TrackPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/kitchen', + builder: (context, state) => const KitchenPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/meals', + builder: (context, state) => const MealsPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/groceries', + builder: (context, state) => const GroceriesPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/read', + builder: (context, state) => const ReaderPage(), + ), + ], + ), + ], + ), + GoRoute( + path: '/read/summary', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) => SummaryPage( + url: state.uri.queryParameters['url'] ?? '', + savedArticleId: int.tryParse( + state.uri.queryParameters['articleId'] ?? '', + ), + ), + ), + GoRoute( + path: '/settings', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/scan', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) => const ScannerPage(), + ), + ], + ); +} + +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.shell}); + + final StatefulNavigationShell shell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: (index) => + shell.goBranch(index, initialLocation: index == shell.currentIndex), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.donut_large_outlined), + selectedIcon: Icon(Icons.donut_large), + label: 'Track', + ), + NavigationDestination( + icon: Icon(Icons.kitchen_outlined), + selectedIcon: Icon(Icons.kitchen), + label: 'Kitchen', + ), + NavigationDestination( + icon: Icon(Icons.restaurant_menu_outlined), + selectedIcon: Icon(Icons.restaurant_menu), + label: 'Meals', + ), + NavigationDestination( + icon: Icon(Icons.shopping_basket_outlined), + selectedIcon: Icon(Icons.shopping_basket), + label: 'Groceries', + ), + NavigationDestination( + icon: Icon(Icons.auto_stories_outlined), + selectedIcon: Icon(Icons.auto_stories), + label: 'Read', + ), + ], + ), + ); + } +} diff --git a/lib/app/theme.dart b/lib/app/theme.dart new file mode 100644 index 0000000..deb1d21 --- /dev/null +++ b/lib/app/theme.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Clean, rounded Material 3 theme with a fresh green seed. +ThemeData buildTheme(Brightness brightness) { + final scheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF3E9B4F), + brightness: brightness, + ); + final base = ThemeData(colorScheme: scheme, useMaterial3: true); + + return base.copyWith( + textTheme: GoogleFonts.manropeTextTheme(base.textTheme), + scaffoldBackgroundColor: scheme.surface, + appBarTheme: AppBarTheme( + centerTitle: false, + backgroundColor: scheme.surface, + scrolledUnderElevation: 0, + titleTextStyle: GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w800, + color: scheme.onSurface, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + margin: EdgeInsets.zero, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: scheme.surfaceContainer, + indicatorColor: scheme.primaryContainer, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ), + chipTheme: base.chipTheme.copyWith( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide.none, + backgroundColor: scheme.surfaceContainerHigh, + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + ), + bottomSheetTheme: const BottomSheetThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + showDragHandle: true, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart new file mode 100644 index 0000000..eb89f93 --- /dev/null +++ b/lib/data/db/database.dart @@ -0,0 +1,150 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; + +part 'database.g.dart'; + +class PantryItems extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get barcode => text().nullable()(); + TextColumn get name => text()(); + TextColumn get brand => text().nullable()(); + TextColumn get imageUrl => text().nullable()(); + RealColumn get kcalPer100g => real().nullable()(); + RealColumn get proteinsPer100g => real().nullable()(); + RealColumn get carbsPer100g => real().nullable()(); + RealColumn get sugarsPer100g => real().nullable()(); + RealColumn get fatsPer100g => real().nullable()(); + + /// Human readable package size, e.g. "500 g". + TextColumn get packageQuantity => text().nullable()(); + + /// Estimated fraction left in the package, 0.0 to 1.0. + RealColumn get amountLeft => real().withDefault(const Constant(1.0))(); + DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); +} + +@DataClassName('ConsumptionEntry') +class ConsumptionEntries extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text()(); + RealColumn get kcal => real()(); + + /// breakfast | lunch | dinner | snack + TextColumn get mealType => text()(); + IntColumn get pantryItemId => integer().nullable()(); + RealColumn get grams => real().nullable()(); + DateTimeColumn get loggedAt => dateTime().withDefault(currentDateAndTime)(); +} + +class ShoppingItems extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text()(); + + /// Optional reason/note (filled by AI suggestions). + TextColumn get note => text().nullable()(); + BoolColumn get done => boolean().withDefault(const Constant(false))(); + + /// manual | ai + TextColumn get source => text().withDefault(const Constant('manual'))(); + DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); +} + +class Articles extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get url => text()(); + TextColumn get title => text()(); + TextColumn get summary => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} + +@DriftDatabase( + tables: [PantryItems, ConsumptionEntries, ShoppingItems, Articles], +) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(driftDatabase(name: 'readly')); + + AppDatabase.forTesting(super.e); + + @override + int get schemaVersion => 1; + + // ---- Pantry ---- + + Stream> watchPantry() => + (select(pantryItems)..orderBy([(t) => OrderingTerm.asc(t.name)])).watch(); + + Future addPantryItem(PantryItemsCompanion item) => + into(pantryItems).insert(item); + + Future updatePantryItem(int id, PantryItemsCompanion changes) => + (update(pantryItems)..where((t) => t.id.equals(id))).write( + changes.copyWith(updatedAt: Value(DateTime.now())), + ); + + Future deletePantryItem(int id) => + (delete(pantryItems)..where((t) => t.id.equals(id))).go(); + + Future pantryItemByBarcode(String barcode) => (select( + pantryItems, + )..where((t) => t.barcode.equals(barcode))).getSingleOrNull(); + + // ---- Consumption ---- + + Stream> watchEntriesBetween( + DateTime start, + DateTime end, + ) => + (select(consumptionEntries) + ..where((t) => t.loggedAt.isBetweenValues(start, end)) + ..orderBy([(t) => OrderingTerm.desc(t.loggedAt)])) + .watch(); + + Future> entriesSince(DateTime since) => (select( + consumptionEntries, + )..where((t) => t.loggedAt.isBiggerThanValue(since))).get(); + + Future logConsumption(ConsumptionEntriesCompanion entry) => + into(consumptionEntries).insert(entry); + + Future deleteConsumption(int id) => + (delete(consumptionEntries)..where((t) => t.id.equals(id))).go(); + + // ---- Shopping ---- + + Stream> watchShopping() => + (select(shoppingItems)..orderBy([ + (t) => OrderingTerm.asc(t.done), + (t) => OrderingTerm.desc(t.addedAt), + ])) + .watch(); + + Future addShoppingItem(ShoppingItemsCompanion item) => + into(shoppingItems).insert(item); + + Future setShoppingDone(int id, bool done) => + (update(shoppingItems)..where((t) => t.id.equals(id))).write( + ShoppingItemsCompanion(done: Value(done)), + ); + + Future deleteShoppingItem(int id) => + (delete(shoppingItems)..where((t) => t.id.equals(id))).go(); + + Future clearDoneShopping() => + (delete(shoppingItems)..where((t) => t.done.equals(true))).go(); + + // ---- Articles ---- + + Stream> watchArticles() => (select( + articles, + )..orderBy([(t) => OrderingTerm.desc(t.createdAt)])).watch(); + + Future saveArticle(ArticlesCompanion article) => + into(articles).insert(article); + + Future articleById(int id) => + (select(articles)..where((t) => t.id.equals(id))).getSingleOrNull(); + + Future deleteArticle(int id) => + (delete(articles)..where((t) => t.id.equals(id))).go(); +} diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart new file mode 100644 index 0000000..d180c9f --- /dev/null +++ b/lib/data/db/database.g.dart @@ -0,0 +1,3068 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $PantryItemsTable extends PantryItems + with TableInfo<$PantryItemsTable, PantryItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PantryItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _barcodeMeta = const VerificationMeta( + 'barcode', + ); + @override + late final GeneratedColumn barcode = GeneratedColumn( + 'barcode', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _brandMeta = const VerificationMeta('brand'); + @override + late final GeneratedColumn brand = GeneratedColumn( + 'brand', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _imageUrlMeta = const VerificationMeta( + 'imageUrl', + ); + @override + late final GeneratedColumn imageUrl = GeneratedColumn( + 'image_url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _kcalPer100gMeta = const VerificationMeta( + 'kcalPer100g', + ); + @override + late final GeneratedColumn kcalPer100g = GeneratedColumn( + 'kcal_per100g', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _proteinsPer100gMeta = const VerificationMeta( + 'proteinsPer100g', + ); + @override + late final GeneratedColumn proteinsPer100g = GeneratedColumn( + 'proteins_per100g', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _carbsPer100gMeta = const VerificationMeta( + 'carbsPer100g', + ); + @override + late final GeneratedColumn carbsPer100g = GeneratedColumn( + 'carbs_per100g', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _sugarsPer100gMeta = const VerificationMeta( + 'sugarsPer100g', + ); + @override + late final GeneratedColumn sugarsPer100g = GeneratedColumn( + 'sugars_per100g', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _fatsPer100gMeta = const VerificationMeta( + 'fatsPer100g', + ); + @override + late final GeneratedColumn fatsPer100g = GeneratedColumn( + 'fats_per100g', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _packageQuantityMeta = const VerificationMeta( + 'packageQuantity', + ); + @override + late final GeneratedColumn packageQuantity = GeneratedColumn( + 'package_quantity', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _amountLeftMeta = const VerificationMeta( + 'amountLeft', + ); + @override + late final GeneratedColumn amountLeft = GeneratedColumn( + 'amount_left', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(1.0), + ); + static const VerificationMeta _addedAtMeta = const VerificationMeta( + 'addedAt', + ); + @override + late final GeneratedColumn addedAt = GeneratedColumn( + 'added_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + barcode, + name, + brand, + imageUrl, + kcalPer100g, + proteinsPer100g, + carbsPer100g, + sugarsPer100g, + fatsPer100g, + packageQuantity, + amountLeft, + addedAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pantry_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('barcode')) { + context.handle( + _barcodeMeta, + barcode.isAcceptableOrUnknown(data['barcode']!, _barcodeMeta), + ); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('brand')) { + context.handle( + _brandMeta, + brand.isAcceptableOrUnknown(data['brand']!, _brandMeta), + ); + } + if (data.containsKey('image_url')) { + context.handle( + _imageUrlMeta, + imageUrl.isAcceptableOrUnknown(data['image_url']!, _imageUrlMeta), + ); + } + if (data.containsKey('kcal_per100g')) { + context.handle( + _kcalPer100gMeta, + kcalPer100g.isAcceptableOrUnknown( + data['kcal_per100g']!, + _kcalPer100gMeta, + ), + ); + } + if (data.containsKey('proteins_per100g')) { + context.handle( + _proteinsPer100gMeta, + proteinsPer100g.isAcceptableOrUnknown( + data['proteins_per100g']!, + _proteinsPer100gMeta, + ), + ); + } + if (data.containsKey('carbs_per100g')) { + context.handle( + _carbsPer100gMeta, + carbsPer100g.isAcceptableOrUnknown( + data['carbs_per100g']!, + _carbsPer100gMeta, + ), + ); + } + if (data.containsKey('sugars_per100g')) { + context.handle( + _sugarsPer100gMeta, + sugarsPer100g.isAcceptableOrUnknown( + data['sugars_per100g']!, + _sugarsPer100gMeta, + ), + ); + } + if (data.containsKey('fats_per100g')) { + context.handle( + _fatsPer100gMeta, + fatsPer100g.isAcceptableOrUnknown( + data['fats_per100g']!, + _fatsPer100gMeta, + ), + ); + } + if (data.containsKey('package_quantity')) { + context.handle( + _packageQuantityMeta, + packageQuantity.isAcceptableOrUnknown( + data['package_quantity']!, + _packageQuantityMeta, + ), + ); + } + if (data.containsKey('amount_left')) { + context.handle( + _amountLeftMeta, + amountLeft.isAcceptableOrUnknown(data['amount_left']!, _amountLeftMeta), + ); + } + if (data.containsKey('added_at')) { + context.handle( + _addedAtMeta, + addedAt.isAcceptableOrUnknown(data['added_at']!, _addedAtMeta), + ); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PantryItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PantryItem( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + barcode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}barcode'], + ), + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + brand: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}brand'], + ), + imageUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}image_url'], + ), + kcalPer100g: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}kcal_per100g'], + ), + proteinsPer100g: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}proteins_per100g'], + ), + carbsPer100g: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}carbs_per100g'], + ), + sugarsPer100g: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}sugars_per100g'], + ), + fatsPer100g: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}fats_per100g'], + ), + packageQuantity: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}package_quantity'], + ), + amountLeft: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}amount_left'], + )!, + addedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}added_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $PantryItemsTable createAlias(String alias) { + return $PantryItemsTable(attachedDatabase, alias); + } +} + +class PantryItem extends DataClass implements Insertable { + final int id; + final String? barcode; + final String name; + final String? brand; + final String? imageUrl; + final double? kcalPer100g; + final double? proteinsPer100g; + final double? carbsPer100g; + final double? sugarsPer100g; + final double? fatsPer100g; + + /// Human readable package size, e.g. "500 g". + final String? packageQuantity; + + /// Estimated fraction left in the package, 0.0 to 1.0. + final double amountLeft; + final DateTime addedAt; + final DateTime updatedAt; + const PantryItem({ + required this.id, + this.barcode, + required this.name, + this.brand, + this.imageUrl, + this.kcalPer100g, + this.proteinsPer100g, + this.carbsPer100g, + this.sugarsPer100g, + this.fatsPer100g, + this.packageQuantity, + required this.amountLeft, + required this.addedAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || barcode != null) { + map['barcode'] = Variable(barcode); + } + map['name'] = Variable(name); + if (!nullToAbsent || brand != null) { + map['brand'] = Variable(brand); + } + if (!nullToAbsent || imageUrl != null) { + map['image_url'] = Variable(imageUrl); + } + if (!nullToAbsent || kcalPer100g != null) { + map['kcal_per100g'] = Variable(kcalPer100g); + } + if (!nullToAbsent || proteinsPer100g != null) { + map['proteins_per100g'] = Variable(proteinsPer100g); + } + if (!nullToAbsent || carbsPer100g != null) { + map['carbs_per100g'] = Variable(carbsPer100g); + } + if (!nullToAbsent || sugarsPer100g != null) { + map['sugars_per100g'] = Variable(sugarsPer100g); + } + if (!nullToAbsent || fatsPer100g != null) { + map['fats_per100g'] = Variable(fatsPer100g); + } + if (!nullToAbsent || packageQuantity != null) { + map['package_quantity'] = Variable(packageQuantity); + } + map['amount_left'] = Variable(amountLeft); + map['added_at'] = Variable(addedAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + PantryItemsCompanion toCompanion(bool nullToAbsent) { + return PantryItemsCompanion( + id: Value(id), + barcode: barcode == null && nullToAbsent + ? const Value.absent() + : Value(barcode), + name: Value(name), + brand: brand == null && nullToAbsent + ? const Value.absent() + : Value(brand), + imageUrl: imageUrl == null && nullToAbsent + ? const Value.absent() + : Value(imageUrl), + kcalPer100g: kcalPer100g == null && nullToAbsent + ? const Value.absent() + : Value(kcalPer100g), + proteinsPer100g: proteinsPer100g == null && nullToAbsent + ? const Value.absent() + : Value(proteinsPer100g), + carbsPer100g: carbsPer100g == null && nullToAbsent + ? const Value.absent() + : Value(carbsPer100g), + sugarsPer100g: sugarsPer100g == null && nullToAbsent + ? const Value.absent() + : Value(sugarsPer100g), + fatsPer100g: fatsPer100g == null && nullToAbsent + ? const Value.absent() + : Value(fatsPer100g), + packageQuantity: packageQuantity == null && nullToAbsent + ? const Value.absent() + : Value(packageQuantity), + amountLeft: Value(amountLeft), + addedAt: Value(addedAt), + updatedAt: Value(updatedAt), + ); + } + + factory PantryItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PantryItem( + id: serializer.fromJson(json['id']), + barcode: serializer.fromJson(json['barcode']), + name: serializer.fromJson(json['name']), + brand: serializer.fromJson(json['brand']), + imageUrl: serializer.fromJson(json['imageUrl']), + kcalPer100g: serializer.fromJson(json['kcalPer100g']), + proteinsPer100g: serializer.fromJson(json['proteinsPer100g']), + carbsPer100g: serializer.fromJson(json['carbsPer100g']), + sugarsPer100g: serializer.fromJson(json['sugarsPer100g']), + fatsPer100g: serializer.fromJson(json['fatsPer100g']), + packageQuantity: serializer.fromJson(json['packageQuantity']), + amountLeft: serializer.fromJson(json['amountLeft']), + addedAt: serializer.fromJson(json['addedAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'barcode': serializer.toJson(barcode), + 'name': serializer.toJson(name), + 'brand': serializer.toJson(brand), + 'imageUrl': serializer.toJson(imageUrl), + 'kcalPer100g': serializer.toJson(kcalPer100g), + 'proteinsPer100g': serializer.toJson(proteinsPer100g), + 'carbsPer100g': serializer.toJson(carbsPer100g), + 'sugarsPer100g': serializer.toJson(sugarsPer100g), + 'fatsPer100g': serializer.toJson(fatsPer100g), + 'packageQuantity': serializer.toJson(packageQuantity), + 'amountLeft': serializer.toJson(amountLeft), + 'addedAt': serializer.toJson(addedAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + PantryItem copyWith({ + int? id, + Value barcode = const Value.absent(), + String? name, + Value brand = const Value.absent(), + Value imageUrl = const Value.absent(), + Value kcalPer100g = const Value.absent(), + Value proteinsPer100g = const Value.absent(), + Value carbsPer100g = const Value.absent(), + Value sugarsPer100g = const Value.absent(), + Value fatsPer100g = const Value.absent(), + Value packageQuantity = const Value.absent(), + double? amountLeft, + DateTime? addedAt, + DateTime? updatedAt, + }) => PantryItem( + id: id ?? this.id, + barcode: barcode.present ? barcode.value : this.barcode, + name: name ?? this.name, + brand: brand.present ? brand.value : this.brand, + imageUrl: imageUrl.present ? imageUrl.value : this.imageUrl, + kcalPer100g: kcalPer100g.present ? kcalPer100g.value : this.kcalPer100g, + proteinsPer100g: proteinsPer100g.present + ? proteinsPer100g.value + : this.proteinsPer100g, + carbsPer100g: carbsPer100g.present ? carbsPer100g.value : this.carbsPer100g, + sugarsPer100g: sugarsPer100g.present + ? sugarsPer100g.value + : this.sugarsPer100g, + fatsPer100g: fatsPer100g.present ? fatsPer100g.value : this.fatsPer100g, + packageQuantity: packageQuantity.present + ? packageQuantity.value + : this.packageQuantity, + amountLeft: amountLeft ?? this.amountLeft, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + PantryItem copyWithCompanion(PantryItemsCompanion data) { + return PantryItem( + id: data.id.present ? data.id.value : this.id, + barcode: data.barcode.present ? data.barcode.value : this.barcode, + name: data.name.present ? data.name.value : this.name, + brand: data.brand.present ? data.brand.value : this.brand, + imageUrl: data.imageUrl.present ? data.imageUrl.value : this.imageUrl, + kcalPer100g: data.kcalPer100g.present + ? data.kcalPer100g.value + : this.kcalPer100g, + proteinsPer100g: data.proteinsPer100g.present + ? data.proteinsPer100g.value + : this.proteinsPer100g, + carbsPer100g: data.carbsPer100g.present + ? data.carbsPer100g.value + : this.carbsPer100g, + sugarsPer100g: data.sugarsPer100g.present + ? data.sugarsPer100g.value + : this.sugarsPer100g, + fatsPer100g: data.fatsPer100g.present + ? data.fatsPer100g.value + : this.fatsPer100g, + packageQuantity: data.packageQuantity.present + ? data.packageQuantity.value + : this.packageQuantity, + amountLeft: data.amountLeft.present + ? data.amountLeft.value + : this.amountLeft, + addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('PantryItem(') + ..write('id: $id, ') + ..write('barcode: $barcode, ') + ..write('name: $name, ') + ..write('brand: $brand, ') + ..write('imageUrl: $imageUrl, ') + ..write('kcalPer100g: $kcalPer100g, ') + ..write('proteinsPer100g: $proteinsPer100g, ') + ..write('carbsPer100g: $carbsPer100g, ') + ..write('sugarsPer100g: $sugarsPer100g, ') + ..write('fatsPer100g: $fatsPer100g, ') + ..write('packageQuantity: $packageQuantity, ') + ..write('amountLeft: $amountLeft, ') + ..write('addedAt: $addedAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + barcode, + name, + brand, + imageUrl, + kcalPer100g, + proteinsPer100g, + carbsPer100g, + sugarsPer100g, + fatsPer100g, + packageQuantity, + amountLeft, + addedAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PantryItem && + other.id == this.id && + other.barcode == this.barcode && + other.name == this.name && + other.brand == this.brand && + other.imageUrl == this.imageUrl && + other.kcalPer100g == this.kcalPer100g && + other.proteinsPer100g == this.proteinsPer100g && + other.carbsPer100g == this.carbsPer100g && + other.sugarsPer100g == this.sugarsPer100g && + other.fatsPer100g == this.fatsPer100g && + other.packageQuantity == this.packageQuantity && + other.amountLeft == this.amountLeft && + other.addedAt == this.addedAt && + other.updatedAt == this.updatedAt); +} + +class PantryItemsCompanion extends UpdateCompanion { + final Value id; + final Value barcode; + final Value name; + final Value brand; + final Value imageUrl; + final Value kcalPer100g; + final Value proteinsPer100g; + final Value carbsPer100g; + final Value sugarsPer100g; + final Value fatsPer100g; + final Value packageQuantity; + final Value amountLeft; + final Value addedAt; + final Value updatedAt; + const PantryItemsCompanion({ + this.id = const Value.absent(), + this.barcode = const Value.absent(), + this.name = const Value.absent(), + this.brand = const Value.absent(), + this.imageUrl = const Value.absent(), + this.kcalPer100g = const Value.absent(), + this.proteinsPer100g = const Value.absent(), + this.carbsPer100g = const Value.absent(), + this.sugarsPer100g = const Value.absent(), + this.fatsPer100g = const Value.absent(), + this.packageQuantity = const Value.absent(), + this.amountLeft = const Value.absent(), + this.addedAt = const Value.absent(), + this.updatedAt = const Value.absent(), + }); + PantryItemsCompanion.insert({ + this.id = const Value.absent(), + this.barcode = const Value.absent(), + required String name, + this.brand = const Value.absent(), + this.imageUrl = const Value.absent(), + this.kcalPer100g = const Value.absent(), + this.proteinsPer100g = const Value.absent(), + this.carbsPer100g = const Value.absent(), + this.sugarsPer100g = const Value.absent(), + this.fatsPer100g = const Value.absent(), + this.packageQuantity = const Value.absent(), + this.amountLeft = const Value.absent(), + this.addedAt = const Value.absent(), + this.updatedAt = const Value.absent(), + }) : name = Value(name); + static Insertable custom({ + Expression? id, + Expression? barcode, + Expression? name, + Expression? brand, + Expression? imageUrl, + Expression? kcalPer100g, + Expression? proteinsPer100g, + Expression? carbsPer100g, + Expression? sugarsPer100g, + Expression? fatsPer100g, + Expression? packageQuantity, + Expression? amountLeft, + Expression? addedAt, + Expression? updatedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (barcode != null) 'barcode': barcode, + if (name != null) 'name': name, + if (brand != null) 'brand': brand, + if (imageUrl != null) 'image_url': imageUrl, + if (kcalPer100g != null) 'kcal_per100g': kcalPer100g, + if (proteinsPer100g != null) 'proteins_per100g': proteinsPer100g, + if (carbsPer100g != null) 'carbs_per100g': carbsPer100g, + if (sugarsPer100g != null) 'sugars_per100g': sugarsPer100g, + if (fatsPer100g != null) 'fats_per100g': fatsPer100g, + if (packageQuantity != null) 'package_quantity': packageQuantity, + if (amountLeft != null) 'amount_left': amountLeft, + if (addedAt != null) 'added_at': addedAt, + if (updatedAt != null) 'updated_at': updatedAt, + }); + } + + PantryItemsCompanion copyWith({ + Value? id, + Value? barcode, + Value? name, + Value? brand, + Value? imageUrl, + Value? kcalPer100g, + Value? proteinsPer100g, + Value? carbsPer100g, + Value? sugarsPer100g, + Value? fatsPer100g, + Value? packageQuantity, + Value? amountLeft, + Value? addedAt, + Value? updatedAt, + }) { + return PantryItemsCompanion( + id: id ?? this.id, + barcode: barcode ?? this.barcode, + name: name ?? this.name, + brand: brand ?? this.brand, + imageUrl: imageUrl ?? this.imageUrl, + kcalPer100g: kcalPer100g ?? this.kcalPer100g, + proteinsPer100g: proteinsPer100g ?? this.proteinsPer100g, + carbsPer100g: carbsPer100g ?? this.carbsPer100g, + sugarsPer100g: sugarsPer100g ?? this.sugarsPer100g, + fatsPer100g: fatsPer100g ?? this.fatsPer100g, + packageQuantity: packageQuantity ?? this.packageQuantity, + amountLeft: amountLeft ?? this.amountLeft, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (barcode.present) { + map['barcode'] = Variable(barcode.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (brand.present) { + map['brand'] = Variable(brand.value); + } + if (imageUrl.present) { + map['image_url'] = Variable(imageUrl.value); + } + if (kcalPer100g.present) { + map['kcal_per100g'] = Variable(kcalPer100g.value); + } + if (proteinsPer100g.present) { + map['proteins_per100g'] = Variable(proteinsPer100g.value); + } + if (carbsPer100g.present) { + map['carbs_per100g'] = Variable(carbsPer100g.value); + } + if (sugarsPer100g.present) { + map['sugars_per100g'] = Variable(sugarsPer100g.value); + } + if (fatsPer100g.present) { + map['fats_per100g'] = Variable(fatsPer100g.value); + } + if (packageQuantity.present) { + map['package_quantity'] = Variable(packageQuantity.value); + } + if (amountLeft.present) { + map['amount_left'] = Variable(amountLeft.value); + } + if (addedAt.present) { + map['added_at'] = Variable(addedAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PantryItemsCompanion(') + ..write('id: $id, ') + ..write('barcode: $barcode, ') + ..write('name: $name, ') + ..write('brand: $brand, ') + ..write('imageUrl: $imageUrl, ') + ..write('kcalPer100g: $kcalPer100g, ') + ..write('proteinsPer100g: $proteinsPer100g, ') + ..write('carbsPer100g: $carbsPer100g, ') + ..write('sugarsPer100g: $sugarsPer100g, ') + ..write('fatsPer100g: $fatsPer100g, ') + ..write('packageQuantity: $packageQuantity, ') + ..write('amountLeft: $amountLeft, ') + ..write('addedAt: $addedAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } +} + +class $ConsumptionEntriesTable extends ConsumptionEntries + with TableInfo<$ConsumptionEntriesTable, ConsumptionEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ConsumptionEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _kcalMeta = const VerificationMeta('kcal'); + @override + late final GeneratedColumn kcal = GeneratedColumn( + 'kcal', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _mealTypeMeta = const VerificationMeta( + 'mealType', + ); + @override + late final GeneratedColumn mealType = GeneratedColumn( + 'meal_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _pantryItemIdMeta = const VerificationMeta( + 'pantryItemId', + ); + @override + late final GeneratedColumn pantryItemId = GeneratedColumn( + 'pantry_item_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _gramsMeta = const VerificationMeta('grams'); + @override + late final GeneratedColumn grams = GeneratedColumn( + 'grams', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _loggedAtMeta = const VerificationMeta( + 'loggedAt', + ); + @override + late final GeneratedColumn loggedAt = GeneratedColumn( + 'logged_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + name, + kcal, + mealType, + pantryItemId, + grams, + loggedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'consumption_entries'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('kcal')) { + context.handle( + _kcalMeta, + kcal.isAcceptableOrUnknown(data['kcal']!, _kcalMeta), + ); + } else if (isInserting) { + context.missing(_kcalMeta); + } + if (data.containsKey('meal_type')) { + context.handle( + _mealTypeMeta, + mealType.isAcceptableOrUnknown(data['meal_type']!, _mealTypeMeta), + ); + } else if (isInserting) { + context.missing(_mealTypeMeta); + } + if (data.containsKey('pantry_item_id')) { + context.handle( + _pantryItemIdMeta, + pantryItemId.isAcceptableOrUnknown( + data['pantry_item_id']!, + _pantryItemIdMeta, + ), + ); + } + if (data.containsKey('grams')) { + context.handle( + _gramsMeta, + grams.isAcceptableOrUnknown(data['grams']!, _gramsMeta), + ); + } + if (data.containsKey('logged_at')) { + context.handle( + _loggedAtMeta, + loggedAt.isAcceptableOrUnknown(data['logged_at']!, _loggedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ConsumptionEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ConsumptionEntry( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + kcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}kcal'], + )!, + mealType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}meal_type'], + )!, + pantryItemId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}pantry_item_id'], + ), + grams: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}grams'], + ), + loggedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}logged_at'], + )!, + ); + } + + @override + $ConsumptionEntriesTable createAlias(String alias) { + return $ConsumptionEntriesTable(attachedDatabase, alias); + } +} + +class ConsumptionEntry extends DataClass + implements Insertable { + final int id; + final String name; + final double kcal; + + /// breakfast | lunch | dinner | snack + final String mealType; + final int? pantryItemId; + final double? grams; + final DateTime loggedAt; + const ConsumptionEntry({ + required this.id, + required this.name, + required this.kcal, + required this.mealType, + this.pantryItemId, + this.grams, + required this.loggedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['kcal'] = Variable(kcal); + map['meal_type'] = Variable(mealType); + if (!nullToAbsent || pantryItemId != null) { + map['pantry_item_id'] = Variable(pantryItemId); + } + if (!nullToAbsent || grams != null) { + map['grams'] = Variable(grams); + } + map['logged_at'] = Variable(loggedAt); + return map; + } + + ConsumptionEntriesCompanion toCompanion(bool nullToAbsent) { + return ConsumptionEntriesCompanion( + id: Value(id), + name: Value(name), + kcal: Value(kcal), + mealType: Value(mealType), + pantryItemId: pantryItemId == null && nullToAbsent + ? const Value.absent() + : Value(pantryItemId), + grams: grams == null && nullToAbsent + ? const Value.absent() + : Value(grams), + loggedAt: Value(loggedAt), + ); + } + + factory ConsumptionEntry.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ConsumptionEntry( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + kcal: serializer.fromJson(json['kcal']), + mealType: serializer.fromJson(json['mealType']), + pantryItemId: serializer.fromJson(json['pantryItemId']), + grams: serializer.fromJson(json['grams']), + loggedAt: serializer.fromJson(json['loggedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'kcal': serializer.toJson(kcal), + 'mealType': serializer.toJson(mealType), + 'pantryItemId': serializer.toJson(pantryItemId), + 'grams': serializer.toJson(grams), + 'loggedAt': serializer.toJson(loggedAt), + }; + } + + ConsumptionEntry copyWith({ + int? id, + String? name, + double? kcal, + String? mealType, + Value pantryItemId = const Value.absent(), + Value grams = const Value.absent(), + DateTime? loggedAt, + }) => ConsumptionEntry( + id: id ?? this.id, + name: name ?? this.name, + kcal: kcal ?? this.kcal, + mealType: mealType ?? this.mealType, + pantryItemId: pantryItemId.present ? pantryItemId.value : this.pantryItemId, + grams: grams.present ? grams.value : this.grams, + loggedAt: loggedAt ?? this.loggedAt, + ); + ConsumptionEntry copyWithCompanion(ConsumptionEntriesCompanion data) { + return ConsumptionEntry( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + kcal: data.kcal.present ? data.kcal.value : this.kcal, + mealType: data.mealType.present ? data.mealType.value : this.mealType, + pantryItemId: data.pantryItemId.present + ? data.pantryItemId.value + : this.pantryItemId, + grams: data.grams.present ? data.grams.value : this.grams, + loggedAt: data.loggedAt.present ? data.loggedAt.value : this.loggedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ConsumptionEntry(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('kcal: $kcal, ') + ..write('mealType: $mealType, ') + ..write('pantryItemId: $pantryItemId, ') + ..write('grams: $grams, ') + ..write('loggedAt: $loggedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, kcal, mealType, pantryItemId, grams, loggedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ConsumptionEntry && + other.id == this.id && + other.name == this.name && + other.kcal == this.kcal && + other.mealType == this.mealType && + other.pantryItemId == this.pantryItemId && + other.grams == this.grams && + other.loggedAt == this.loggedAt); +} + +class ConsumptionEntriesCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value kcal; + final Value mealType; + final Value pantryItemId; + final Value grams; + final Value loggedAt; + const ConsumptionEntriesCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.kcal = const Value.absent(), + this.mealType = const Value.absent(), + this.pantryItemId = const Value.absent(), + this.grams = const Value.absent(), + this.loggedAt = const Value.absent(), + }); + ConsumptionEntriesCompanion.insert({ + this.id = const Value.absent(), + required String name, + required double kcal, + required String mealType, + this.pantryItemId = const Value.absent(), + this.grams = const Value.absent(), + this.loggedAt = const Value.absent(), + }) : name = Value(name), + kcal = Value(kcal), + mealType = Value(mealType); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? kcal, + Expression? mealType, + Expression? pantryItemId, + Expression? grams, + Expression? loggedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (kcal != null) 'kcal': kcal, + if (mealType != null) 'meal_type': mealType, + if (pantryItemId != null) 'pantry_item_id': pantryItemId, + if (grams != null) 'grams': grams, + if (loggedAt != null) 'logged_at': loggedAt, + }); + } + + ConsumptionEntriesCompanion copyWith({ + Value? id, + Value? name, + Value? kcal, + Value? mealType, + Value? pantryItemId, + Value? grams, + Value? loggedAt, + }) { + return ConsumptionEntriesCompanion( + id: id ?? this.id, + name: name ?? this.name, + kcal: kcal ?? this.kcal, + mealType: mealType ?? this.mealType, + pantryItemId: pantryItemId ?? this.pantryItemId, + grams: grams ?? this.grams, + loggedAt: loggedAt ?? this.loggedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (kcal.present) { + map['kcal'] = Variable(kcal.value); + } + if (mealType.present) { + map['meal_type'] = Variable(mealType.value); + } + if (pantryItemId.present) { + map['pantry_item_id'] = Variable(pantryItemId.value); + } + if (grams.present) { + map['grams'] = Variable(grams.value); + } + if (loggedAt.present) { + map['logged_at'] = Variable(loggedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConsumptionEntriesCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('kcal: $kcal, ') + ..write('mealType: $mealType, ') + ..write('pantryItemId: $pantryItemId, ') + ..write('grams: $grams, ') + ..write('loggedAt: $loggedAt') + ..write(')')) + .toString(); + } +} + +class $ShoppingItemsTable extends ShoppingItems + with TableInfo<$ShoppingItemsTable, ShoppingItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ShoppingItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _doneMeta = const VerificationMeta('done'); + @override + late final GeneratedColumn done = GeneratedColumn( + 'done', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("done" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('manual'), + ); + static const VerificationMeta _addedAtMeta = const VerificationMeta( + 'addedAt', + ); + @override + late final GeneratedColumn addedAt = GeneratedColumn( + 'added_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, name, note, done, source, addedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'shopping_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } + if (data.containsKey('done')) { + context.handle( + _doneMeta, + done.isAcceptableOrUnknown(data['done']!, _doneMeta), + ); + } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } + if (data.containsKey('added_at')) { + context.handle( + _addedAtMeta, + addedAt.isAcceptableOrUnknown(data['added_at']!, _addedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ShoppingItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ShoppingItem( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + note: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + ), + done: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}done'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + addedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}added_at'], + )!, + ); + } + + @override + $ShoppingItemsTable createAlias(String alias) { + return $ShoppingItemsTable(attachedDatabase, alias); + } +} + +class ShoppingItem extends DataClass implements Insertable { + final int id; + final String name; + + /// Optional reason/note (filled by AI suggestions). + final String? note; + final bool done; + + /// manual | ai + final String source; + final DateTime addedAt; + const ShoppingItem({ + required this.id, + required this.name, + this.note, + required this.done, + required this.source, + required this.addedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + map['done'] = Variable(done); + map['source'] = Variable(source); + map['added_at'] = Variable(addedAt); + return map; + } + + ShoppingItemsCompanion toCompanion(bool nullToAbsent) { + return ShoppingItemsCompanion( + id: Value(id), + name: Value(name), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + done: Value(done), + source: Value(source), + addedAt: Value(addedAt), + ); + } + + factory ShoppingItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ShoppingItem( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + note: serializer.fromJson(json['note']), + done: serializer.fromJson(json['done']), + source: serializer.fromJson(json['source']), + addedAt: serializer.fromJson(json['addedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'note': serializer.toJson(note), + 'done': serializer.toJson(done), + 'source': serializer.toJson(source), + 'addedAt': serializer.toJson(addedAt), + }; + } + + ShoppingItem copyWith({ + int? id, + String? name, + Value note = const Value.absent(), + bool? done, + String? source, + DateTime? addedAt, + }) => ShoppingItem( + id: id ?? this.id, + name: name ?? this.name, + note: note.present ? note.value : this.note, + done: done ?? this.done, + source: source ?? this.source, + addedAt: addedAt ?? this.addedAt, + ); + ShoppingItem copyWithCompanion(ShoppingItemsCompanion data) { + return ShoppingItem( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + note: data.note.present ? data.note.value : this.note, + done: data.done.present ? data.done.value : this.done, + source: data.source.present ? data.source.value : this.source, + addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ShoppingItem(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('note: $note, ') + ..write('done: $done, ') + ..write('source: $source, ') + ..write('addedAt: $addedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, note, done, source, addedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShoppingItem && + other.id == this.id && + other.name == this.name && + other.note == this.note && + other.done == this.done && + other.source == this.source && + other.addedAt == this.addedAt); +} + +class ShoppingItemsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value note; + final Value done; + final Value source; + final Value addedAt; + const ShoppingItemsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.note = const Value.absent(), + this.done = const Value.absent(), + this.source = const Value.absent(), + this.addedAt = const Value.absent(), + }); + ShoppingItemsCompanion.insert({ + this.id = const Value.absent(), + required String name, + this.note = const Value.absent(), + this.done = const Value.absent(), + this.source = const Value.absent(), + this.addedAt = const Value.absent(), + }) : name = Value(name); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? note, + Expression? done, + Expression? source, + Expression? addedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (note != null) 'note': note, + if (done != null) 'done': done, + if (source != null) 'source': source, + if (addedAt != null) 'added_at': addedAt, + }); + } + + ShoppingItemsCompanion copyWith({ + Value? id, + Value? name, + Value? note, + Value? done, + Value? source, + Value? addedAt, + }) { + return ShoppingItemsCompanion( + id: id ?? this.id, + name: name ?? this.name, + note: note ?? this.note, + done: done ?? this.done, + source: source ?? this.source, + addedAt: addedAt ?? this.addedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (done.present) { + map['done'] = Variable(done.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (addedAt.present) { + map['added_at'] = Variable(addedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShoppingItemsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('note: $note, ') + ..write('done: $done, ') + ..write('source: $source, ') + ..write('addedAt: $addedAt') + ..write(')')) + .toString(); + } +} + +class $ArticlesTable extends Articles with TableInfo<$ArticlesTable, Article> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ArticlesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _summaryMeta = const VerificationMeta( + 'summary', + ); + @override + late final GeneratedColumn summary = GeneratedColumn( + 'summary', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, url, title, summary, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'articles'; + @override + VerificationContext validateIntegrity( + Insertable
instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('summary')) { + context.handle( + _summaryMeta, + summary.isAcceptableOrUnknown(data['summary']!, _summaryMeta), + ); + } else if (isInserting) { + context.missing(_summaryMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Article map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Article( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + summary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summary'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $ArticlesTable createAlias(String alias) { + return $ArticlesTable(attachedDatabase, alias); + } +} + +class Article extends DataClass implements Insertable
{ + final int id; + final String url; + final String title; + final String summary; + final DateTime createdAt; + const Article({ + required this.id, + required this.url, + required this.title, + required this.summary, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['url'] = Variable(url); + map['title'] = Variable(title); + map['summary'] = Variable(summary); + map['created_at'] = Variable(createdAt); + return map; + } + + ArticlesCompanion toCompanion(bool nullToAbsent) { + return ArticlesCompanion( + id: Value(id), + url: Value(url), + title: Value(title), + summary: Value(summary), + createdAt: Value(createdAt), + ); + } + + factory Article.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Article( + id: serializer.fromJson(json['id']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + summary: serializer.fromJson(json['summary']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'summary': serializer.toJson(summary), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Article copyWith({ + int? id, + String? url, + String? title, + String? summary, + DateTime? createdAt, + }) => Article( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + summary: summary ?? this.summary, + createdAt: createdAt ?? this.createdAt, + ); + Article copyWithCompanion(ArticlesCompanion data) { + return Article( + id: data.id.present ? data.id.value : this.id, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + summary: data.summary.present ? data.summary.value : this.summary, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Article(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('summary: $summary, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, url, title, summary, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Article && + other.id == this.id && + other.url == this.url && + other.title == this.title && + other.summary == this.summary && + other.createdAt == this.createdAt); +} + +class ArticlesCompanion extends UpdateCompanion
{ + final Value id; + final Value url; + final Value title; + final Value summary; + final Value createdAt; + const ArticlesCompanion({ + this.id = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.summary = const Value.absent(), + this.createdAt = const Value.absent(), + }); + ArticlesCompanion.insert({ + this.id = const Value.absent(), + required String url, + required String title, + required String summary, + this.createdAt = const Value.absent(), + }) : url = Value(url), + title = Value(title), + summary = Value(summary); + static Insertable
custom({ + Expression? id, + Expression? url, + Expression? title, + Expression? summary, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (summary != null) 'summary': summary, + if (createdAt != null) 'created_at': createdAt, + }); + } + + ArticlesCompanion copyWith({ + Value? id, + Value? url, + Value? title, + Value? summary, + Value? createdAt, + }) { + return ArticlesCompanion( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + summary: summary ?? this.summary, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (summary.present) { + map['summary'] = Variable(summary.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticlesCompanion(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('summary: $summary, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $PantryItemsTable pantryItems = $PantryItemsTable(this); + late final $ConsumptionEntriesTable consumptionEntries = + $ConsumptionEntriesTable(this); + late final $ShoppingItemsTable shoppingItems = $ShoppingItemsTable(this); + late final $ArticlesTable articles = $ArticlesTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + pantryItems, + consumptionEntries, + shoppingItems, + articles, + ]; +} + +typedef $$PantryItemsTableCreateCompanionBuilder = + PantryItemsCompanion Function({ + Value id, + Value barcode, + required String name, + Value brand, + Value imageUrl, + Value kcalPer100g, + Value proteinsPer100g, + Value carbsPer100g, + Value sugarsPer100g, + Value fatsPer100g, + Value packageQuantity, + Value amountLeft, + Value addedAt, + Value updatedAt, + }); +typedef $$PantryItemsTableUpdateCompanionBuilder = + PantryItemsCompanion Function({ + Value id, + Value barcode, + Value name, + Value brand, + Value imageUrl, + Value kcalPer100g, + Value proteinsPer100g, + Value carbsPer100g, + Value sugarsPer100g, + Value fatsPer100g, + Value packageQuantity, + Value amountLeft, + Value addedAt, + Value updatedAt, + }); + +class $$PantryItemsTableFilterComposer + extends Composer<_$AppDatabase, $PantryItemsTable> { + $$PantryItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get barcode => $composableBuilder( + column: $table.barcode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get brand => $composableBuilder( + column: $table.brand, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get imageUrl => $composableBuilder( + column: $table.imageUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kcalPer100g => $composableBuilder( + column: $table.kcalPer100g, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get proteinsPer100g => $composableBuilder( + column: $table.proteinsPer100g, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get carbsPer100g => $composableBuilder( + column: $table.carbsPer100g, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sugarsPer100g => $composableBuilder( + column: $table.sugarsPer100g, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fatsPer100g => $composableBuilder( + column: $table.fatsPer100g, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get packageQuantity => $composableBuilder( + column: $table.packageQuantity, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get amountLeft => $composableBuilder( + column: $table.amountLeft, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$PantryItemsTableOrderingComposer + extends Composer<_$AppDatabase, $PantryItemsTable> { + $$PantryItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get barcode => $composableBuilder( + column: $table.barcode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get brand => $composableBuilder( + column: $table.brand, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get imageUrl => $composableBuilder( + column: $table.imageUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kcalPer100g => $composableBuilder( + column: $table.kcalPer100g, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get proteinsPer100g => $composableBuilder( + column: $table.proteinsPer100g, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get carbsPer100g => $composableBuilder( + column: $table.carbsPer100g, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sugarsPer100g => $composableBuilder( + column: $table.sugarsPer100g, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fatsPer100g => $composableBuilder( + column: $table.fatsPer100g, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get packageQuantity => $composableBuilder( + column: $table.packageQuantity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get amountLeft => $composableBuilder( + column: $table.amountLeft, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$PantryItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $PantryItemsTable> { + $$PantryItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get barcode => + $composableBuilder(column: $table.barcode, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get brand => + $composableBuilder(column: $table.brand, builder: (column) => column); + + GeneratedColumn get imageUrl => + $composableBuilder(column: $table.imageUrl, builder: (column) => column); + + GeneratedColumn get kcalPer100g => $composableBuilder( + column: $table.kcalPer100g, + builder: (column) => column, + ); + + GeneratedColumn get proteinsPer100g => $composableBuilder( + column: $table.proteinsPer100g, + builder: (column) => column, + ); + + GeneratedColumn get carbsPer100g => $composableBuilder( + column: $table.carbsPer100g, + builder: (column) => column, + ); + + GeneratedColumn get sugarsPer100g => $composableBuilder( + column: $table.sugarsPer100g, + builder: (column) => column, + ); + + GeneratedColumn get fatsPer100g => $composableBuilder( + column: $table.fatsPer100g, + builder: (column) => column, + ); + + GeneratedColumn get packageQuantity => $composableBuilder( + column: $table.packageQuantity, + builder: (column) => column, + ); + + GeneratedColumn get amountLeft => $composableBuilder( + column: $table.amountLeft, + builder: (column) => column, + ); + + GeneratedColumn get addedAt => + $composableBuilder(column: $table.addedAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$PantryItemsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PantryItemsTable, + PantryItem, + $$PantryItemsTableFilterComposer, + $$PantryItemsTableOrderingComposer, + $$PantryItemsTableAnnotationComposer, + $$PantryItemsTableCreateCompanionBuilder, + $$PantryItemsTableUpdateCompanionBuilder, + ( + PantryItem, + BaseReferences<_$AppDatabase, $PantryItemsTable, PantryItem>, + ), + PantryItem, + PrefetchHooks Function() + > { + $$PantryItemsTableTableManager(_$AppDatabase db, $PantryItemsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PantryItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PantryItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PantryItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value barcode = const Value.absent(), + Value name = const Value.absent(), + Value brand = const Value.absent(), + Value imageUrl = const Value.absent(), + Value kcalPer100g = const Value.absent(), + Value proteinsPer100g = const Value.absent(), + Value carbsPer100g = const Value.absent(), + Value sugarsPer100g = const Value.absent(), + Value fatsPer100g = const Value.absent(), + Value packageQuantity = const Value.absent(), + Value amountLeft = const Value.absent(), + Value addedAt = const Value.absent(), + Value updatedAt = const Value.absent(), + }) => PantryItemsCompanion( + id: id, + barcode: barcode, + name: name, + brand: brand, + imageUrl: imageUrl, + kcalPer100g: kcalPer100g, + proteinsPer100g: proteinsPer100g, + carbsPer100g: carbsPer100g, + sugarsPer100g: sugarsPer100g, + fatsPer100g: fatsPer100g, + packageQuantity: packageQuantity, + amountLeft: amountLeft, + addedAt: addedAt, + updatedAt: updatedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value barcode = const Value.absent(), + required String name, + Value brand = const Value.absent(), + Value imageUrl = const Value.absent(), + Value kcalPer100g = const Value.absent(), + Value proteinsPer100g = const Value.absent(), + Value carbsPer100g = const Value.absent(), + Value sugarsPer100g = const Value.absent(), + Value fatsPer100g = const Value.absent(), + Value packageQuantity = const Value.absent(), + Value amountLeft = const Value.absent(), + Value addedAt = const Value.absent(), + Value updatedAt = const Value.absent(), + }) => PantryItemsCompanion.insert( + id: id, + barcode: barcode, + name: name, + brand: brand, + imageUrl: imageUrl, + kcalPer100g: kcalPer100g, + proteinsPer100g: proteinsPer100g, + carbsPer100g: carbsPer100g, + sugarsPer100g: sugarsPer100g, + fatsPer100g: fatsPer100g, + packageQuantity: packageQuantity, + amountLeft: amountLeft, + addedAt: addedAt, + updatedAt: updatedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$PantryItemsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PantryItemsTable, + PantryItem, + $$PantryItemsTableFilterComposer, + $$PantryItemsTableOrderingComposer, + $$PantryItemsTableAnnotationComposer, + $$PantryItemsTableCreateCompanionBuilder, + $$PantryItemsTableUpdateCompanionBuilder, + ( + PantryItem, + BaseReferences<_$AppDatabase, $PantryItemsTable, PantryItem>, + ), + PantryItem, + PrefetchHooks Function() + >; +typedef $$ConsumptionEntriesTableCreateCompanionBuilder = + ConsumptionEntriesCompanion Function({ + Value id, + required String name, + required double kcal, + required String mealType, + Value pantryItemId, + Value grams, + Value loggedAt, + }); +typedef $$ConsumptionEntriesTableUpdateCompanionBuilder = + ConsumptionEntriesCompanion Function({ + Value id, + Value name, + Value kcal, + Value mealType, + Value pantryItemId, + Value grams, + Value loggedAt, + }); + +class $$ConsumptionEntriesTableFilterComposer + extends Composer<_$AppDatabase, $ConsumptionEntriesTable> { + $$ConsumptionEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mealType => $composableBuilder( + column: $table.mealType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pantryItemId => $composableBuilder( + column: $table.pantryItemId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get grams => $composableBuilder( + column: $table.grams, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get loggedAt => $composableBuilder( + column: $table.loggedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ConsumptionEntriesTableOrderingComposer + extends Composer<_$AppDatabase, $ConsumptionEntriesTable> { + $$ConsumptionEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get mealType => $composableBuilder( + column: $table.mealType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pantryItemId => $composableBuilder( + column: $table.pantryItemId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get grams => $composableBuilder( + column: $table.grams, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get loggedAt => $composableBuilder( + column: $table.loggedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ConsumptionEntriesTableAnnotationComposer + extends Composer<_$AppDatabase, $ConsumptionEntriesTable> { + $$ConsumptionEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get kcal => + $composableBuilder(column: $table.kcal, builder: (column) => column); + + GeneratedColumn get mealType => + $composableBuilder(column: $table.mealType, builder: (column) => column); + + GeneratedColumn get pantryItemId => $composableBuilder( + column: $table.pantryItemId, + builder: (column) => column, + ); + + GeneratedColumn get grams => + $composableBuilder(column: $table.grams, builder: (column) => column); + + GeneratedColumn get loggedAt => + $composableBuilder(column: $table.loggedAt, builder: (column) => column); +} + +class $$ConsumptionEntriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ConsumptionEntriesTable, + ConsumptionEntry, + $$ConsumptionEntriesTableFilterComposer, + $$ConsumptionEntriesTableOrderingComposer, + $$ConsumptionEntriesTableAnnotationComposer, + $$ConsumptionEntriesTableCreateCompanionBuilder, + $$ConsumptionEntriesTableUpdateCompanionBuilder, + ( + ConsumptionEntry, + BaseReferences< + _$AppDatabase, + $ConsumptionEntriesTable, + ConsumptionEntry + >, + ), + ConsumptionEntry, + PrefetchHooks Function() + > { + $$ConsumptionEntriesTableTableManager( + _$AppDatabase db, + $ConsumptionEntriesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ConsumptionEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ConsumptionEntriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ConsumptionEntriesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value kcal = const Value.absent(), + Value mealType = const Value.absent(), + Value pantryItemId = const Value.absent(), + Value grams = const Value.absent(), + Value loggedAt = const Value.absent(), + }) => ConsumptionEntriesCompanion( + id: id, + name: name, + kcal: kcal, + mealType: mealType, + pantryItemId: pantryItemId, + grams: grams, + loggedAt: loggedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + required double kcal, + required String mealType, + Value pantryItemId = const Value.absent(), + Value grams = const Value.absent(), + Value loggedAt = const Value.absent(), + }) => ConsumptionEntriesCompanion.insert( + id: id, + name: name, + kcal: kcal, + mealType: mealType, + pantryItemId: pantryItemId, + grams: grams, + loggedAt: loggedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ConsumptionEntriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ConsumptionEntriesTable, + ConsumptionEntry, + $$ConsumptionEntriesTableFilterComposer, + $$ConsumptionEntriesTableOrderingComposer, + $$ConsumptionEntriesTableAnnotationComposer, + $$ConsumptionEntriesTableCreateCompanionBuilder, + $$ConsumptionEntriesTableUpdateCompanionBuilder, + ( + ConsumptionEntry, + BaseReferences< + _$AppDatabase, + $ConsumptionEntriesTable, + ConsumptionEntry + >, + ), + ConsumptionEntry, + PrefetchHooks Function() + >; +typedef $$ShoppingItemsTableCreateCompanionBuilder = + ShoppingItemsCompanion Function({ + Value id, + required String name, + Value note, + Value done, + Value source, + Value addedAt, + }); +typedef $$ShoppingItemsTableUpdateCompanionBuilder = + ShoppingItemsCompanion Function({ + Value id, + Value name, + Value note, + Value done, + Value source, + Value addedAt, + }); + +class $$ShoppingItemsTableFilterComposer + extends Composer<_$AppDatabase, $ShoppingItemsTable> { + $$ShoppingItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get done => $composableBuilder( + column: $table.done, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ShoppingItemsTableOrderingComposer + extends Composer<_$AppDatabase, $ShoppingItemsTable> { + $$ShoppingItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get done => $composableBuilder( + column: $table.done, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ShoppingItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $ShoppingItemsTable> { + $$ShoppingItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get done => + $composableBuilder(column: $table.done, builder: (column) => column); + + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + GeneratedColumn get addedAt => + $composableBuilder(column: $table.addedAt, builder: (column) => column); +} + +class $$ShoppingItemsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ShoppingItemsTable, + ShoppingItem, + $$ShoppingItemsTableFilterComposer, + $$ShoppingItemsTableOrderingComposer, + $$ShoppingItemsTableAnnotationComposer, + $$ShoppingItemsTableCreateCompanionBuilder, + $$ShoppingItemsTableUpdateCompanionBuilder, + ( + ShoppingItem, + BaseReferences<_$AppDatabase, $ShoppingItemsTable, ShoppingItem>, + ), + ShoppingItem, + PrefetchHooks Function() + > { + $$ShoppingItemsTableTableManager(_$AppDatabase db, $ShoppingItemsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ShoppingItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ShoppingItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ShoppingItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value note = const Value.absent(), + Value done = const Value.absent(), + Value source = const Value.absent(), + Value addedAt = const Value.absent(), + }) => ShoppingItemsCompanion( + id: id, + name: name, + note: note, + done: done, + source: source, + addedAt: addedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + Value note = const Value.absent(), + Value done = const Value.absent(), + Value source = const Value.absent(), + Value addedAt = const Value.absent(), + }) => ShoppingItemsCompanion.insert( + id: id, + name: name, + note: note, + done: done, + source: source, + addedAt: addedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ShoppingItemsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ShoppingItemsTable, + ShoppingItem, + $$ShoppingItemsTableFilterComposer, + $$ShoppingItemsTableOrderingComposer, + $$ShoppingItemsTableAnnotationComposer, + $$ShoppingItemsTableCreateCompanionBuilder, + $$ShoppingItemsTableUpdateCompanionBuilder, + ( + ShoppingItem, + BaseReferences<_$AppDatabase, $ShoppingItemsTable, ShoppingItem>, + ), + ShoppingItem, + PrefetchHooks Function() + >; +typedef $$ArticlesTableCreateCompanionBuilder = + ArticlesCompanion Function({ + Value id, + required String url, + required String title, + required String summary, + Value createdAt, + }); +typedef $$ArticlesTableUpdateCompanionBuilder = + ArticlesCompanion Function({ + Value id, + Value url, + Value title, + Value summary, + Value createdAt, + }); + +class $$ArticlesTableFilterComposer + extends Composer<_$AppDatabase, $ArticlesTable> { + $$ArticlesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ArticlesTableOrderingComposer + extends Composer<_$AppDatabase, $ArticlesTable> { + $$ArticlesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ArticlesTableAnnotationComposer + extends Composer<_$AppDatabase, $ArticlesTable> { + $$ArticlesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$ArticlesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ArticlesTable, + Article, + $$ArticlesTableFilterComposer, + $$ArticlesTableOrderingComposer, + $$ArticlesTableAnnotationComposer, + $$ArticlesTableCreateCompanionBuilder, + $$ArticlesTableUpdateCompanionBuilder, + (Article, BaseReferences<_$AppDatabase, $ArticlesTable, Article>), + Article, + PrefetchHooks Function() + > { + $$ArticlesTableTableManager(_$AppDatabase db, $ArticlesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ArticlesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ArticlesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ArticlesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value summary = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ArticlesCompanion( + id: id, + url: url, + title: title, + summary: summary, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String url, + required String title, + required String summary, + Value createdAt = const Value.absent(), + }) => ArticlesCompanion.insert( + id: id, + url: url, + title: title, + summary: summary, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ArticlesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ArticlesTable, + Article, + $$ArticlesTableFilterComposer, + $$ArticlesTableOrderingComposer, + $$ArticlesTableAnnotationComposer, + $$ArticlesTableCreateCompanionBuilder, + $$ArticlesTableUpdateCompanionBuilder, + (Article, BaseReferences<_$AppDatabase, $ArticlesTable, Article>), + Article, + PrefetchHooks Function() + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$PantryItemsTableTableManager get pantryItems => + $$PantryItemsTableTableManager(_db, _db.pantryItems); + $$ConsumptionEntriesTableTableManager get consumptionEntries => + $$ConsumptionEntriesTableTableManager(_db, _db.consumptionEntries); + $$ShoppingItemsTableTableManager get shoppingItems => + $$ShoppingItemsTableTableManager(_db, _db.shoppingItems); + $$ArticlesTableTableManager get articles => + $$ArticlesTableTableManager(_db, _db.articles); +} diff --git a/lib/data/meal_type.dart b/lib/data/meal_type.dart new file mode 100644 index 0000000..65bed4d --- /dev/null +++ b/lib/data/meal_type.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +enum MealType { + breakfast('breakfast', 'Breakfast', Icons.free_breakfast), + lunch('lunch', 'Lunch', Icons.lunch_dining), + dinner('dinner', 'Dinner', Icons.dinner_dining), + snack('snack', 'Snack', Icons.cookie); + + const MealType(this.value, this.label, this.icon); + + final String value; + final String label; + final IconData icon; + + static MealType fromValue(String value) => MealType.values.firstWhere( + (t) => t.value == value, + orElse: () => MealType.snack, + ); + + /// Best guess for the current time of day. + static MealType suggestedNow([DateTime? now]) { + final hour = (now ?? DateTime.now()).hour; + if (hour < 11) return MealType.breakfast; + if (hour < 15) return MealType.lunch; + if (hour < 18) return MealType.snack; + if (hour < 22) return MealType.dinner; + return MealType.snack; + } +} diff --git a/lib/data/services/anthropic_service.dart b/lib/data/services/anthropic_service.dart new file mode 100644 index 0000000..0d9672b --- /dev/null +++ b/lib/data/services/anthropic_service.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Thrown when the Anthropic API returns an error. +class AnthropicException implements Exception { + AnthropicException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + bool get isAuthError => statusCode == 401; + + @override + String toString() => 'AnthropicException($statusCode): $message'; +} + +/// A meal suggestion produced by the AI meal maker. +class MealSuggestion { + const MealSuggestion({ + required this.title, + required this.description, + required this.timeMinutes, + required this.kcal, + required this.usedIngredients, + required this.missingIngredients, + required this.steps, + }); + + factory MealSuggestion.fromJson(Map json) { + return MealSuggestion( + title: json['title'] as String, + description: json['description'] as String, + timeMinutes: (json['time_minutes'] as num).toInt(), + kcal: (json['kcal'] as num).toDouble(), + usedIngredients: (json['used_ingredients'] as List) + .cast(), + missingIngredients: (json['missing_ingredients'] as List) + .cast(), + steps: (json['steps'] as List).cast(), + ); + } + + final String title; + final String description; + final int timeMinutes; + final double kcal; + final List usedIngredients; + final List missingIngredients; + final List steps; +} + +/// A grocery purchase suggestion produced by the AI. +class GrocerySuggestion { + const GrocerySuggestion({required this.name, required this.reason}); + + factory GrocerySuggestion.fromJson(Map json) { + return GrocerySuggestion( + name: json['name'] as String, + reason: json['reason'] as String, + ); + } + + final String name; + final String reason; +} + +/// Direct client for the Anthropic Messages API (there is no official Dart +/// SDK). The user supplies their own API key (BYOK). +class AnthropicService { + AnthropicService({required this.apiKey, http.Client? client}) + : _client = client ?? http.Client(); + + static const endpoint = 'https://api.anthropic.com/v1/messages'; + static const model = 'claude-opus-4-8'; + + final String apiKey; + final http.Client _client; + + Map get _headers => { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }; + + /// Streams a summary of [text] as it is generated (SSE). + Stream streamArticleSummary({ + required String? title, + required String text, + required String language, + }) async* { + final request = http.Request('POST', Uri.parse(endpoint)) + ..headers.addAll(_headers) + ..body = jsonEncode({ + 'model': model, + 'max_tokens': 16000, + 'stream': true, + 'system': + 'You are an expert at extracting key information from web articles. ' + 'Produce a concise, well-organized summary: a one-sentence TL;DR ' + 'first, then short paragraphs or "- " bullet points for the key ' + 'facts. Ignore navigation, ads and unrelated content. Plain text ' + 'only, no markdown headers. Answer in $language.', + 'messages': [ + { + 'role': 'user', + 'content': + 'Summarize this article${title == null ? '' : ' titled "$title"'}:\n\n$text', + }, + ], + }); + + final response = await _client.send(request); + if (response.statusCode != 200) { + final body = await response.stream.bytesToString(); + throw AnthropicException( + _errorMessage(body), + statusCode: response.statusCode, + ); + } + yield* parseSseTextDeltas(response.stream.transform(utf8.decoder)); + } + + /// Parses an Anthropic SSE stream into the text deltas it carries. + /// Exposed for testing. + static Stream parseSseTextDeltas(Stream input) async* { + final lines = input.transform(const LineSplitter()); + await for (final line in lines) { + if (!line.startsWith('data: ')) continue; + final payload = line.substring(6).trim(); + if (payload.isEmpty || payload == '[DONE]') continue; + final Map event; + try { + event = jsonDecode(payload) as Map; + } on FormatException { + continue; + } + switch (event['type']) { + case 'content_block_delta': + final delta = event['delta'] as Map?; + if (delta?['type'] == 'text_delta') { + yield delta!['text'] as String; + } + case 'error': + final error = event['error'] as Map?; + throw AnthropicException( + (error?['message'] as String?) ?? 'Unknown streaming error', + ); + } + } + } + + /// Asks for meal suggestions based on what is in the kitchen. + /// Uses structured outputs so the response is guaranteed valid JSON. + Future> suggestMeals({ + required List> pantry, + required double remainingKcal, + required String language, + }) async { + final result = await _structuredRequest( + system: + 'You are a pragmatic home-cooking assistant for a lazy person who is ' + 'addicted to sugar and quick meals, and wants to eat healthier with ' + 'minimal effort. Suggest simple, realistic meals that mostly use ' + 'what is already in the kitchen. Prefer few steps and short cooking ' + 'times. Answer in $language.', + userContent: + 'Here is my kitchen inventory as JSON (amount_left_percent is how ' + 'much of the package remains):\n${jsonEncode(pantry)}\n\n' + 'I have about ${remainingKcal.round()} kcal left for today. ' + 'Suggest exactly 3 healthy, low-effort meals I can make right now. ' + 'Only list an ingredient in missing_ingredients if I truly need to ' + 'buy it (assume I have water, salt, pepper and basic oil).', + schema: { + 'type': 'object', + 'properties': { + 'meals': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'title': {'type': 'string'}, + 'description': {'type': 'string'}, + 'time_minutes': {'type': 'integer'}, + 'kcal': {'type': 'number'}, + 'used_ingredients': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'missing_ingredients': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'steps': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': [ + 'title', + 'description', + 'time_minutes', + 'kcal', + 'used_ingredients', + 'missing_ingredients', + 'steps', + ], + 'additionalProperties': false, + }, + }, + }, + 'required': ['meals'], + 'additionalProperties': false, + }, + ); + return (result['meals'] as List) + .map((m) => MealSuggestion.fromJson(m as Map)) + .toList(); + } + + /// Asks what should be bought at the store, given the pantry state and + /// recent consumption habits. + Future> suggestGroceries({ + required List> pantry, + required List recentlyEaten, + required List alreadyOnList, + required String language, + }) async { + final result = await _structuredRequest( + system: + 'You are a grocery-planning assistant for a lazy person trying to ' + 'eat healthier. Suggest practical items to buy so that healthy, ' + 'low-effort meals are always possible. Favor staples and fresh ' + 'items that complement what they own. Answer in $language.', + userContent: + 'Kitchen inventory:\n${jsonEncode(pantry)}\n\n' + 'Recently eaten: ${jsonEncode(recentlyEaten)}\n' + 'Already on my shopping list: ${jsonEncode(alreadyOnList)}\n\n' + 'Suggest up to 8 items I should buy (do not repeat items already on ' + 'the list), each with a very short reason.', + schema: { + 'type': 'object', + 'properties': { + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'reason': {'type': 'string'}, + }, + 'required': ['name', 'reason'], + 'additionalProperties': false, + }, + }, + }, + 'required': ['items'], + 'additionalProperties': false, + }, + ); + return (result['items'] as List) + .map((i) => GrocerySuggestion.fromJson(i as Map)) + .toList(); + } + + Future> _structuredRequest({ + required String system, + required String userContent, + required Map schema, + }) async { + final response = await _client + .post( + Uri.parse(endpoint), + headers: _headers, + body: jsonEncode({ + 'model': model, + 'max_tokens': 8000, + 'system': system, + 'output_config': { + 'format': {'type': 'json_schema', 'schema': schema}, + }, + 'messages': [ + {'role': 'user', 'content': userContent}, + ], + }), + ) + .timeout(const Duration(minutes: 5)); + + if (response.statusCode != 200) { + throw AnthropicException( + _errorMessage(response.body), + statusCode: response.statusCode, + ); + } + return extractStructuredJson(response.body); + } + + /// Pulls the JSON payload out of a structured-output response body. + /// Exposed for testing. + static Map extractStructuredJson(String responseBody) { + final body = jsonDecode(responseBody) as Map; + if (body['stop_reason'] == 'refusal') { + throw AnthropicException('The model declined to answer this request.'); + } + final content = (body['content'] as List) + .cast>(); + final textBlock = content.firstWhere( + (b) => b['type'] == 'text', + orElse: () => throw AnthropicException('No text block in response'), + ); + return jsonDecode(textBlock['text'] as String) as Map; + } + + static String _errorMessage(String body) { + try { + final json = jsonDecode(body) as Map; + final error = json['error'] as Map?; + return (error?['message'] as String?) ?? body; + } on FormatException { + return body; + } + } +} diff --git a/lib/data/services/article_extractor.dart b/lib/data/services/article_extractor.dart new file mode 100644 index 0000000..ced213d --- /dev/null +++ b/lib/data/services/article_extractor.dart @@ -0,0 +1,93 @@ +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; + +/// The readable content pulled out of a web page. +class ExtractedArticle { + const ExtractedArticle({required this.title, required this.text}); + + final String title; + final String text; +} + +/// Fetches a web page and extracts its readable text in-app. +/// (Replaces the old dependency on the readly.lightin.io readability API — +/// Claude does the heavy lifting on the extracted text anyway.) +class ArticleExtractor { + ArticleExtractor({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + + /// Characters beyond this are dropped before sending to the model. + static const maxChars = 60000; + + Future extract(String url) async { + final response = await _client + .get( + Uri.parse(url), + headers: { + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/126.0 Mobile Safari/537.36', + }, + ) + .timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw Exception('Could not fetch the page (HTTP ${response.statusCode})'); + } + return extractFromHtml(response.body, fallbackTitle: url); + } + + /// Pure HTML → readable text extraction. Exposed for testing. + static ExtractedArticle extractFromHtml( + String htmlSource, { + required String fallbackTitle, + }) { + final document = html_parser.parse(htmlSource); + + // Remove non-content elements. + for (final selector in [ + 'script', + 'style', + 'noscript', + 'nav', + 'header', + 'footer', + 'aside', + 'form', + 'iframe', + ]) { + for (final node in document.querySelectorAll(selector)) { + node.remove(); + } + } + + final title = document.querySelector('title')?.text.trim(); + final root = + document.querySelector('article') ?? + document.querySelector('main') ?? + document.body; + + final text = _readableText(root); + return ExtractedArticle( + title: (title == null || title.isEmpty) ? fallbackTitle : title, + text: text.length > maxChars ? text.substring(0, maxChars) : text, + ); + } + + static String _readableText(dom.Element? root) { + if (root == null) return ''; + final blocks = root.querySelectorAll('p, h1, h2, h3, h4, li, blockquote'); + final parts = []; + for (final block in blocks) { + final text = block.text.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (text.length > 30 || (block.localName?.startsWith('h') ?? false)) { + if (text.isNotEmpty) parts.add(text); + } + } + if (parts.isEmpty) { + return root.text.replaceAll(RegExp(r'\s+'), ' ').trim(); + } + return parts.join('\n\n'); + } +} diff --git a/lib/data/services/off_service.dart b/lib/data/services/off_service.dart new file mode 100644 index 0000000..11bf993 --- /dev/null +++ b/lib/data/services/off_service.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// A product looked up on Open Food Facts. +class OffProduct { + const OffProduct({ + required this.barcode, + required this.name, + this.brand, + this.imageUrl, + this.quantity, + this.kcalPer100g, + this.proteinsPer100g, + this.carbsPer100g, + this.sugarsPer100g, + this.fatsPer100g, + this.servingGrams, + }); + + final String barcode; + final String name; + final String? brand; + final String? imageUrl; + + /// Package size as printed, e.g. "500 g". + final String? quantity; + final double? kcalPer100g; + final double? proteinsPer100g; + final double? carbsPer100g; + final double? sugarsPer100g; + final double? fatsPer100g; + final double? servingGrams; + + /// Parses an Open Food Facts v2 product payload. Returns null when the + /// product is unknown or has no usable name. + static OffProduct? fromApiJson(String barcode, Map json) { + final product = json['product'] as Map?; + if (product == null) return null; + final name = (product['product_name'] as String?)?.trim(); + if (name == null || name.isEmpty) return null; + + final nutriments = + (product['nutriments'] as Map?) ?? const {}; + return OffProduct( + barcode: barcode, + name: name, + brand: (product['brands'] as String?)?.split(',').first.trim(), + imageUrl: product['image_front_url'] as String?, + quantity: product['quantity'] as String?, + kcalPer100g: _asDouble(nutriments['energy-kcal_100g']), + proteinsPer100g: _asDouble(nutriments['proteins_100g']), + carbsPer100g: _asDouble(nutriments['carbohydrates_100g']), + sugarsPer100g: _asDouble(nutriments['sugars_100g']), + fatsPer100g: _asDouble(nutriments['fat_100g']), + servingGrams: _asDouble(product['serving_quantity']), + ); + } + + static double? _asDouble(Object? value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value.replaceAll(',', '.')); + return null; + } +} + +/// Minimal Open Food Facts v2 client. +class OpenFoodFactsService { + OpenFoodFactsService({http.Client? client}) + : _client = client ?? http.Client(); + + static const _fields = + 'product_name,brands,image_front_url,quantity,' + 'nutriments,serving_quantity'; + + final http.Client _client; + + /// Looks up a product by barcode. Returns null when not found. + Future fetchProduct(String barcode) async { + final uri = Uri.parse( + 'https://world.openfoodfacts.org/api/v2/product/$barcode.json?fields=$_fields', + ); + final response = await _client + .get( + uri, + // OFF asks API users to identify themselves. + headers: {'User-Agent': 'Readly/3.0 (personal kcal tracker)'}, + ) + .timeout(const Duration(seconds: 20)); + + if (response.statusCode == 404) return null; + if (response.statusCode != 200) { + throw Exception('Open Food Facts error ${response.statusCode}'); + } + final json = jsonDecode(response.body) as Map; + if (json['status'] == 0) return null; + return OffProduct.fromApiJson(barcode, json); + } +} diff --git a/lib/data/services/settings_service.dart b/lib/data/services/settings_service.dart new file mode 100644 index 0000000..ddae356 --- /dev/null +++ b/lib/data/services/settings_service.dart @@ -0,0 +1,73 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppSettings { + const AppSettings({ + required this.hasApiKey, + required this.language, + required this.dailyKcalGoal, + }); + + final bool hasApiKey; + final String language; + final int dailyKcalGoal; + + AppSettings copyWith({ + bool? hasApiKey, + String? language, + int? dailyKcalGoal, + }) { + return AppSettings( + hasApiKey: hasApiKey ?? this.hasApiKey, + language: language ?? this.language, + dailyKcalGoal: dailyKcalGoal ?? this.dailyKcalGoal, + ); + } +} + +class SettingsService { + SettingsService({ + FlutterSecureStorage? secureStorage, + SharedPreferencesAsync? prefs, + }) : _secure = secureStorage ?? const FlutterSecureStorage(), + _prefsOverride = prefs; + + static const _apiKeyKey = 'anthropicApiKey'; + static const _languageKey = 'language'; + static const _kcalGoalKey = 'dailyKcalGoal'; + static const defaultKcalGoal = 2200; + static const defaultLanguage = 'english'; + + final FlutterSecureStorage _secure; + final SharedPreferencesAsync? _prefsOverride; + + // Lazy so that test fakes overriding every method never touch the platform. + late final SharedPreferencesAsync _prefs = + _prefsOverride ?? SharedPreferencesAsync(); + + Future getApiKey() => _secure.read(key: _apiKeyKey); + + Future setApiKey(String? key) async { + if (key == null || key.trim().isEmpty) { + await _secure.delete(key: _apiKeyKey); + } else { + await _secure.write(key: _apiKeyKey, value: key.trim()); + } + } + + Future load() async { + final apiKey = await getApiKey(); + final language = await _prefs.getString(_languageKey); + final goal = await _prefs.getInt(_kcalGoalKey); + return AppSettings( + hasApiKey: apiKey != null && apiKey.isNotEmpty, + language: language ?? defaultLanguage, + dailyKcalGoal: goal ?? defaultKcalGoal, + ); + } + + Future setLanguage(String language) => + _prefs.setString(_languageKey, language); + + Future setDailyKcalGoal(int goal) => _prefs.setInt(_kcalGoalKey, goal); +} diff --git a/lib/features/groceries/groceries_page.dart b/lib/features/groceries/groceries_page.dart new file mode 100644 index 0000000..5c8759d --- /dev/null +++ b/lib/features/groceries/groceries_page.dart @@ -0,0 +1,220 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/db/database.dart'; +import '../../data/services/anthropic_service.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; + +class GroceriesPage extends ConsumerStatefulWidget { + const GroceriesPage({super.key}); + + @override + ConsumerState createState() => _GroceriesPageState(); +} + +class _GroceriesPageState extends ConsumerState { + final _addController = TextEditingController(); + bool _aiLoading = false; + + @override + void dispose() { + _addController.dispose(); + super.dispose(); + } + + Future _addManual() async { + final name = _addController.text.trim(); + if (name.isEmpty) return; + _addController.clear(); + await ref + .read(databaseProvider) + .addShoppingItem(ShoppingItemsCompanion.insert(name: name)); + } + + Future _askAi() async { + final anthropic = await ref.read(anthropicServiceProvider.future); + if (!mounted) return; + if (anthropic == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Add your Anthropic API key in settings first.'), + action: SnackBarAction( + label: 'Settings', + onPressed: () => context.push('/settings'), + ), + ), + ); + return; + } + + setState(() => _aiLoading = true); + try { + final db = ref.read(databaseProvider); + final settings = await ref.read(settingsProvider.future); + final pantry = await ref.read(pantryProvider.future); + final current = await ref.read(shoppingProvider.future); + final recent = await db.entriesSince( + DateTime.now().subtract(const Duration(days: 14)), + ); + + final suggestions = await anthropic.suggestGroceries( + pantry: [ + for (final item in pantry) + { + 'name': item.name, + 'amount_left_percent': (item.amountLeft * 100).round(), + }, + ], + recentlyEaten: recent.map((e) => e.name).toSet().take(30).toList(), + alreadyOnList: current.map((i) => i.name).toList(), + language: settings.language, + ); + + final existingNames = current.map((i) => i.name.toLowerCase()).toSet(); + var added = 0; + for (final suggestion in suggestions) { + if (existingNames.contains(suggestion.name.toLowerCase())) continue; + await db.addShoppingItem( + ShoppingItemsCompanion.insert( + name: suggestion.name, + note: Value(suggestion.reason), + source: const Value('ai'), + ), + ); + added++; + } + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Added $added AI suggestions.'))); + } + } on AnthropicException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Suggestion failed: $e'))); + } + } finally { + if (mounted) setState(() => _aiLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final items = ref.watch(shoppingProvider).value ?? []; + final hasDone = items.any((i) => i.done); + + return Scaffold( + appBar: AppBar( + title: const Text('Groceries'), + actions: [ + if (hasDone) + IconButton( + tooltip: 'Clear checked items', + icon: const Icon(Icons.playlist_remove), + onPressed: () => ref.read(databaseProvider).clearDoneShopping(), + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + icon: _aiLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + label: const Text('What should I buy?'), + onPressed: _aiLoading ? null : _askAi, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: TextField( + controller: _addController, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Add an item…', + prefixIcon: const Icon(Icons.add), + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_upward), + onPressed: _addManual, + ), + ), + onSubmitted: (_) => _addManual(), + ), + ), + Expanded( + child: items.isEmpty + ? const EmptyState( + icon: Icons.shopping_basket, + title: 'Nothing to buy', + message: + 'Add items yourself, or let the AI propose what to ' + 'buy based on your kitchen and eating habits.', + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 96), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Dismissible( + key: ValueKey('shop-${item.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: Theme.of(context).colorScheme.errorContainer, + child: const Icon(Icons.delete_outline), + ), + onDismissed: (_) => ref + .read(databaseProvider) + .deleteShoppingItem(item.id), + child: CheckboxListTile( + value: item.done, + onChanged: (value) => ref + .read(databaseProvider) + .setShoppingDone(item.id, value ?? false), + title: Text( + item.name, + style: item.done + ? const TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: item.note == null + ? null + : Text( + item.note!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + secondary: item.source == 'ai' + ? const Icon(Icons.auto_awesome, size: 18) + : null, + controlAffinity: ListTileControlAffinity.leading, + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/kitchen/kitchen_page.dart b/lib/features/kitchen/kitchen_page.dart new file mode 100644 index 0000000..6f12bf0 --- /dev/null +++ b/lib/features/kitchen/kitchen_page.dart @@ -0,0 +1,422 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/db/database.dart'; +import '../../data/services/off_service.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; + +class KitchenPage extends ConsumerWidget { + const KitchenPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pantry = ref.watch(pantryProvider).value ?? []; + + return Scaffold( + appBar: AppBar( + title: const Text('Kitchen'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('Add item'), + onPressed: () => _showAddMenu(context, ref), + ), + body: pantry.isEmpty + ? const EmptyState( + icon: Icons.kitchen, + title: 'Your kitchen is empty', + message: + 'Scan the barcodes of what you have at home to build your ' + 'inventory — the meal maker uses it to suggest what to cook.', + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), + itemCount: pantry.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) => _PantryCard(item: pantry[index]), + ), + ); + } + + void _showAddMenu(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('Scan a barcode'), + subtitle: const Text('Fill in details from Open Food Facts'), + onTap: () { + Navigator.pop(sheetContext); + _scanAndAdd(context, ref); + }, + ), + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Add manually'), + onTap: () { + Navigator.pop(sheetContext); + showPantryEditSheet(context, ref); + }, + ), + ], + ), + ), + ); + } + + Future _scanAndAdd(BuildContext context, WidgetRef ref) async { + final code = await context.push('/scan'); + if (code == null || !context.mounted) return; + + final db = ref.read(databaseProvider); + final existing = await db.pantryItemByBarcode(code); + if (!context.mounted) return; + if (existing != null) { + // Re-scanning something you own: treat it as a fresh, full package. + await db.updatePantryItem( + existing.id, + const PantryItemsCompanion(amountLeft: Value(1.0)), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${existing.name}" refilled to 100%.')), + ); + } + return; + } + + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Looking up product…')), + ); + OffProduct? product; + try { + product = await ref.read(offServiceProvider).fetchProduct(code); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Lookup failed: $e'))); + } + messenger.hideCurrentSnackBar(); + if (!context.mounted) return; + await showPantryEditSheet(context, ref, product: product, barcode: code); + } +} + +class _PantryCard extends ConsumerStatefulWidget { + const _PantryCard({required this.item}); + + final PantryItem item; + + @override + ConsumerState<_PantryCard> createState() => _PantryCardState(); +} + +class _PantryCardState extends ConsumerState<_PantryCard> { + double? _pendingAmount; + + @override + Widget build(BuildContext context) { + final item = widget.item; + final scheme = Theme.of(context).colorScheme; + final amount = _pendingAmount ?? item.amountLeft; + + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Thumbnail(imageUrl: item.imageUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + [ + if (item.brand != null) item.brand!, + if (item.packageQuantity != null) + item.packageQuantity!, + if (item.kcalPer100g != null) + '${item.kcalPer100g!.round()} kcal/100g', + ].join(' · '), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + PopupMenuButton( + onSelected: (value) => _onMenu(value), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem( + value: 'shop', + child: Text('Add to groceries'), + ), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ], + ), + Row( + children: [ + Expanded( + child: Slider( + value: amount, + onChanged: (v) => setState(() => _pendingAmount = v), + onChangeEnd: (v) { + ref + .read(databaseProvider) + .updatePantryItem( + item.id, + PantryItemsCompanion(amountLeft: Value(v)), + ); + }, + ), + ), + SizedBox( + width: 48, + child: Text( + '${(amount * 100).round()}%', + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 8), + ], + ), + ], + ), + ), + ); + } + + Future _onMenu(String value) async { + final db = ref.read(databaseProvider); + switch (value) { + case 'edit': + await showPantryEditSheet(context, ref, existing: widget.item); + case 'shop': + await db.addShoppingItem( + ShoppingItemsCompanion.insert(name: widget.item.name), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('"${widget.item.name}" added to groceries.'), + ), + ); + } + case 'delete': + await db.deletePantryItem(widget.item.id); + } + } +} + +class _Thumbnail extends StatelessWidget { + const _Thumbnail({this.imageUrl}); + + final String? imageUrl; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: 52, + height: 52, + child: imageUrl == null + ? ColoredBox( + color: scheme.surfaceContainerHigh, + child: Icon(Icons.fastfood, color: scheme.onSurfaceVariant), + ) + : Image.network( + imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: scheme.surfaceContainerHigh, + child: Icon(Icons.fastfood, color: scheme.onSurfaceVariant), + ), + ), + ), + ); + } +} + +/// Add/edit sheet, optionally pre-filled from an Open Food Facts [product] +/// or an [existing] pantry item. +Future showPantryEditSheet( + BuildContext context, + WidgetRef ref, { + OffProduct? product, + PantryItem? existing, + String? barcode, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _PantryEditSheet( + product: product, + existing: existing, + barcode: barcode, + ), + ); +} + +class _PantryEditSheet extends ConsumerStatefulWidget { + const _PantryEditSheet({this.product, this.existing, this.barcode}); + + final OffProduct? product; + final PantryItem? existing; + final String? barcode; + + @override + ConsumerState<_PantryEditSheet> createState() => _PantryEditSheetState(); +} + +class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { + late final TextEditingController _name; + late final TextEditingController _brand; + late final TextEditingController _kcal; + late final TextEditingController _package; + + @override + void initState() { + super.initState(); + final p = widget.product; + final e = widget.existing; + _name = TextEditingController(text: e?.name ?? p?.name ?? ''); + _brand = TextEditingController(text: e?.brand ?? p?.brand ?? ''); + _kcal = TextEditingController( + text: (e?.kcalPer100g ?? p?.kcalPer100g)?.round().toString() ?? '', + ); + _package = TextEditingController( + text: e?.packageQuantity ?? p?.quantity ?? '', + ); + } + + @override + void dispose() { + _name.dispose(); + _brand.dispose(); + _kcal.dispose(); + _package.dispose(); + super.dispose(); + } + + Future _save() async { + final name = _name.text.trim(); + if (name.isEmpty) return; + final db = ref.read(databaseProvider); + final kcal = double.tryParse(_kcal.text.replaceAll(',', '.')); + final brand = _brand.text.trim(); + final package = _package.text.trim(); + final p = widget.product; + + if (widget.existing != null) { + await db.updatePantryItem( + widget.existing!.id, + PantryItemsCompanion( + name: Value(name), + brand: Value(brand.isEmpty ? null : brand), + kcalPer100g: Value(kcal), + packageQuantity: Value(package.isEmpty ? null : package), + ), + ); + } else { + await db.addPantryItem( + PantryItemsCompanion.insert( + name: name, + barcode: Value(widget.barcode ?? p?.barcode), + brand: Value(brand.isEmpty ? null : brand), + imageUrl: Value(p?.imageUrl), + kcalPer100g: Value(kcal), + proteinsPer100g: Value(p?.proteinsPer100g), + carbsPer100g: Value(p?.carbsPer100g), + sugarsPer100g: Value(p?.sugarsPer100g), + fatsPer100g: Value(p?.fatsPer100g), + packageQuantity: Value(package.isEmpty ? null : package), + ), + ); + } + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.existing == null ? 'Add to kitchen' : 'Edit item', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 20), + TextField( + controller: _name, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: _brand, + decoration: const InputDecoration(labelText: 'Brand (optional)'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _kcal, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'kcal / 100 g'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _package, + decoration: const InputDecoration( + labelText: 'Package (e.g. 500 g)', + ), + ), + ), + ], + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: const Icon(Icons.check), + label: Text(widget.existing == null ? 'Add item' : 'Save'), + onPressed: _save, + ), + ], + ), + ); + } +} diff --git a/lib/features/meals/meals_page.dart b/lib/features/meals/meals_page.dart new file mode 100644 index 0000000..c068a99 --- /dev/null +++ b/lib/features/meals/meals_page.dart @@ -0,0 +1,303 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/db/database.dart'; +import '../../data/meal_type.dart'; +import '../../data/services/anthropic_service.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; + +class MealsPage extends ConsumerWidget { + const MealsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final suggestions = ref.watch(mealSuggestionsProvider); + final hasApiKey = ref.watch(settingsProvider).value?.hasApiKey ?? false; + + return Scaffold( + appBar: AppBar( + title: const Text('Meals'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: switch (suggestions) { + null => _Idle(hasApiKey: hasApiKey), + AsyncLoading() => const _Loading(), + AsyncError(:final error) => EmptyState( + icon: Icons.error_outline, + title: 'Could not get suggestions', + message: error is AnthropicException ? error.message : '$error', + action: FilledButton( + onPressed: () => + ref.read(mealSuggestionsProvider.notifier).generate(), + child: const Text('Try again'), + ), + ), + AsyncValue(:final value?) => _SuggestionList(meals: value), + }, + ); + } +} + +class _Idle extends ConsumerWidget { + const _Idle({required this.hasApiKey}); + + final bool hasApiKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!hasApiKey) { + return EmptyState( + icon: Icons.key_off, + title: 'AI is not set up yet', + message: + 'Add your Anthropic API key in settings to unlock meal ' + 'suggestions and article summaries.', + action: FilledButton.icon( + icon: const Icon(Icons.settings), + label: const Text('Open settings'), + onPressed: () => context.push('/settings'), + ), + ); + } + final pantryCount = ref.watch(pantryProvider).value?.length ?? 0; + return EmptyState( + icon: Icons.restaurant_menu, + title: 'What should I cook?', + message: pantryCount == 0 + ? 'Your kitchen is empty. Scan a few items first so the ' + 'suggestions can use what you actually own.' + : 'Based on the $pantryCount items in your kitchen and your ' + 'remaining kcal for today, get 3 healthy, low-effort ideas.', + action: FilledButton.icon( + icon: const Icon(Icons.auto_awesome), + label: const Text('Suggest 3 meals'), + onPressed: pantryCount == 0 + ? null + : () => ref.read(mealSuggestionsProvider.notifier).generate(), + ), + ); + } +} + +class _Loading extends StatelessWidget { + const _Loading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Cooking up ideas…'), + ], + ), + ); + } +} + +class _SuggestionList extends ConsumerWidget { + const _SuggestionList({required this.meals}); + + final List meals; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + for (final meal in meals) ...[ + _MealCard(meal: meal), + const SizedBox(height: 12), + ], + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Suggest something else'), + onPressed: () => + ref.read(mealSuggestionsProvider.notifier).generate(), + ), + ], + ); + } +} + +class _MealCard extends ConsumerWidget { + const _MealCard({required this.meal}); + + final MealSuggestion meal; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + meal.title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 6), + Text(meal.description), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: const Icon(Icons.schedule, size: 18), + label: Text('${meal.timeMinutes} min'), + ), + Chip( + avatar: const Icon(Icons.local_fire_department, size: 18), + label: Text('${meal.kcal.round()} kcal'), + ), + ], + ), + if (meal.usedIngredients.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'From your kitchen', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + meal.usedIngredients.join(' · '), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ], + if (meal.missingIngredients.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'Missing — tap to add to groceries', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final ingredient in meal.missingIngredients) + ActionChip( + avatar: const Icon(Icons.add_shopping_cart, size: 18), + label: Text(ingredient), + onPressed: () => + _addToGroceries(context, ref, ingredient), + ), + ], + ), + ], + if (meal.steps.isNotEmpty) ...[ + const SizedBox(height: 8), + ExpansionTile( + tilePadding: EdgeInsets.zero, + shape: const Border(), + title: Text( + 'Steps', + style: Theme.of(context).textTheme.labelLarge, + ), + children: [ + for (final (index, step) in meal.steps.indexed) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 13, + child: Text('${index + 1}'), + ), + title: Text(step), + ), + ], + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonalIcon( + icon: const Icon(Icons.check), + label: const Text('I made it'), + onPressed: () => _logMeal(context, ref), + ), + ), + ], + ), + ), + ); + } + + Future _addToGroceries( + BuildContext context, + WidgetRef ref, + String ingredient, + ) async { + await ref + .read(databaseProvider) + .addShoppingItem( + ShoppingItemsCompanion.insert( + name: ingredient, + note: Value('For "${meal.title}"'), + source: const Value('ai'), + ), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"$ingredient" added to groceries.')), + ); + } + } + + Future _logMeal(BuildContext context, WidgetRef ref) async { + final type = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Log "${meal.title}" (${meal.kcal.round()} kcal) as:', + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + ), + for (final type in MealType.values) + ListTile( + leading: Icon(type.icon), + title: Text(type.label), + onTap: () => Navigator.pop(sheetContext, type), + ), + ], + ), + ), + ); + if (type == null) return; + await ref + .read(databaseProvider) + .logConsumption( + ConsumptionEntriesCompanion.insert( + name: meal.title, + kcal: meal.kcal, + mealType: type.value, + ), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Logged ${meal.kcal.round()} kcal. Enjoy!')), + ); + } + } +} diff --git a/lib/features/reader/reader_page.dart b/lib/features/reader/reader_page.dart new file mode 100644 index 0000000..10f5c2b --- /dev/null +++ b/lib/features/reader/reader_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers.dart'; +import '../../widgets/common.dart'; + +class ReaderPage extends ConsumerStatefulWidget { + const ReaderPage({super.key}); + + @override + ConsumerState createState() => _ReaderPageState(); +} + +class _ReaderPageState extends ConsumerState { + final _urlController = TextEditingController(); + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + void _summarize() { + var url = _urlController.text.trim(); + if (url.isEmpty) return; + if (!url.startsWith('http')) url = 'https://$url'; + _urlController.clear(); + context.push( + Uri(path: '/read/summary', queryParameters: {'url': url}).toString(), + ); + } + + @override + Widget build(BuildContext context) { + final articles = ref.watch(articlesProvider).value ?? []; + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Read'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + TextField( + controller: _urlController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + hintText: 'Paste an article URL…', + prefixIcon: const Icon(Icons.link), + suffixIcon: IconButton( + icon: const Icon(Icons.auto_awesome), + onPressed: _summarize, + ), + ), + onSubmitted: (_) => _summarize(), + ), + const SizedBox(height: 12), + Card( + color: scheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.share, color: scheme.onSecondaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Tip: share any page from your browser to Readly to ' + 'summarize it instantly.', + style: TextStyle(color: scheme.onSecondaryContainer), + ), + ), + ], + ), + ), + ), + if (articles.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.auto_stories, + title: 'No summaries yet', + message: + 'Summaries of the articles you share will pile up here.', + ), + ) + else ...[ + const SectionHeader('History'), + Card( + child: Column( + children: [ + for (final article in articles) + Dismissible( + key: ValueKey('article-${article.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: scheme.errorContainer, + child: const Icon(Icons.delete_outline), + ), + onDismissed: (_) => + ref.read(databaseProvider).deleteArticle(article.id), + child: ListTile( + leading: const Icon(Icons.article_outlined), + title: Text( + article.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + Uri.tryParse(article.url)?.host ?? article.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => context.push( + Uri( + path: '/read/summary', + queryParameters: { + 'url': article.url, + 'articleId': article.id.toString(), + }, + ).toString(), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/reader/summary_page.dart b/lib/features/reader/summary_page.dart new file mode 100644 index 0000000..275dfbf --- /dev/null +++ b/lib/features/reader/summary_page.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../data/db/database.dart'; +import '../../data/services/anthropic_service.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; + +enum _Status { extracting, streaming, done, error } + +/// Shows a saved summary (when [savedArticleId] is set) or runs the +/// extract → stream-summary pipeline for [url]. +class SummaryPage extends ConsumerStatefulWidget { + const SummaryPage({super.key, required this.url, this.savedArticleId}); + + final String url; + final int? savedArticleId; + + @override + ConsumerState createState() => _SummaryPageState(); +} + +class _SummaryPageState extends ConsumerState { + _Status _status = _Status.extracting; + String _title = ''; + String _summary = ''; + String _error = ''; + bool _needsApiKey = false; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + if (widget.savedArticleId != null) { + unawaited(_loadSaved()); + } else { + unawaited(_run()); + } + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); + } + + Future _loadSaved() async { + final article = await ref + .read(databaseProvider) + .articleById(widget.savedArticleId!); + if (!mounted) return; + setState(() { + if (article == null) { + _status = _Status.error; + _error = 'This summary was deleted.'; + } else { + _title = article.title; + _summary = article.summary; + _status = _Status.done; + } + }); + } + + Future _run() async { + setState(() { + _status = _Status.extracting; + _summary = ''; + _error = ''; + _needsApiKey = false; + }); + + final anthropic = await ref.read(anthropicServiceProvider.future); + if (!mounted) return; + if (anthropic == null) { + setState(() { + _status = _Status.error; + _needsApiKey = true; + _error = + 'Add your Anthropic API key in settings to summarize articles.'; + }); + return; + } + + try { + final settings = await ref.read(settingsProvider.future); + final article = await ref + .read(articleExtractorProvider) + .extract(widget.url); + if (!mounted) return; + if (article.text.trim().isEmpty) { + throw Exception('No readable text found on this page.'); + } + setState(() { + _title = article.title; + _status = _Status.streaming; + }); + + _subscription = anthropic + .streamArticleSummary( + title: article.title, + text: article.text, + language: settings.language, + ) + .listen( + (delta) => setState(() => _summary += delta), + onError: (Object e) => setState(() { + _status = _Status.error; + _error = e is AnthropicException ? e.message : '$e'; + }), + onDone: () async { + if (!mounted) return; + setState(() => _status = _Status.done); + if (_summary.trim().isNotEmpty) { + await ref + .read(databaseProvider) + .saveArticle( + ArticlesCompanion.insert( + url: widget.url, + title: _title, + summary: _summary, + ), + ); + } + }, + ); + } catch (e) { + if (!mounted) return; + setState(() { + _status = _Status.error; + _error = e is AnthropicException ? e.message : '$e'; + }); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar( + title: const Text('Summary'), + actions: [ + IconButton( + tooltip: 'Open original', + icon: const Icon(Icons.open_in_browser), + onPressed: () => launchUrl( + Uri.parse(widget.url), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + body: switch (_status) { + _Status.extracting => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Reading the page…'), + ], + ), + ), + _Status.error => EmptyState( + icon: _needsApiKey ? Icons.key_off : Icons.error_outline, + title: 'Could not summarize', + message: _error, + action: _needsApiKey + ? FilledButton.icon( + icon: const Icon(Icons.settings), + label: const Text('Open settings'), + onPressed: () => context.push('/settings'), + ) + : FilledButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + onPressed: _run, + ), + ), + _Status.streaming || _Status.done => ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + children: [ + Text( + _title, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + Uri.tryParse(widget.url)?.host ?? widget.url, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + SelectableText( + _summary, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(height: 1.5), + ), + if (_status == _Status.streaming) + const Padding( + padding: EdgeInsets.only(top: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ], + ), + }, + ); + } +} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart new file mode 100644 index 0000000..8d4b721 --- /dev/null +++ b/lib/features/settings/settings_page.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../providers.dart'; +import '../../widgets/common.dart'; + +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key}); + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + final _apiKeyController = TextEditingController(); + final _goalController = TextEditingController(); + bool _obscureKey = true; + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider).value; + if (settings != null) { + _goalController.text = settings.dailyKcalGoal.toString(); + } + } + + @override + void dispose() { + _apiKeyController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _saveApiKey() async { + final key = _apiKeyController.text.trim(); + if (key.isEmpty) return; + await ref.read(settingsProvider.notifier).setApiKey(key); + _apiKeyController.clear(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('API key saved.'))); + } + } + + Future _saveGoal() async { + final goal = int.tryParse(_goalController.text.trim()); + if (goal == null || goal <= 0) return; + await ref.read(settingsProvider.notifier).setDailyKcalGoal(goal); + if (mounted) FocusScope.of(context).unfocus(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(settingsProvider).value; + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + const SectionHeader('AI (Anthropic)'), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + settings?.hasApiKey ?? false + ? Icons.check_circle + : Icons.key_off, + color: settings?.hasApiKey ?? false + ? scheme.primary + : scheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + settings?.hasApiKey ?? false + ? 'API key configured' + : 'No API key yet — AI features are disabled', + ), + ), + if (settings?.hasApiKey ?? false) + TextButton( + onPressed: () => ref + .read(settingsProvider.notifier) + .setApiKey(null), + child: const Text('Remove'), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _apiKeyController, + obscureText: _obscureKey, + decoration: InputDecoration( + labelText: 'Anthropic API key', + hintText: 'sk-ant-…', + suffixIcon: IconButton( + icon: Icon( + _obscureKey ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => + setState(() => _obscureKey = !_obscureKey), + ), + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _saveApiKey, + child: const Text('Save key'), + ), + TextButton.icon( + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Get a key at console.anthropic.com'), + onPressed: () => launchUrl( + Uri.parse('https://console.anthropic.com/settings/keys'), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ), + ), + const SectionHeader('Preferences'), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownMenu( + initialSelection: settings?.language ?? 'english', + label: const Text('AI answers language'), + expandedInsets: EdgeInsets.zero, + dropdownMenuEntries: const [ + DropdownMenuEntry(value: 'english', label: 'English'), + DropdownMenuEntry(value: 'french', label: 'Français'), + ], + onSelected: (value) { + if (value != null) { + ref.read(settingsProvider.notifier).setLanguage(value); + } + }, + ), + const SizedBox(height: 16), + TextField( + controller: _goalController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Daily kcal goal', + ), + onSubmitted: (_) => _saveGoal(), + onTapOutside: (_) => _saveGoal(), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Center( + child: Text( + 'Readly 3.0 — everything stays on your device.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/track/track_page.dart b/lib/features/track/track_page.dart new file mode 100644 index 0000000..b5ad5b9 --- /dev/null +++ b/lib/features/track/track_page.dart @@ -0,0 +1,453 @@ +import 'dart:math' as math; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/db/database.dart'; +import '../../data/meal_type.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; +import '../../widgets/log_portion_sheet.dart'; + +class TrackPage extends ConsumerWidget { + const TrackPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entries = ref.watch(todayEntriesProvider).value ?? []; + final goal = ref.watch(settingsProvider).value?.dailyKcalGoal ?? 2200; + final consumed = entries.fold(0, (sum, e) => sum + e.kcal); + + return Scaffold( + appBar: AppBar( + title: const Text('Today'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('Log food'), + onPressed: () => _showLogMenu(context, ref), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), + children: [ + _KcalHeader(consumed: consumed, goal: goal), + if (entries.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.restaurant, + title: 'Nothing logged yet', + message: + 'Scan a barcode, pick something from your kitchen or ' + 'quick-add whatever you just ate.', + ), + ) + else + for (final type in MealType.values) + ..._mealSection(context, ref, type, entries), + ], + ), + ); + } + + List _mealSection( + BuildContext context, + WidgetRef ref, + MealType type, + List all, + ) { + final entries = all.where((e) => e.mealType == type.value).toList(); + if (entries.isEmpty) return const []; + final subtotal = entries.fold(0, (sum, e) => sum + e.kcal); + return [ + SectionHeader( + type.label, + trailing: Text( + '${subtotal.round()} kcal', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Card( + child: Column( + children: [ + for (final entry in entries) + Dismissible( + key: ValueKey('entry-${entry.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: Theme.of(context).colorScheme.errorContainer, + child: const Icon(Icons.delete_outline), + ), + onDismissed: (_) => + ref.read(databaseProvider).deleteConsumption(entry.id), + child: ListTile( + leading: Icon(type.icon), + title: Text(entry.name), + subtitle: entry.grams == null + ? null + : Text('${entry.grams!.round()} g'), + trailing: Text( + '${entry.kcal.round()} kcal', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + ]; + } + + void _showLogMenu(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('Scan a barcode'), + subtitle: const Text('Look up nutrition on Open Food Facts'), + onTap: () { + Navigator.pop(sheetContext); + _scanAndLog(context, ref); + }, + ), + ListTile( + leading: const Icon(Icons.kitchen), + title: const Text('From my kitchen'), + subtitle: const Text('Log something from your pantry'), + onTap: () { + Navigator.pop(sheetContext); + _logFromPantry(context, ref); + }, + ), + ListTile( + leading: const Icon(Icons.bolt), + title: const Text('Quick add'), + subtitle: const Text('Just a name and kcal'), + onTap: () { + Navigator.pop(sheetContext); + _quickAdd(context, ref); + }, + ), + ], + ), + ), + ); + } + + Future _scanAndLog(BuildContext context, WidgetRef ref) async { + final code = await context.push('/scan'); + if (code == null || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Looking up product…')), + ); + try { + final product = await ref.read(offServiceProvider).fetchProduct(code); + messenger.hideCurrentSnackBar(); + if (!context.mounted) return; + if (product == null) { + messenger.showSnackBar( + const SnackBar( + content: Text('Product not found — use quick add instead.'), + ), + ); + await _quickAdd(context, ref); + return; + } + final result = await showLogPortionSheet( + context, + foodName: product.name, + kcalPer100g: product.kcalPer100g, + defaultGrams: product.servingGrams ?? 100, + ); + if (result == null) return; + await ref + .read(databaseProvider) + .logConsumption( + ConsumptionEntriesCompanion.insert( + name: product.name, + kcal: result.kcal, + mealType: result.mealType.value, + grams: Value(result.grams), + ), + ); + } catch (e) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar(content: Text('Lookup failed: $e'))); + } + } + + Future _logFromPantry(BuildContext context, WidgetRef ref) async { + final pantry = await ref.read(pantryProvider.future); + if (!context.mounted) return; + if (pantry.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Your kitchen is empty — scan items in the Kitchen tab first.', + ), + ), + ); + return; + } + final item = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final item in pantry) + ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(item.name), + subtitle: item.kcalPer100g == null + ? null + : Text('${item.kcalPer100g!.round()} kcal / 100 g'), + onTap: () => Navigator.pop(sheetContext, item), + ), + ], + ), + ), + ); + if (item == null || !context.mounted) return; + final result = await showLogPortionSheet( + context, + foodName: item.name, + kcalPer100g: item.kcalPer100g, + ); + if (result == null) return; + await ref + .read(databaseProvider) + .logConsumption( + ConsumptionEntriesCompanion.insert( + name: item.name, + kcal: result.kcal, + mealType: result.mealType.value, + pantryItemId: Value(item.id), + grams: Value(result.grams), + ), + ); + } + + Future _quickAdd(BuildContext context, WidgetRef ref) async { + final result = await showModalBottomSheet<(String, double, MealType)>( + context: context, + isScrollControlled: true, + builder: (context) => const _QuickAddSheet(), + ); + if (result == null) return; + await ref + .read(databaseProvider) + .logConsumption( + ConsumptionEntriesCompanion.insert( + name: result.$1, + kcal: result.$2, + mealType: result.$3.value, + ), + ); + } +} + +class _KcalHeader extends StatelessWidget { + const _KcalHeader({required this.consumed, required this.goal}); + + final double consumed; + final int goal; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final remaining = goal - consumed; + final over = remaining < 0; + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + SizedBox( + width: 120, + height: 120, + child: CustomPaint( + painter: _RingPainter( + progress: goal == 0 ? 0 : consumed / goal, + background: scheme.surfaceContainerHighest, + foreground: over ? scheme.error : scheme.primary, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + consumed.round().toString(), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + 'kcal', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + over + ? '${remaining.abs().round()} kcal over' + : '${remaining.round()} kcal left', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Goal: $goal kcal', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({ + required this.progress, + required this.background, + required this.foreground, + }); + + final double progress; + final Color background; + final Color foreground; + + @override + void paint(Canvas canvas, Size size) { + const stroke = 12.0; + final rect = Offset.zero & size; + final arcRect = rect.deflate(stroke / 2); + final backgroundPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = background + ..strokeCap = StrokeCap.round; + final foregroundPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = foreground + ..strokeCap = StrokeCap.round; + + canvas.drawArc(arcRect, 0, 2 * math.pi, false, backgroundPaint); + canvas.drawArc( + arcRect, + -math.pi / 2, + 2 * math.pi * progress.clamp(0.0, 1.0), + false, + foregroundPaint, + ); + } + + @override + bool shouldRepaint(_RingPainter oldDelegate) => + oldDelegate.progress != progress || + oldDelegate.foreground != foreground || + oldDelegate.background != background; +} + +class _QuickAddSheet extends StatefulWidget { + const _QuickAddSheet(); + + @override + State<_QuickAddSheet> createState() => _QuickAddSheetState(); +} + +class _QuickAddSheetState extends State<_QuickAddSheet> { + final _nameController = TextEditingController(); + final _kcalController = TextEditingController(); + MealType _mealType = MealType.suggestedNow(); + + @override + void dispose() { + _nameController.dispose(); + _kcalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Quick add', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 20), + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'What did you eat?'), + ), + const SizedBox(height: 12), + TextField( + controller: _kcalController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'kcal'), + ), + const SizedBox(height: 20), + SegmentedButton( + segments: [ + for (final type in MealType.values) + ButtonSegment(value: type, icon: Icon(type.icon, size: 18)), + ], + selected: {_mealType}, + onSelectionChanged: (selection) => + setState(() => _mealType = selection.first), + ), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.check), + label: const Text('Log it'), + onPressed: () { + final name = _nameController.text.trim(); + final kcal = double.tryParse( + _kcalController.text.replaceAll(',', '.'), + ); + if (name.isEmpty || kcal == null) return; + Navigator.of(context).pop((name, kcal, _mealType)); + }, + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0e17adb..7792c10 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:readly/page/search_page.dart'; -import 'package:readly/page/welcome_page.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app/app.dart'; void main() { - runApp(MaterialApp( - // home: const GenerationPage(), // Back to the roots - home: const SearchPage(), // How to page - theme: ThemeData( - brightness: Brightness.dark, - primaryColor: Colors.lightBlue[800], - fontFamily: 'Montserrat', - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 24.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - displayMedium: TextStyle(fontSize: 18.0, color: Colors.white), - ), - splashColor: Colors.yellow, - ), - )); + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: ReadlyApp())); } - -// TODO : faire un how to par defaut, mettre le hookListener et redirect vers la page de generation si besoin diff --git a/lib/model/article.dart b/lib/model/article.dart deleted file mode 100644 index 4d0b6e9..0000000 --- a/lib/model/article.dart +++ /dev/null @@ -1,15 +0,0 @@ -class Article { - late String url; - String? title; - List? listImagesUrls; - String? content; - late DateTime date; - - Article({ - required this.url, - this.title, - this.listImagesUrls, - this.content, - DateTime? date, - }) : date = date ?? DateTime.now(); -} diff --git a/lib/model/article_controller.dart b/lib/model/article_controller.dart deleted file mode 100644 index 14ac4f8..0000000 --- a/lib/model/article_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -class ArticleController { - final bool isLoading; - final bool isOpenAI; - final String? content; - final String? title; - final String? url; - List? listImagesUrls; - - ArticleController( - this.isLoading, // - this.content, // - this.title, // - this.listImagesUrls, // - this.isOpenAI, // - this.url // - ); -} diff --git a/lib/page/generation_page.dart b/lib/page/generation_page.dart deleted file mode 100644 index bdb331f..0000000 --- a/lib/page/generation_page.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:chat_gpt_sdk/chat_gpt_sdk.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:readly/model/article_controller.dart'; -import 'package:readly/model/article.dart'; -import 'package:readly/page/settings_page.dart'; -import 'package:readly/services/history_service.dart'; -import 'package:readly/view/article_view.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class GenerationPage extends StatefulWidget { - final ArticleController articleController; - - const GenerationPage({super.key, required this.articleController}); - - @override - State createState() => _GenerationPageState(); -} - -class _GenerationPageState extends State { - late OpenAI openAI; - late SharedPreferences _prefs; - late String language = "english"; - - late ArticleController articleController; - - String? _content; - bool _isLoading = false; - - @override - void initState() { - super.initState(); - articleController = widget.articleController; - initGenerator(); - } - - Future initGenerator() async { - const storage = FlutterSecureStorage(); - final apiKey = await storage.read(key: "apiKey"); - language = await storage.read(key: "language") ?? "english"; - - _prefs = await SharedPreferences.getInstance(); - - if (apiKey == null) { - if (!context.mounted) return; - Navigator.push(context, - MaterialPageRoute(builder: (context) => const SettingsPage())); - } else { - openAI = OpenAI.instance.build( - token: apiKey, - baseOption: HttpSetup( - receiveTimeout: const Duration(seconds: 60), - connectTimeout: const Duration(seconds: 60)), - enableLog: true); - - synthetizeArticle(); - } - } - -// Synthetize Article - Future synthetizeArticle() async { - setState(() { - _isLoading = true; - _content = ""; - }); - - if (articleController.content != null) { - final request = ChatCompleteText(messages: [ - Map.of({ - "role": "user", - "content": - 'You are an expert in key information extraction. Analyze the entirety of the content provided on a current affairs topic. Identify and select relevant information from the global website (all the information may not be related to the current article) to create a concise and informative summary. Present the data in a clear and digestible manner, using tables or a condensed format like TL/DR or bullet point, according to the best way to tell important part of the information. Ensure the answer is free of redundancies. The answer must be in $language. Content to analyze: "${articleController.content}"' - }) - ], maxToken: 2000, model: GptTurboChatModel()); - - final res = - await openAI.onChatCompletionSSE(request: request).listen((it) { - debugPrint(it.choices?.last.message?.content); - - if (it.choices != null) { - setState(() { - log("res.choices?.first.message!.content: ${it.choices?.last.message?.content}"); - - _content = - _content.toString() + (it.choices?.last.message?.content ?? ""); - - _isLoading = - it.choices?.last.message?.content != null && _content != "" - ? false - : true; - }); - - var articleToSave = Article( - url: articleController.url.toString(), - title: articleController.title.toString(), - content: _content.toString(), - listImagesUrls: articleController.listImagesUrls, - ); - - // save the article to the history - HistoryService().saveHistory(articleToSave); - } else { - setState(() { - _isLoading = false; - }); - } - }); - - if (res == null) { - if (!context.mounted) return; - setState(() { - _isLoading = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - var controller = ArticleController( - _isLoading, - _content, - articleController.title.toString(), - articleController.listImagesUrls, - false, - articleController.url.toString()); - - return ArticleView(context, controller); - } -} diff --git a/lib/page/history_page.dart b/lib/page/history_page.dart deleted file mode 100644 index c330213..0000000 --- a/lib/page/history_page.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:readly/model/article.dart'; -import 'package:readly/page/view_page.dart'; -import 'package:readly/services/history_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -final GlobalKey navigatorKey = GlobalKey(); - -class HistoryPage extends StatefulWidget { - const HistoryPage({Key? key}) : super(key: key); - - @override - _HistoryPageState createState() => _HistoryPageState(); -} - -class _HistoryPageState extends State { - late SharedPreferences _prefs; - - bool _isLoading = false; - - late List
_articlesList; - - @override - void initState() { - super.initState(); - _checkNewsDictionary(); - } - - Future _checkNewsDictionary() async { - _isLoading = true; - - var histories = await HistoryService().getAllHistory(); - - setState(() { - _articlesList = histories; - _isLoading = false; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('History'), - ), - body: _isLoading - ? const Center( - child: CircularProgressIndicator(), - ) - : Padding( - padding: const EdgeInsets.all(16.0), - - // liste des news dans le dictionnaire - - child: ListView.builder( - itemCount: _articlesList.length, - itemBuilder: (context, index) { - return Card( - child: ListTile( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ViewPage( - synthese: _articlesList[index], - ))); - }, - title: Text(_articlesList[index].title ?? "??"), - subtitle: Text(_articlesList[index].date.toString()), - splashColor: Colors.white38, - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - await HistoryService() - .deleteHistory(_articlesList[index].url); - setState(() { - _checkNewsDictionary(); - }); - }, - ), - ), - ); - }), - ), - ); - } -} diff --git a/lib/page/images_page.dart b/lib/page/images_page.dart deleted file mode 100644 index 3df520b..0000000 --- a/lib/page/images_page.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:animated_image_list/AnimatedImageList.dart'; -import 'package:flutter/material.dart'; - -class ImagesPage extends StatefulWidget { - final List? listImages; - - const ImagesPage({super.key, this.listImages}); - - @override - _ImagesPageState createState() => _ImagesPageState(); -} - -class _ImagesPageState extends State { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Center( - child: AnimatedImageList( - images: widget.listImages ?? [], - builder: (context, index, progress) { - return Positioned.directional( - textDirection: TextDirection.ltr, - bottom: 15, - start: 25, - child: const Opacity( - opacity: 0, - child: Text(''), - )); - }, - scrollDirection: Axis.vertical, - itemExtent: 100, - maxExtent: 400, - ), - ); - } -} diff --git a/lib/page/search_page.dart b/lib/page/search_page.dart deleted file mode 100644 index f5daebc..0000000 --- a/lib/page/search_page.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:readly/page/settings_page.dart'; -import 'package:readly/page/simplify_page.dart'; -import 'package:readly/page/welcome_page.dart'; -import 'package:readly/services/hook_service.dart'; -import 'package:share_handler/share_handler.dart'; - -// The WelcomePage is the first page of the app. it have the "how to" information and have a button to go to the settings page -class SearchPage extends StatefulWidget { - const SearchPage({Key? key}) : super(key: key); - - @override - State createState() => _SearchPageState(); -} - -class _SearchPageState extends State { - TextEditingController searchController = TextEditingController(); - - @override - void initState() { - super.initState(); - pageRedirect(); - welcomeVerification(); - } - - void pageRedirect() async { - await HookService.getInitialSharedMedia(context); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Readly"), - actions: [ - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage())); - }, - icon: const Icon(Icons.settings)), - ], - ), - body: Column(children: [ - Expanded( - child: ListView(children: [ - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column(children: [ - const Text( - "Read a web page and get a summary!", - style: TextStyle( - fontSize: 26.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - // search bar looking like google search bar - - TextField( - controller: searchController, - decoration: const InputDecoration( - hintText: "Enter the url of the article to summarize", - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10.0)), - ), - ), - ), - const SizedBox(height: 30), - - const Text( - "It will extract the main information and images from the article for you !", - style: TextStyle( - fontSize: 18.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - - // search button looking really good and nice - ElevatedButton( - onPressed: () { - var shared = new SharedMedia(); - shared.content = searchController.text; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SimplifyPage( - sharedmedia: shared, - ))); - }, - child: const Text("Search"), - ), - ])) - ])) - ])); - } - - Future welcomeVerification() async { - // check secure storage for the welcome page - // if it's the first time, show the welcome page - // if it's not the first time, show the search page - // if the user has already seen the welcome page, show the search page - - var storage = new FlutterSecureStorage(); - var welcome = await storage.read(key: "welcome"); - - if (welcome == null) { - Navigator.pushReplacement(context, - MaterialPageRoute(builder: (context) => const WelcomePage())); - } - } -} diff --git a/lib/page/settings_page.dart b/lib/page/settings_page.dart deleted file mode 100644 index e711c6f..0000000 --- a/lib/page/settings_page.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class SettingsPage extends StatefulWidget { - const SettingsPage({Key? key}) : super(key: key); - - @override - _SettingsPageState createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - @override - void dispose() { - _apiKeyController.dispose(); - _languageController.dispose(); - super.dispose(); - } - - final TextEditingController _apiKeyController = TextEditingController(); - final TextEditingController _languageController = TextEditingController(); - final FlutterSecureStorage storage = const FlutterSecureStorage(); - bool _isLoading = false; - - @override - void initState() { - super.initState(); - getSettings(); - } - - Future getSettings() async { - final apiKey = await storage.read(key: "apiKey"); - if (apiKey != null) { - _apiKeyController.text = apiKey; - } - final language = await storage.read(key: "language"); - if (language != null) { - _languageController.text = language; - } - } - - Future saveSettings() async { - setState(() { - _isLoading = true; - }); - - final apiKey = _apiKeyController.text.trim(); - if (apiKey.isNotEmpty) { - // Write value - await storage.write(key: "apiKey", value: apiKey); - } - - final language = _languageController.text.trim(); - if (language.isNotEmpty) { - // Write value - await storage.write(key: "language", value: language); - } - - setState(() { - _isLoading = false; - }); - if (apiKey.isNotEmpty) { - if (!context.mounted) return; - Navigator.of(context).pop(); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - ), - body: _isLoading - ? const Center( - child: CircularProgressIndicator(), - ) - : Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Enter your API key:', - style: TextStyle(fontSize: 18.0), - ), - const SizedBox(height: 10.0), - TextField( - controller: _apiKeyController, - decoration: const InputDecoration( - hintText: 'API key', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 20.0), - const Text( - 'Enter your language (ex : french)', - style: TextStyle(fontSize: 18.0), - ), - const SizedBox(height: 10.0), - TextField( - controller: _languageController, - decoration: const InputDecoration( - hintText: 'language', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 20.0), - ElevatedButton( - onPressed: () async { - await saveSettings(); - }, - child: const Text('Save'), - ), - const SizedBox(height: 20.0), - const Text( - 'You can get your API key from the following link:', - style: TextStyle(fontSize: 18.0), - ), - const SizedBox(height: 10.0), - GestureDetector( - onTap: () => launchUrl(Uri.parse( - "https://platform.openai.com/account/api-keys")), - child: const Text( - 'https://platform.openai.com/account/api-keys', - style: TextStyle( - fontSize: 18.0, - color: Colors.blue, - decoration: TextDecoration.underline, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/page/simplify_page.dart b/lib/page/simplify_page.dart deleted file mode 100644 index 682b665..0000000 --- a/lib/page/simplify_page.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:html/dom.dart' as dom; -import 'package:html/parser.dart' as html; -import 'package:http/http.dart' as http; -import 'package:readly/model/article_controller.dart'; -import 'package:readly/model/article.dart'; -import 'package:readly/services/fetch_service.dart'; -import 'package:readly/services/history_service.dart'; -import 'package:readly/view/article_view.dart'; -import 'package:share_handler_platform_interface/share_handler_platform_interface.dart'; - -class SimplifyPage extends StatefulWidget { - final SharedMedia sharedmedia; - - const SimplifyPage({Key? key, required this.sharedmedia}) : super(key: key); - - @override - State createState() => _SimplifyPageState(); -} - -class _SimplifyPageState extends State { - SharedMedia? shared; - String? domContent; - String? title; - String? textContent; - String? _synthese; - bool _isLoading = false; - - bool _isOpenAI = false; - final List _listImages = []; - - @override - void initState() { - super.initState(); - shared = widget.sharedmedia; - parseArticle(); - } - - // parse Arcticle - Future parseArticle() async { - const storage = FlutterSecureStorage(); - var isOpenAI = await storage.read(key: "apiKey") != null ? true : false; - - setState(() { - _isLoading = true; - _synthese = ""; - _isOpenAI = isOpenAI; - }); - - // Fetch the content from the URL - final response = await http.get( - Uri.parse("https://readly.lightin.io/api/read?url=${shared!.content}"), - headers: {'Content-Type': 'application/json;'}, - ); - if (response.statusCode == 200) { - setState(() { - final parsedResponse = jsonDecode(response.body); - - textContent = parsedResponse["textContent"]; - domContent = parsedResponse["content"]; - title = parsedResponse["title"]; - }); - // Fetch the images using service - final dom.Document document = html.parse(domContent); - _listImages.addAll(FetchService.getImageList(document)); - - // save the article to the history - HistoryService().saveHistory(Article( - url: shared!.content ?? "", - title: title!, - content: textContent!, - listImagesUrls: _listImages, - )); - - setState(() { - _isLoading = false; - log("CHANGE STATE"); - _synthese = textContent ?? ""; - }); - } - } - - @override - Widget build(BuildContext context) { - var controller = ArticleController( - _isLoading, _synthese, title, _listImages, _isOpenAI, shared!.content); - - return ArticleView(context, controller); - } -} diff --git a/lib/page/view_page.dart b/lib/page/view_page.dart deleted file mode 100644 index 58d23db..0000000 --- a/lib/page/view_page.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:readly/model/article_controller.dart'; -import 'package:readly/view/article_view.dart'; - -import '../model/article.dart'; - -class ViewPage extends StatefulWidget { - final Article synthese; - - const ViewPage({Key? key, required this.synthese}) : super(key: key); - - @override - _ViewPageState createState() => _ViewPageState(); -} - -class _ViewPageState extends State { - Article get synthese => widget.synthese; - - TextEditingController _apiKeyController = TextEditingController(); - - bool _isLoading = false; - - @override - void initState() { - super.initState(); - // _checkApiKey(); - } - - @override - void dispose() { - _apiKeyController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - var controller = ArticleController( - false, - widget.synthese.content, - widget.synthese.title, - widget.synthese.listImagesUrls, - false, - widget.synthese.url); - - return ArticleView(context, controller); - } -} diff --git a/lib/page/welcome_page.dart b/lib/page/welcome_page.dart deleted file mode 100644 index b855091..0000000 --- a/lib/page/welcome_page.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:readly/page/settings_page.dart'; -import 'package:readly/services/hook_service.dart'; - -// The WelcomePage is the first page of the app. it have the "how to" information and have a button to go to the settings page -class WelcomePage extends StatefulWidget { - const WelcomePage({Key? key}) : super(key: key); - - @override - State createState() => _WelcomePageState(); -} - -class _WelcomePageState extends State { - @override - void initState() { - super.initState(); - saveWelcomeCheck(); - pageRedirect(); - } - - void saveWelcomeCheck() async { - // secure storage - var storage = FlutterSecureStorage(); - await storage.write(key: "welcome", value: "true"); - } - - void pageRedirect() async { - await HookService.getInitialSharedMedia(context); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Readly"), - actions: [ - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage())); - }, - icon: const Icon(Icons.settings)), - ], - ), - body: Column(children: [ - Expanded( - child: ListView(children: [ - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column(children: [ - const Text( - "Welcome on Readly !", - style: TextStyle( - fontSize: 26.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - const Text( - "Readly is an application that generates text summaries from press articles.", - style: TextStyle( - fontSize: 18.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - const Text( - "All you have to do is share the url of an article with the application", - style: TextStyle( - fontSize: 18.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - - // TODO : Ajouter une image pour montrer comment partager un article - // l'image doit etre rounded - - const SizedBox(height: 30), - ClipRRect( - borderRadius: BorderRadius.circular(18.0), - child: Image( - image: AssetImage('assets/howto.png'), - height: 400, - )), - const SizedBox(height: 30), - const Text( - "You can also copy the url and paste it in the search bar", - style: TextStyle( - fontSize: 18.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - const Text( - "To fully enjoy the power of the application, enter your OpenIA api key.\n Click on the button below to access the settings.", - style: TextStyle( - fontSize: 18.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage())); - }, - child: const Text("Settings"), - ) - ])) - ])) - ])); - } -} diff --git a/lib/providers.dart b/lib/providers.dart new file mode 100644 index 0000000..44d545c --- /dev/null +++ b/lib/providers.dart @@ -0,0 +1,138 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'data/db/database.dart'; +import 'data/services/anthropic_service.dart'; +import 'data/services/article_extractor.dart'; +import 'data/services/off_service.dart'; +import 'data/services/settings_service.dart'; + +// ---- Infrastructure ---- + +final databaseProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +}); + +final settingsServiceProvider = Provider( + (ref) => SettingsService(), +); + +final offServiceProvider = Provider( + (ref) => OpenFoodFactsService(), +); + +final articleExtractorProvider = Provider( + (ref) => ArticleExtractor(), +); + +// ---- Settings state ---- + +class SettingsNotifier extends AsyncNotifier { + @override + Future build() => ref.read(settingsServiceProvider).load(); + + Future setApiKey(String? key) async { + await ref.read(settingsServiceProvider).setApiKey(key); + ref.invalidate(anthropicServiceProvider); + state = await AsyncValue.guard( + () => ref.read(settingsServiceProvider).load(), + ); + } + + Future setLanguage(String language) async { + await ref.read(settingsServiceProvider).setLanguage(language); + state = AsyncData(state.requireValue.copyWith(language: language)); + } + + Future setDailyKcalGoal(int goal) async { + await ref.read(settingsServiceProvider).setDailyKcalGoal(goal); + state = AsyncData(state.requireValue.copyWith(dailyKcalGoal: goal)); + } +} + +final settingsProvider = AsyncNotifierProvider( + SettingsNotifier.new, +); + +/// The Anthropic client, or null while no API key is configured. +final anthropicServiceProvider = FutureProvider((ref) async { + // Re-created whenever settings change (invalidated on key updates). + ref.watch(settingsProvider); + final key = await ref.read(settingsServiceProvider).getApiKey(); + if (key == null || key.isEmpty) return null; + return AnthropicService(apiKey: key); +}); + +// ---- Database streams ---- + +final pantryProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchPantry(), +); + +final todayEntriesProvider = StreamProvider>((ref) { + final now = DateTime.now(); + final start = DateTime(now.year, now.month, now.day); + final end = start.add(const Duration(days: 1)); + return ref.watch(databaseProvider).watchEntriesBetween(start, end); +}); + +final shoppingProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchShopping(), +); + +final articlesProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchArticles(), +); + +// ---- AI results kept in memory ---- + +class MealSuggestionsNotifier + extends Notifier>?> { + @override + AsyncValue>? build() => null; + + Future generate() async { + final anthropic = await ref.read(anthropicServiceProvider.future); + if (anthropic == null) { + state = AsyncValue.error( + AnthropicException('Add your Anthropic API key in settings first.'), + StackTrace.current, + ); + return; + } + state = const AsyncValue.loading(); + + final settings = await ref.read(settingsProvider.future); + final pantry = await ref.read(databaseProvider).watchPantry().first; + final entries = await ref.read(todayEntriesProvider.future); + final eaten = entries.fold(0, (sum, e) => sum + e.kcal); + final remaining = settings.dailyKcalGoal - eaten; + + state = await AsyncValue.guard( + () => anthropic.suggestMeals( + pantry: [ + for (final item in pantry) + { + 'name': item.name, + if (item.brand != null) 'brand': item.brand, + 'amount_left_percent': (item.amountLeft * 100).round(), + if (item.kcalPer100g != null) 'kcal_per_100g': item.kcalPer100g, + if (item.packageQuantity != null) + 'package_size': item.packageQuantity, + }, + ], + remainingKcal: remaining.clamp(300, 4000).toDouble(), + language: settings.language, + ), + ); + } + + void clear() => state = null; +} + +final mealSuggestionsProvider = + NotifierProvider< + MealSuggestionsNotifier, + AsyncValue>? + >(MealSuggestionsNotifier.new); diff --git a/lib/services/fetch_service.dart b/lib/services/fetch_service.dart deleted file mode 100644 index 758126d..0000000 --- a/lib/services/fetch_service.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:developer'; - -import 'package:html/dom.dart'; - -class FetchService { - static getImageList(Document document) { - // Récupérer tous les éléments - final imgElements = document.querySelectorAll('img'); - - var listImages = []; - - // Extraire l'URL de chaque image et les ajouter à la liste - for (final img in imgElements) { - log("img: ${img.attributes['src']}"); - final src = img.attributes['src']; - - if (src != null) { - Uri? uri = Uri.tryParse(src); - if (uri != null && - uri.isAbsolute && - (uri.scheme == 'http' || uri.scheme == 'https')) { - listImages.add(src); - } - } - } - - return listImages; - } -} diff --git a/lib/services/history_service.dart b/lib/services/history_service.dart deleted file mode 100644 index 38b4d0c..0000000 --- a/lib/services/history_service.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:html/dom.dart'; -import 'package:readly/model/article.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class HistoryService { - late SharedPreferences _prefs; - - iniPrefs() async { - _prefs = await SharedPreferences.getInstance(); - } - - saveHistory(Article article) async { - // initialiser les préférences - await iniPrefs(); - - // convert Article to JSON dictionary - final page_dictionary = { - "url": article.url.toString(), - "title": article.title.toString(), - "images": jsonEncode(article.listImagesUrls), - "content": article.content.toString(), - "date": "${DateTime.now()}" - }; - - // get the newsDictionary from the shared preferences - final newsDictionary = - jsonDecode(_prefs.getString("newsDictionary") ?? "{}"); - - // add the new article to the newsDictionary - newsDictionary[article.url.toString()] = page_dictionary; - - // save the newsDictionary to the shared preferences - _prefs.setString("newsDictionary", jsonEncode(newsDictionary)); - } - - Future
getHistory(String url) async { - // initialiser les préférences - await iniPrefs(); - - // get the newsDictionary from the shared preferences - final newsDictionary = - jsonDecode(_prefs.getString("newsDictionary") ?? "{}"); - - // get the article from the newsDictionary - final article = newsDictionary[url]; - - // convert the article to an Article object - return Article( - url: article["url"], - title: article["title"], - content: article["content"], - listImagesUrls: article["images"], - date: article["date"], - ); - } - - Future> getAllHistory() async { - // initialiser les préférences - await iniPrefs(); - - // get the newsDictionary from the shared preferences - final newsDictionary = - jsonDecode(_prefs.getString("newsDictionary") ?? "{}"); - - print(newsDictionary); - - // Convertir toutes les valeurs (chaque article) en objets Article - var articles = newsDictionary.values.map
((articleMap) { - // Assure-toi que articleMap est bien un Map avant de l'utiliser - Map article = Map.from(articleMap); - - return Article( - url: article['url'], - title: article['title'], - content: article['content'], - listImagesUrls: List.from(jsonDecode(article['images'])), - // Convertir en List si nécessaire - date: DateTime.parse(article['date'])); - }).toList(); // Convertir le résultat itérable en List avec toList() - - return articles; - } - - Future deleteHistory(String url) async { - // initialiser les préférences - await iniPrefs(); - - // get the newsDictionary from the shared preferences - final newsDictionary = - jsonDecode(_prefs.getString("newsDictionary") ?? "{}"); - - // remove the article from the newsDictionary - newsDictionary.remove(url); - - // save the newsDictionary to the shared preferences - _prefs.setString("newsDictionary", jsonEncode(newsDictionary)); - } -} diff --git a/lib/services/hook_service.dart b/lib/services/hook_service.dart deleted file mode 100644 index 524df60..0000000 --- a/lib/services/hook_service.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:readly/page/simplify_page.dart'; -import 'package:share_handler_platform_interface/share_handler_platform_interface.dart'; - -class HookService { - static getInitialSharedMedia(BuildContext context) async { - final handler = ShareHandlerPlatform.instance; - - handler.sharedMediaStream - .listen((media) => _handleHookChange(media, context)); - - SharedMedia? shared = await handler.getInitialSharedMedia(); - log("Awaited shared : "); - - if (shared != null) { - RegExp urlPattern = RegExp(r'(http[s]?://\S+)'); - String? extractedUrl = - urlPattern.firstMatch(shared.content ?? '')?.group(0); - - var extractedMedia = SharedMedia(content: extractedUrl); - - if (!context.mounted) return; - redirect(context, extractedMedia, overwrite: true); - } - } - - // Only if a new url is shared - static void _handleHookChange(SharedMedia media, BuildContext context) async { - log("Hook change !!! "); - log(media.content.toString()); - - // Utiliser une expression régulière pour extraire l'URL - RegExp urlPattern = RegExp(r'(http[s]?://\S+)'); - String? extractedUrl = urlPattern.firstMatch(media.content ?? '')?.group(0); - log(extractedUrl.toString()); - - var extractedMedia = SharedMedia(content: extractedUrl); - - if (extractedUrl != null && extractedUrl.startsWith('http')) { - if (!context.mounted) return; - redirect(context, extractedMedia); - } - } - - static void redirect(BuildContext context, SharedMedia media, - {bool overwrite = false}) { - if (overwrite) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => SimplifyPage( - sharedmedia: media, - ))); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SimplifyPage( - sharedmedia: media, - ))); - - // TODO : check if the url is an article - // TODO : check if the url is already in the list - // TODO : add the url to the list - // TODO : redirect to the generation page - } -} diff --git a/lib/view/article_view.dart b/lib/view/article_view.dart deleted file mode 100644 index d118d76..0000000 --- a/lib/view/article_view.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:readly/model/article_controller.dart'; -import 'package:readly/page/generation_page.dart'; -import 'package:readly/page/images_page.dart'; -import 'package:readly/page/history_page.dart'; -import 'package:readly/page/settings_page.dart'; - -Scaffold ArticleView(BuildContext context, ArticleController controller) { - return Scaffold( - appBar: AppBar( - leading: IconButton.outlined( - onPressed: () { - Navigator.push(context, - MaterialPageRoute(builder: (context) => const HistoryPage())); - }, - icon: const Icon(Icons.account_tree_outlined)), - actions: [ - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage())); - }, - icon: const Icon(Icons.settings)), - ], - title: Text(controller.title.toString() == "null" - ? "Readly" - : controller.title.toString()), - ), - body: Column( - children: [ - Expanded( - child: ListView( - children: [ - const SizedBox(height: 10), - controller.isLoading - ? const Center( - child: CircularProgressIndicator(), - ) - : controller.content != null && controller.content != "" - ? Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Text( - controller.title.toString(), - style: const TextStyle( - fontSize: 26.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - const SizedBox(height: 30), - Text( - controller.content!, - style: const TextStyle( - fontSize: 20.0, - fontFamily: 'Montserrat', - color: Colors.white, - ), - ), - ], - ), - ) - : const Text('No data'), - const SizedBox(height: 120), - // ... - ], - ), - ), - ], - ), - floatingActionButton: - Column(mainAxisAlignment: MainAxisAlignment.end, children: [ - // FLOAT ACTION FOR IA GENERATION - controller.isOpenAI - ? FloatingActionButton( - onPressed: () { - // Add your onPressed code here! - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => GenerationPage( - articleController: controller, - ))); - }, - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - splashColor: Colors.black45, - child: const Icon(Icons.spa_outlined), - ) - : const SizedBox(), - const SizedBox( - height: 10, - ), - - // FLOAT ACTION FOR IMAGES PAGE - - controller.listImagesUrls == null || controller.listImagesUrls!.isEmpty - ? const SizedBox() - : FloatingActionButton( - onPressed: () { - // Add your onPressed code here! - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ImagesPage( - listImages: controller.listImagesUrls, - ))); - }, - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - splashColor: Colors.black45, - child: const Icon(Icons.photo_album_outlined), - ), - ]), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat // - ); -} diff --git a/lib/widgets/common.dart b/lib/widgets/common.dart new file mode 100644 index 0000000..a2c89ff --- /dev/null +++ b/lib/widgets/common.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// Centered placeholder for empty lists. +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.message, + this.action, + }); + + final IconData icon; + final String title; + final String message; + final Widget? action; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 72, color: scheme.primary.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + if (action != null) ...[const SizedBox(height: 20), action!], + ], + ), + ), + ); + } +} + +/// Small section title used above list groups. +class SectionHeader extends StatelessWidget { + const SectionHeader(this.title, {super.key, this.trailing}); + + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 20, 4, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + ), + ?trailing, + ], + ), + ); + } +} diff --git a/lib/widgets/log_portion_sheet.dart b/lib/widgets/log_portion_sheet.dart new file mode 100644 index 0000000..7ffdc19 --- /dev/null +++ b/lib/widgets/log_portion_sheet.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../data/meal_type.dart'; + +class LogPortionResult { + const LogPortionResult({ + required this.kcal, + required this.mealType, + this.grams, + }); + + final double kcal; + final MealType mealType; + final double? grams; +} + +/// Bottom sheet asking for a portion size (grams) and meal type for a food +/// with known kcal/100 g. Pops with a [LogPortionResult]. +Future showLogPortionSheet( + BuildContext context, { + required String foodName, + required double? kcalPer100g, + double? defaultGrams, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _LogPortionSheet( + foodName: foodName, + kcalPer100g: kcalPer100g, + defaultGrams: defaultGrams, + ), + ); +} + +class _LogPortionSheet extends StatefulWidget { + const _LogPortionSheet({ + required this.foodName, + required this.kcalPer100g, + this.defaultGrams, + }); + + final String foodName; + final double? kcalPer100g; + final double? defaultGrams; + + @override + State<_LogPortionSheet> createState() => _LogPortionSheetState(); +} + +class _LogPortionSheetState extends State<_LogPortionSheet> { + late final TextEditingController _gramsController; + late final TextEditingController _kcalController; + MealType _mealType = MealType.suggestedNow(); + + @override + void initState() { + super.initState(); + final grams = widget.defaultGrams ?? 100; + _gramsController = TextEditingController(text: _trim(grams)); + _kcalController = TextEditingController( + text: widget.kcalPer100g == null + ? '' + : _trim(widget.kcalPer100g! * grams / 100), + ); + } + + static String _trim(double v) => + v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(1); + + void _recomputeKcal() { + final grams = double.tryParse(_gramsController.text.replaceAll(',', '.')); + if (grams != null && widget.kcalPer100g != null) { + _kcalController.text = _trim(widget.kcalPer100g! * grams / 100); + } + } + + @override + void dispose() { + _gramsController.dispose(); + _kcalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.foodName, style: Theme.of(context).textTheme.titleLarge), + if (widget.kcalPer100g != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${widget.kcalPer100g!.round()} kcal / 100 g', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextField( + controller: _gramsController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration(labelText: 'Portion (g)'), + onChanged: (_) => _recomputeKcal(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _kcalController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration(labelText: 'kcal'), + ), + ), + ], + ), + const SizedBox(height: 20), + SegmentedButton( + segments: [ + for (final type in MealType.values) + ButtonSegment(value: type, icon: Icon(type.icon, size: 18)), + ], + selected: {_mealType}, + onSelectionChanged: (selection) => + setState(() => _mealType = selection.first), + ), + const SizedBox(height: 8), + Center( + child: Text( + _mealType.label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.check), + label: const Text('Log it'), + onPressed: () { + final kcal = double.tryParse( + _kcalController.text.replaceAll(',', '.'), + ); + if (kcal == null) return; + Navigator.of(context).pop( + LogPortionResult( + kcal: kcal, + mealType: _mealType, + grams: double.tryParse( + _gramsController.text.replaceAll(',', '.'), + ), + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/scanner_page.dart b/lib/widgets/scanner_page.dart new file mode 100644 index 0000000..9fef913 --- /dev/null +++ b/lib/widgets/scanner_page.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Full screen barcode scanner. Pops with the scanned code as a [String]. +class ScannerPage extends StatefulWidget { + const ScannerPage({super.key}); + + @override + State createState() => _ScannerPageState(); +} + +class _ScannerPageState extends State { + final _controller = MobileScannerController( + formats: [BarcodeFormat.ean13, BarcodeFormat.ean8, BarcodeFormat.upcA], + ); + bool _handled = false; + + void _onDetect(BarcodeCapture capture) { + if (_handled) return; + final code = capture.barcodes + .map((b) => b.rawValue) + .whereType() + .firstOrNull; + if (code == null) return; + _handled = true; + context.pop(code); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + title: const Text('Scan a barcode'), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + actions: [ + IconButton( + icon: const Icon(Icons.flashlight_on), + onPressed: _controller.toggleTorch, + ), + ], + ), + extendBodyBehindAppBar: true, + body: Stack( + alignment: Alignment.center, + children: [ + MobileScanner(controller: _controller, onDetect: _onDetect), + IgnorePointer( + child: Container( + width: 280, + height: 170, + decoration: BoxDecoration( + border: Border.all(color: Colors.white70, width: 3), + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 351590b..7b1c8a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,106 +1,53 @@ name: readly -description: "Simplify web pages" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +description: "Personal kcal tracker, meal maker & article summarizer" +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 2.2.0+4 +version: 3.0.0+5 environment: - sdk: '>=3.3.4 <4.0.0' + sdk: ^3.12.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - share_handler: 0.0.25 - http: 1.6.0 - html: 0.15.6 - chat_gpt_sdk: 3.0.8 - url_launcher: 6.3.1 - shared_preferences: 2.3.3 - cupertino_icons: 1.0.8 - flutter_secure_storage: 9.2.4 - animated_image_list: 1.0.0 + # State & navigation + flutter_riverpod: ^3.3.2 + go_router: ^17.3.0 -dev_dependencies: - flutter_test: - sdk: flutter - flutter_launcher_icons: 0.14.4 + # Local database + drift: ^2.34.1 + drift_flutter: ^0.3.0 + + # Scanning & food data + mobile_scanner: ^7.2.0 + http: ^1.6.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: 4.0.0 + # Article synthesis (legacy feature) + share_handler: ^0.0.25 + html: ^0.15.6 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec + # Storage & misc + flutter_secure_storage: ^10.3.1 + shared_preferences: ^2.5.5 + url_launcher: ^6.3.2 + google_fonts: ^8.1.0 + cupertino_icons: ^1.0.9 +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + drift_dev: ^2.34.0 + build_runner: ^2.4.15 + flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: android: "launcher_icon" ios: true image_path: "assets/logo.png" - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - assets: - assets/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..d8d13bb --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,79 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/app/app.dart'; +import 'package:readly/data/db/database.dart'; +import 'package:readly/data/services/settings_service.dart'; +import 'package:readly/providers.dart'; + +class _FakeSettingsService extends SettingsService { + String? apiKey; + String language = 'english'; + int goal = 2000; + + @override + Future getApiKey() async => apiKey; + + @override + Future setApiKey(String? key) async => apiKey = key; + + @override + Future load() async => AppSettings( + hasApiKey: apiKey != null, + language: language, + dailyKcalGoal: goal, + ); + + @override + Future setLanguage(String value) async => language = value; + + @override + Future setDailyKcalGoal(int value) async => goal = value; +} + +void main() { + testWidgets('app boots and all five tabs navigate', (tester) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + settingsServiceProvider.overrideWithValue(_FakeSettingsService()), + ], + child: const ReadlyApp(), + ), + ); + await tester.pumpAndSettle(); + + // Track tab is home. + expect(find.text('Today'), findsOneWidget); + expect(find.text('Log food'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.kitchen_outlined)); + await tester.pumpAndSettle(); + expect(find.text('Your kitchen is empty'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.restaurant_menu_outlined)); + await tester.pumpAndSettle(); + expect(find.text('AI is not set up yet'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.shopping_basket_outlined)); + await tester.pumpAndSettle(); + expect(find.text('Nothing to buy'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.auto_stories_outlined)); + await tester.pumpAndSettle(); + expect(find.text('No summaries yet'), findsOneWidget); + }); + + test('extractUrl pulls the first link out of shared text', () { + expect( + ReadlyApp.extractUrl('Check this https://example.com/a?x=1 out'), + 'https://example.com/a?x=1', + ); + expect(ReadlyApp.extractUrl('no link here'), isNull); + }); +} diff --git a/test/data/database_test.dart b/test/data/database_test.dart new file mode 100644 index 0000000..93338e0 --- /dev/null +++ b/test/data/database_test.dart @@ -0,0 +1,93 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/db/database.dart'; + +void main() { + late AppDatabase db; + + setUp(() => db = AppDatabase.forTesting(NativeDatabase.memory())); + tearDown(() => db.close()); + + test('pantry: add, update amount left, refill by barcode, delete', () async { + final id = await db.addPantryItem( + PantryItemsCompanion.insert( + name: 'Pasta', + barcode: const Value('123'), + kcalPer100g: const Value(360), + ), + ); + + var pantry = await db.watchPantry().first; + expect(pantry.single.amountLeft, 1.0); + + await db.updatePantryItem( + id, + const PantryItemsCompanion(amountLeft: Value(0.25)), + ); + pantry = await db.watchPantry().first; + expect(pantry.single.amountLeft, 0.25); + + final byBarcode = await db.pantryItemByBarcode('123'); + expect(byBarcode?.name, 'Pasta'); + expect(await db.pantryItemByBarcode('999'), isNull); + + await db.deletePantryItem(id); + expect(await db.watchPantry().first, isEmpty); + }); + + test('consumption: only entries inside the window are returned', () async { + final today = DateTime(2026, 7, 8, 12); + final start = DateTime(2026, 7, 8); + final end = start.add(const Duration(days: 1)); + + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: 'Breakfast bowl', + kcal: 420, + mealType: 'breakfast', + loggedAt: Value(today), + ), + ); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: 'Yesterday pizza', + kcal: 900, + mealType: 'dinner', + loggedAt: Value(today.subtract(const Duration(days: 1))), + ), + ); + + final entries = await db.watchEntriesBetween(start, end).first; + expect(entries.map((e) => e.name), ['Breakfast bowl']); + }); + + test('shopping: done items sort last and can be cleared', () async { + final milkId = await db.addShoppingItem( + ShoppingItemsCompanion.insert(name: 'Milk'), + ); + await db.addShoppingItem(ShoppingItemsCompanion.insert(name: 'Eggs')); + + await db.setShoppingDone(milkId, true); + var items = await db.watchShopping().first; + expect(items.first.name, 'Eggs'); + expect(items.last.done, isTrue); + + await db.clearDoneShopping(); + items = await db.watchShopping().first; + expect(items.map((i) => i.name), ['Eggs']); + }); + + test('articles: saved summaries can be fetched back by id', () async { + final id = await db.saveArticle( + ArticlesCompanion.insert( + url: 'https://example.com/a', + title: 'A title', + summary: 'A summary', + ), + ); + final article = await db.articleById(id); + expect(article?.summary, 'A summary'); + expect(await db.articleById(9999), isNull); + }); +} diff --git a/test/services/anthropic_service_test.dart b/test/services/anthropic_service_test.dart new file mode 100644 index 0000000..97c5376 --- /dev/null +++ b/test/services/anthropic_service_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/services/anthropic_service.dart'; + +void main() { + group('parseSseTextDeltas', () { + Stream sse(List lines) => + Stream.fromIterable(lines.map((l) => '$l\n')); + + test('yields text deltas and ignores other events', () async { + final deltas = await AnthropicService.parseSseTextDeltas( + sse([ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_1"}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}', + 'event: message_stop', + 'data: {"type":"message_stop"}', + ]), + ).toList(); + expect(deltas.join(), 'Hello world'); + }); + + test('ignores thinking deltas', () async { + final deltas = await AnthropicService.parseSseTextDeltas( + sse([ + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', + ]), + ).toList(); + expect(deltas, ['ok']); + }); + + test('throws on error events', () { + expect( + AnthropicService.parseSseTextDeltas( + sse([ + 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ]), + ).toList(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Overloaded', + ), + ), + ); + }); + }); + + group('extractStructuredJson', () { + test('parses the JSON text block', () { + final body = jsonEncode({ + 'stop_reason': 'end_turn', + 'content': [ + {'type': 'text', 'text': '{"meals":[]}'}, + ], + }); + expect(AnthropicService.extractStructuredJson(body), { + 'meals': [], + }); + }); + + test('throws a readable error on refusal', () { + final body = jsonEncode({ + 'stop_reason': 'refusal', + 'content': [], + }); + expect( + () => AnthropicService.extractStructuredJson(body), + throwsA(isA()), + ); + }); + }); + + test('MealSuggestion.fromJson maps every field', () { + final meal = MealSuggestion.fromJson({ + 'title': 'Omelette', + 'description': 'Fast protein.', + 'time_minutes': 10, + 'kcal': 350.5, + 'used_ingredients': ['eggs', 'cheese'], + 'missing_ingredients': ['chives'], + 'steps': ['Beat eggs', 'Cook'], + }); + expect(meal.title, 'Omelette'); + expect(meal.timeMinutes, 10); + expect(meal.kcal, 350.5); + expect(meal.usedIngredients, hasLength(2)); + expect(meal.missingIngredients, ['chives']); + expect(meal.steps, hasLength(2)); + }); +} diff --git a/test/services/article_extractor_test.dart b/test/services/article_extractor_test.dart new file mode 100644 index 0000000..3d8fbb1 --- /dev/null +++ b/test/services/article_extractor_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/services/article_extractor.dart'; + +void main() { + test('extracts title and paragraphs, ignoring scripts and navigation', () { + const source = ''' + + Big News Story + + +
+

Big News Story

+

This is the first paragraph of the article, long enough to count as content.

+

Second paragraph with additional details about the important event described.

+
+
Copyright notice and useless links everywhere
+ +'''; + + final article = ArticleExtractor.extractFromHtml( + source, + fallbackTitle: 'https://example.com', + ); + expect(article.title, 'Big News Story'); + expect(article.text, contains('first paragraph')); + expect(article.text, contains('Second paragraph')); + expect(article.text, isNot(contains('evil'))); + expect(article.text, isNot(contains('Copyright'))); + }); + + test('falls back to body text and the given title', () { + const source = 'Just some short raw text'; + final article = ArticleExtractor.extractFromHtml( + source, + fallbackTitle: 'https://example.com/x', + ); + expect(article.title, 'https://example.com/x'); + expect(article.text, 'Just some short raw text'); + }); + + test('caps extremely long articles', () { + final source = '

${'word ' * 30000}

'; + final article = ArticleExtractor.extractFromHtml( + source, + fallbackTitle: 't', + ); + expect(article.text.length, lessThanOrEqualTo(ArticleExtractor.maxChars)); + }); +} diff --git a/test/services/off_service_test.dart b/test/services/off_service_test.dart new file mode 100644 index 0000000..72d8e71 --- /dev/null +++ b/test/services/off_service_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:readly/data/services/off_service.dart'; + +void main() { + final sampleProduct = { + 'status': 1, + 'product': { + 'product_name': 'Nutella', + 'brands': 'Ferrero, Nutella', + 'image_front_url': 'https://images.openfoodfacts.org/nutella.jpg', + 'quantity': '400 g', + 'serving_quantity': '15', + 'nutriments': { + 'energy-kcal_100g': 539, + 'proteins_100g': 6.3, + 'carbohydrates_100g': 57.5, + 'sugars_100g': '56,3', + 'fat_100g': 30.9, + }, + }, + }; + + group('OffProduct.fromApiJson', () { + test('maps all nutrition fields, including comma decimals', () { + final product = OffProduct.fromApiJson('301', sampleProduct)!; + expect(product.name, 'Nutella'); + expect(product.brand, 'Ferrero'); + expect(product.quantity, '400 g'); + expect(product.kcalPer100g, 539); + expect(product.sugarsPer100g, 56.3); + expect(product.servingGrams, 15); + }); + + test('returns null when there is no product or no name', () { + expect(OffProduct.fromApiJson('1', {'status': 0}), isNull); + expect( + OffProduct.fromApiJson('1', { + 'product': {'product_name': ''}, + }), + isNull, + ); + }); + }); + + group('OpenFoodFactsService.fetchProduct', () { + test('returns a product on 200', () async { + final service = OpenFoodFactsService( + client: MockClient((request) async { + expect(request.url.path, contains('3017624010701')); + expect(request.headers['User-Agent'], contains('Readly')); + return http.Response( + jsonEncode(sampleProduct), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + final product = await service.fetchProduct('3017624010701'); + expect(product?.name, 'Nutella'); + }); + + test('returns null on 404 and unknown products', () async { + final notFound = OpenFoodFactsService( + client: MockClient((_) async => http.Response('', 404)), + ); + expect(await notFound.fetchProduct('0'), isNull); + + final unknown = OpenFoodFactsService( + client: MockClient( + (_) async => http.Response(jsonEncode({'status': 0}), 200), + ), + ); + expect(await unknown.fetchProduct('0'), isNull); + }); + }); +} From 164cd43b7a291c203fbc02328dd8d382ab797034 Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 19:05:55 +0400 Subject: [PATCH 2/8] test: replace pumpAndSettle with custom pump function for better test stability --- PLAN.md | 44 ++++++++++++++++++++++---------------------- test/app_test.dart | 22 +++++++++++++++++----- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/PLAN.md b/PLAN.md index ad0a1c8..0f09757 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,35 +38,35 @@ Transform Readly from a single-purpose "article summarizer" into a personal **kc ### Phase 0 — Toolchain & hygiene - [x] Audit existing code (1 200 lines, 11 files) and Android config -- [ ] Rewrite `pubspec.yaml`: drop `chat_gpt_sdk`, `animated_image_list`; add riverpod, go_router, drift(+dev,build_runner), mobile_scanner, google_fonts; bump SDK to `^3.12.0`, version `3.0.0` -- [ ] Stricter `analysis_options.yaml` (flutter_lints 6 + extra rules) -- [ ] Android: add `CAMERA` permission, verify minSdk/compileSdk for mobile_scanner -- [ ] CI: analyze + test + release-build APK, current Flutter, drop `dart format` misuse +- [x] Rewrite `pubspec.yaml`: drop `chat_gpt_sdk`, `animated_image_list`; add riverpod, go_router, drift(+dev,build_runner), mobile_scanner, google_fonts; bump SDK to `^3.12.0`, version `3.0.0` +- [x] Stricter `analysis_options.yaml` (flutter_lints 6 + extra rules) +- [x] Android: add `CAMERA` permission, drop deprecated manifest `package` attr, Java 11 +- [x] CI: format + analyze + test + build APK, current Flutter, drop `dart format` misuse ### Phase 1 — Core plumbing -- [ ] Theme (Material 3, light+dark, rounded shapes, google_fonts) -- [ ] Router (5-branch StatefulShellRoute + /settings + /scan) -- [ ] Drift database + DAOs + codegen -- [ ] SettingsService (API key secure, goal/language prefs) -- [ ] AnthropicService: SSE streaming (`summarize`) + structured-output (`suggestMeals`, `suggestGroceries`) against `claude-opus-4-8` -- [ ] OpenFoodFactsService: product by barcode (v2 API, staging-safe parsing) -- [ ] ArticleExtractor: fetch URL → title + readable text -- [ ] Share-intent hook → Read tab +- [x] Theme (Material 3, light+dark, rounded shapes, google_fonts) +- [x] Router (5-branch StatefulShellRoute + /settings + /scan) +- [x] Drift database + DAOs + codegen +- [x] SettingsService (API key secure, goal/language prefs) +- [x] AnthropicService: SSE streaming (`summarize`) + structured-output (`suggestMeals`, `suggestGroceries`) against `claude-opus-4-8` +- [x] OpenFoodFactsService: product by barcode (v2 API, staging-safe parsing) +- [x] ArticleExtractor: fetch URL → title + readable text +- [x] Share-intent hook → Read tab ### Phase 2 — Features -- [ ] Track page (ring painter, grouped log, quick add sheet, scan-to-log flow, log-from-pantry) -- [ ] Kitchen page (list, scan-to-add flow, manual add/edit sheet, amount-left slider, delete) -- [ ] Meals page (suggestion cards, missing→groceries, "I made it" → log) -- [ ] Groceries page (checklist, manual add, AI proposition, clear done) -- [ ] Read page (URL field, history list, streaming summary view) -- [ ] Settings page +- [x] Track page (ring painter, grouped log, quick add sheet, scan-to-log flow, log-from-pantry) +- [x] Kitchen page (list, scan-to-add flow, manual add/edit sheet, amount-left slider, delete) +- [x] Meals page (suggestion cards, missing→groceries, "I made it" → log) +- [x] Groceries page (checklist, manual add, AI proposition, clear done) +- [x] Read page (URL field, history list, streaming summary view) +- [x] Settings page ### Phase 3 — Quality -- [ ] Unit tests: OFF response parsing, SSE stream parsing, AI JSON parsing, kcal math, article extraction -- [ ] Widget test: app boots, 5 tabs navigate -- [ ] `flutter analyze` clean, `dart format` clean +- [x] Unit tests: OFF response parsing, SSE stream parsing, AI JSON parsing, article extraction, DB queries +- [x] Widget test: app boots, 5 tabs navigate +- [x] `flutter analyze` clean, `dart format` clean - [ ] `flutter build apk` passes -- [ ] Delete dead code (old pages/services), update README +- [x] Delete dead code (old pages/services), update README ### Later / ideas (not in this pass) - [ ] Decrement pantry quantities automatically when a meal is cooked diff --git a/test/app_test.dart b/test/app_test.dart index d8d13bb..8c2289e 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -37,6 +37,14 @@ void main() { final db = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(db.close); + // Bounded pumps instead of pumpAndSettle: the app keeps light background + // activity (font loading, platform channels) that never fully settles in + // the test environment. + Future pump() async { + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + await tester.pumpWidget( ProviderScope( overrides: [ @@ -46,27 +54,31 @@ void main() { child: const ReadlyApp(), ), ); - await tester.pumpAndSettle(); + await pump(); // Track tab is home. expect(find.text('Today'), findsOneWidget); expect(find.text('Log food'), findsOneWidget); await tester.tap(find.byIcon(Icons.kitchen_outlined)); - await tester.pumpAndSettle(); + await pump(); expect(find.text('Your kitchen is empty'), findsOneWidget); await tester.tap(find.byIcon(Icons.restaurant_menu_outlined)); - await tester.pumpAndSettle(); + await pump(); expect(find.text('AI is not set up yet'), findsOneWidget); await tester.tap(find.byIcon(Icons.shopping_basket_outlined)); - await tester.pumpAndSettle(); + await pump(); expect(find.text('Nothing to buy'), findsOneWidget); await tester.tap(find.byIcon(Icons.auto_stories_outlined)); - await tester.pumpAndSettle(); + await pump(); expect(find.text('No summaries yet'), findsOneWidget); + + // Flush any lingering timers (stream cleanup, snackbars, etc.) so the + // framework's end-of-test invariants pass. + await tester.pump(const Duration(minutes: 2)); }); test('extractUrl pulls the first link out of shared text', () { From 3639ff81cf6f07001f0208e9898679af8de1ea5d Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 21:12:19 +0400 Subject: [PATCH 3/8] Refactor AI service integration from Anthropic to OpenAI - Updated imports and references from AnthropicService to AiService across multiple files. - Changed error handling to reflect the new AI service. - Updated UI components to reference OpenAI API key and related settings. - Modified meal suggestion and article summarization logic to utilize the new AI service. - Enhanced logging and portion tracking features to accommodate new pantry item structure. - Added unit tests for the new AI service and updated existing tests to remove references to the old service. - Updated pubspec.yaml to include dart_openai dependency. --- PLAN.md | 21 +- devtools_options.yaml | 3 + lib/app/theme.dart | 3 + lib/data/db/database.dart | 26 +- lib/data/db/database.g.dart | 145 ++++++ lib/data/meal_type.dart | 16 +- lib/data/quantity.dart | 52 +++ ...anthropic_service.dart => ai_service.dart} | 227 +++++---- lib/data/services/article_extractor.dart | 48 +- lib/data/services/settings_service.dart | 2 +- lib/features/groceries/groceries_page.dart | 12 +- lib/features/kitchen/kitchen_page.dart | 434 ++++++++++++++---- lib/features/meals/meals_page.dart | 25 +- lib/features/reader/summary_page.dart | 15 +- lib/features/settings/settings_page.dart | 10 +- lib/features/track/track_page.dart | 59 ++- lib/providers.dart | 18 +- lib/widgets/log_portion_sheet.dart | 215 ++++++--- pubspec.yaml | 3 + test/app_test.dart | 31 +- test/data/database_test.dart | 24 + test/data/quantity_test.dart | 72 +++ test/services/ai_service_test.dart | 66 +++ test/services/anthropic_service_test.dart | 98 ---- test/services/article_extractor_test.dart | 30 ++ 25 files changed, 1207 insertions(+), 448 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/data/quantity.dart rename lib/data/services/{anthropic_service.dart => ai_service.dart} (56%) create mode 100644 test/data/quantity_test.dart create mode 100644 test/services/ai_service_test.dart delete mode 100644 test/services/anthropic_service_test.dart diff --git a/PLAN.md b/PLAN.md index 0f09757..f2923ad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,8 +12,8 @@ Transform Readly from a single-purpose "article summarizer" into a personal **kc | Local database | `drift` (SQLite) + `drift_flutter` | Type-safe, reactive streams drive the UI, testable | | Barcode scanning | `mobile_scanner` 7.x | Maintained, CameraX-based | | Food data | Open Food Facts API v2 (plain `http`, `User-Agent` set) | Free, no key, FR products well covered | -| AI | Anthropic Messages API via raw HTTP (`claude-opus-4-8`), BYOK key in `flutter_secure_storage` | `chat_gpt_sdk` is dead; no official Dart SDK → raw HTTP + SSE streaming; structured outputs (`output_config.format`) for meals/groceries | -| Article extraction | In-app: fetch page + strip text with `html` package | Removes dependency on the (likely dead) `readly.lightin.io` readability API | +| AI | ~~Anthropic raw HTTP~~ → `dart_openai` (`gpt-5-nano`), BYOK key in `flutter_secure_storage` | v3.1: user prefers the OpenAI models router; streaming for summaries, `json_schema` response format for meals/groceries | +| Article extraction | `readly.lightin.io/api/read` readability proxy, in-app `html` stripping as fallback | v3.1: the proxy strips HTML/JS/CSS server-side and keeps only the article text — better signal than in-app stripping | | Fonts | `google_fonts` (Outfit / Manrope style) | Clean rounded look without bundling font files | | Lint/CI | `flutter_lints` 6 + stricter rules; GitHub Actions: analyze → test → build | There was no analyze/test step at all | @@ -65,9 +65,24 @@ Transform Readly from a single-purpose "article summarizer" into a personal **kc - [x] Unit tests: OFF response parsing, SSE stream parsing, AI JSON parsing, article extraction, DB queries - [x] Widget test: app boots, 5 tabs navigate - [x] `flutter analyze` clean, `dart format` clean -- [ ] `flutter build apk` passes +- [x] `flutter build apk` passes - [x] Delete dead code (old pages/services), update README +## Readly 3.1 — feedback pass (2026-07) + +Guiding idea: **sliders are estimates, not scales.** Nobody knows to the gram how much +they ate or how much is left — every quantity in the app is a fraction of the package +(or a number of units for eggs & co), with the already-consumed part greyed out. + +- [x] AI: swap Anthropic → `dart_openai` with `gpt-5-nano` (BYOK OpenAI key in settings) +- [x] Read: restore the `readly.lightin.io/api/read` readability proxy (in-app extraction kept as fallback) +- [x] DB v2: `PantryItems.unitCount` (unit-tracked foods) + `PantryItems.perishable`, migration included +- [x] Kitchen: newest item on top; search bar; "Perishable" + "Finished" filter chips +- [x] Kitchen: slider hidden by default (thin colored gauge instead), appears on tap; % for weight-based items, units for unit-based (eggs → 0–12) +- [x] Kitchen: quick actions on each card — "I ate some" (portion slider → logs kcal + decrements stock) and "add to groceries" +- [x] Track: portion slider (fraction of the package, consumed part greyed) instead of the "portion (g)" field; pantry logs keep stock in sync +- [x] Color pass: vibrant scheme variant, per-meal-type accent colors, gradient kcal ring, level-colored stock gauges — still sober + ### Later / ideas (not in this pass) - [ ] Decrement pantry quantities automatically when a meal is cooked - [ ] Weekly kcal/macro charts diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/app/theme.dart b/lib/app/theme.dart index deb1d21..1390eb4 100644 --- a/lib/app/theme.dart +++ b/lib/app/theme.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; /// Clean, rounded Material 3 theme with a fresh green seed. +/// The vibrant variant keeps the sober look but lets secondary/tertiary +/// accents actually show some color. ThemeData buildTheme(Brightness brightness) { final scheme = ColorScheme.fromSeed( seedColor: const Color(0xFF3E9B4F), brightness: brightness, + dynamicSchemeVariant: DynamicSchemeVariant.vibrant, ); final base = ThemeData(colorScheme: scheme, useMaterial3: true); diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index eb89f93..fe32ee0 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -18,6 +18,13 @@ class PantryItems extends Table { /// Human readable package size, e.g. "500 g". TextColumn get packageQuantity => text().nullable()(); + /// For foods counted in units (eggs, yogurts…): units per full package. + /// Null means the item is tracked as a percentage of the package. + IntColumn get unitCount => integer().nullable()(); + + /// Perishable foods should be eaten first. + BoolColumn get perishable => boolean().withDefault(const Constant(false))(); + /// Estimated fraction left in the package, 0.0 to 1.0. RealColumn get amountLeft => real().withDefault(const Constant(1.0))(); DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); @@ -67,12 +74,27 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.addColumn(pantryItems, pantryItems.unitCount); + await m.addColumn(pantryItems, pantryItems.perishable); + } + }, + ); // ---- Pantry ---- + /// Newest additions first, so what you just bought sits on top. Stream> watchPantry() => - (select(pantryItems)..orderBy([(t) => OrderingTerm.asc(t.name)])).watch(); + (select(pantryItems)..orderBy([ + (t) => OrderingTerm.desc(t.addedAt), + (t) => OrderingTerm.desc(t.id), + ])) + .watch(); Future addPantryItem(PantryItemsCompanion item) => into(pantryItems).insert(item); diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index d180c9f..6b92ea3 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -128,6 +128,32 @@ class $PantryItemsTable extends PantryItems type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _unitCountMeta = const VerificationMeta( + 'unitCount', + ); + @override + late final GeneratedColumn unitCount = GeneratedColumn( + 'unit_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _perishableMeta = const VerificationMeta( + 'perishable', + ); + @override + late final GeneratedColumn perishable = GeneratedColumn( + 'perishable', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("perishable" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _amountLeftMeta = const VerificationMeta( 'amountLeft', ); @@ -177,6 +203,8 @@ class $PantryItemsTable extends PantryItems sugarsPer100g, fatsPer100g, packageQuantity, + unitCount, + perishable, amountLeft, addedAt, updatedAt, @@ -276,6 +304,18 @@ class $PantryItemsTable extends PantryItems ), ); } + if (data.containsKey('unit_count')) { + context.handle( + _unitCountMeta, + unitCount.isAcceptableOrUnknown(data['unit_count']!, _unitCountMeta), + ); + } + if (data.containsKey('perishable')) { + context.handle( + _perishableMeta, + perishable.isAcceptableOrUnknown(data['perishable']!, _perishableMeta), + ); + } if (data.containsKey('amount_left')) { context.handle( _amountLeftMeta, @@ -347,6 +387,14 @@ class $PantryItemsTable extends PantryItems DriftSqlType.string, data['${effectivePrefix}package_quantity'], ), + unitCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}unit_count'], + ), + perishable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}perishable'], + )!, amountLeft: attachedDatabase.typeMapping.read( DriftSqlType.double, data['${effectivePrefix}amount_left'], @@ -383,6 +431,13 @@ class PantryItem extends DataClass implements Insertable { /// Human readable package size, e.g. "500 g". final String? packageQuantity; + /// For foods counted in units (eggs, yogurts…): units per full package. + /// Null means the item is tracked as a percentage of the package. + final int? unitCount; + + /// Perishable foods should be eaten first. + final bool perishable; + /// Estimated fraction left in the package, 0.0 to 1.0. final double amountLeft; final DateTime addedAt; @@ -399,6 +454,8 @@ class PantryItem extends DataClass implements Insertable { this.sugarsPer100g, this.fatsPer100g, this.packageQuantity, + this.unitCount, + required this.perishable, required this.amountLeft, required this.addedAt, required this.updatedAt, @@ -435,6 +492,10 @@ class PantryItem extends DataClass implements Insertable { if (!nullToAbsent || packageQuantity != null) { map['package_quantity'] = Variable(packageQuantity); } + if (!nullToAbsent || unitCount != null) { + map['unit_count'] = Variable(unitCount); + } + map['perishable'] = Variable(perishable); map['amount_left'] = Variable(amountLeft); map['added_at'] = Variable(addedAt); map['updated_at'] = Variable(updatedAt); @@ -472,6 +533,10 @@ class PantryItem extends DataClass implements Insertable { packageQuantity: packageQuantity == null && nullToAbsent ? const Value.absent() : Value(packageQuantity), + unitCount: unitCount == null && nullToAbsent + ? const Value.absent() + : Value(unitCount), + perishable: Value(perishable), amountLeft: Value(amountLeft), addedAt: Value(addedAt), updatedAt: Value(updatedAt), @@ -495,6 +560,8 @@ class PantryItem extends DataClass implements Insertable { sugarsPer100g: serializer.fromJson(json['sugarsPer100g']), fatsPer100g: serializer.fromJson(json['fatsPer100g']), packageQuantity: serializer.fromJson(json['packageQuantity']), + unitCount: serializer.fromJson(json['unitCount']), + perishable: serializer.fromJson(json['perishable']), amountLeft: serializer.fromJson(json['amountLeft']), addedAt: serializer.fromJson(json['addedAt']), updatedAt: serializer.fromJson(json['updatedAt']), @@ -515,6 +582,8 @@ class PantryItem extends DataClass implements Insertable { 'sugarsPer100g': serializer.toJson(sugarsPer100g), 'fatsPer100g': serializer.toJson(fatsPer100g), 'packageQuantity': serializer.toJson(packageQuantity), + 'unitCount': serializer.toJson(unitCount), + 'perishable': serializer.toJson(perishable), 'amountLeft': serializer.toJson(amountLeft), 'addedAt': serializer.toJson(addedAt), 'updatedAt': serializer.toJson(updatedAt), @@ -533,6 +602,8 @@ class PantryItem extends DataClass implements Insertable { Value sugarsPer100g = const Value.absent(), Value fatsPer100g = const Value.absent(), Value packageQuantity = const Value.absent(), + Value unitCount = const Value.absent(), + bool? perishable, double? amountLeft, DateTime? addedAt, DateTime? updatedAt, @@ -554,6 +625,8 @@ class PantryItem extends DataClass implements Insertable { packageQuantity: packageQuantity.present ? packageQuantity.value : this.packageQuantity, + unitCount: unitCount.present ? unitCount.value : this.unitCount, + perishable: perishable ?? this.perishable, amountLeft: amountLeft ?? this.amountLeft, addedAt: addedAt ?? this.addedAt, updatedAt: updatedAt ?? this.updatedAt, @@ -583,6 +656,10 @@ class PantryItem extends DataClass implements Insertable { packageQuantity: data.packageQuantity.present ? data.packageQuantity.value : this.packageQuantity, + unitCount: data.unitCount.present ? data.unitCount.value : this.unitCount, + perishable: data.perishable.present + ? data.perishable.value + : this.perishable, amountLeft: data.amountLeft.present ? data.amountLeft.value : this.amountLeft, @@ -605,6 +682,8 @@ class PantryItem extends DataClass implements Insertable { ..write('sugarsPer100g: $sugarsPer100g, ') ..write('fatsPer100g: $fatsPer100g, ') ..write('packageQuantity: $packageQuantity, ') + ..write('unitCount: $unitCount, ') + ..write('perishable: $perishable, ') ..write('amountLeft: $amountLeft, ') ..write('addedAt: $addedAt, ') ..write('updatedAt: $updatedAt') @@ -625,6 +704,8 @@ class PantryItem extends DataClass implements Insertable { sugarsPer100g, fatsPer100g, packageQuantity, + unitCount, + perishable, amountLeft, addedAt, updatedAt, @@ -644,6 +725,8 @@ class PantryItem extends DataClass implements Insertable { other.sugarsPer100g == this.sugarsPer100g && other.fatsPer100g == this.fatsPer100g && other.packageQuantity == this.packageQuantity && + other.unitCount == this.unitCount && + other.perishable == this.perishable && other.amountLeft == this.amountLeft && other.addedAt == this.addedAt && other.updatedAt == this.updatedAt); @@ -661,6 +744,8 @@ class PantryItemsCompanion extends UpdateCompanion { final Value sugarsPer100g; final Value fatsPer100g; final Value packageQuantity; + final Value unitCount; + final Value perishable; final Value amountLeft; final Value addedAt; final Value updatedAt; @@ -676,6 +761,8 @@ class PantryItemsCompanion extends UpdateCompanion { this.sugarsPer100g = const Value.absent(), this.fatsPer100g = const Value.absent(), this.packageQuantity = const Value.absent(), + this.unitCount = const Value.absent(), + this.perishable = const Value.absent(), this.amountLeft = const Value.absent(), this.addedAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -692,6 +779,8 @@ class PantryItemsCompanion extends UpdateCompanion { this.sugarsPer100g = const Value.absent(), this.fatsPer100g = const Value.absent(), this.packageQuantity = const Value.absent(), + this.unitCount = const Value.absent(), + this.perishable = const Value.absent(), this.amountLeft = const Value.absent(), this.addedAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -708,6 +797,8 @@ class PantryItemsCompanion extends UpdateCompanion { Expression? sugarsPer100g, Expression? fatsPer100g, Expression? packageQuantity, + Expression? unitCount, + Expression? perishable, Expression? amountLeft, Expression? addedAt, Expression? updatedAt, @@ -724,6 +815,8 @@ class PantryItemsCompanion extends UpdateCompanion { if (sugarsPer100g != null) 'sugars_per100g': sugarsPer100g, if (fatsPer100g != null) 'fats_per100g': fatsPer100g, if (packageQuantity != null) 'package_quantity': packageQuantity, + if (unitCount != null) 'unit_count': unitCount, + if (perishable != null) 'perishable': perishable, if (amountLeft != null) 'amount_left': amountLeft, if (addedAt != null) 'added_at': addedAt, if (updatedAt != null) 'updated_at': updatedAt, @@ -742,6 +835,8 @@ class PantryItemsCompanion extends UpdateCompanion { Value? sugarsPer100g, Value? fatsPer100g, Value? packageQuantity, + Value? unitCount, + Value? perishable, Value? amountLeft, Value? addedAt, Value? updatedAt, @@ -758,6 +853,8 @@ class PantryItemsCompanion extends UpdateCompanion { sugarsPer100g: sugarsPer100g ?? this.sugarsPer100g, fatsPer100g: fatsPer100g ?? this.fatsPer100g, packageQuantity: packageQuantity ?? this.packageQuantity, + unitCount: unitCount ?? this.unitCount, + perishable: perishable ?? this.perishable, amountLeft: amountLeft ?? this.amountLeft, addedAt: addedAt ?? this.addedAt, updatedAt: updatedAt ?? this.updatedAt, @@ -800,6 +897,12 @@ class PantryItemsCompanion extends UpdateCompanion { if (packageQuantity.present) { map['package_quantity'] = Variable(packageQuantity.value); } + if (unitCount.present) { + map['unit_count'] = Variable(unitCount.value); + } + if (perishable.present) { + map['perishable'] = Variable(perishable.value); + } if (amountLeft.present) { map['amount_left'] = Variable(amountLeft.value); } @@ -826,6 +929,8 @@ class PantryItemsCompanion extends UpdateCompanion { ..write('sugarsPer100g: $sugarsPer100g, ') ..write('fatsPer100g: $fatsPer100g, ') ..write('packageQuantity: $packageQuantity, ') + ..write('unitCount: $unitCount, ') + ..write('perishable: $perishable, ') ..write('amountLeft: $amountLeft, ') ..write('addedAt: $addedAt, ') ..write('updatedAt: $updatedAt') @@ -2039,6 +2144,8 @@ typedef $$PantryItemsTableCreateCompanionBuilder = Value sugarsPer100g, Value fatsPer100g, Value packageQuantity, + Value unitCount, + Value perishable, Value amountLeft, Value addedAt, Value updatedAt, @@ -2056,6 +2163,8 @@ typedef $$PantryItemsTableUpdateCompanionBuilder = Value sugarsPer100g, Value fatsPer100g, Value packageQuantity, + Value unitCount, + Value perishable, Value amountLeft, Value addedAt, Value updatedAt, @@ -2125,6 +2234,16 @@ class $$PantryItemsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get unitCount => $composableBuilder( + column: $table.unitCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get perishable => $composableBuilder( + column: $table.perishable, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => ColumnFilters(column), @@ -2205,6 +2324,16 @@ class $$PantryItemsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get unitCount => $composableBuilder( + column: $table.unitCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get perishable => $composableBuilder( + column: $table.perishable, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => ColumnOrderings(column), @@ -2275,6 +2404,14 @@ class $$PantryItemsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get unitCount => + $composableBuilder(column: $table.unitCount, builder: (column) => column); + + GeneratedColumn get perishable => $composableBuilder( + column: $table.perishable, + builder: (column) => column, + ); + GeneratedColumn get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => column, @@ -2329,6 +2466,8 @@ class $$PantryItemsTableTableManager Value sugarsPer100g = const Value.absent(), Value fatsPer100g = const Value.absent(), Value packageQuantity = const Value.absent(), + Value unitCount = const Value.absent(), + Value perishable = const Value.absent(), Value amountLeft = const Value.absent(), Value addedAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -2344,6 +2483,8 @@ class $$PantryItemsTableTableManager sugarsPer100g: sugarsPer100g, fatsPer100g: fatsPer100g, packageQuantity: packageQuantity, + unitCount: unitCount, + perishable: perishable, amountLeft: amountLeft, addedAt: addedAt, updatedAt: updatedAt, @@ -2361,6 +2502,8 @@ class $$PantryItemsTableTableManager Value sugarsPer100g = const Value.absent(), Value fatsPer100g = const Value.absent(), Value packageQuantity = const Value.absent(), + Value unitCount = const Value.absent(), + Value perishable = const Value.absent(), Value amountLeft = const Value.absent(), Value addedAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -2376,6 +2519,8 @@ class $$PantryItemsTableTableManager sugarsPer100g: sugarsPer100g, fatsPer100g: fatsPer100g, packageQuantity: packageQuantity, + unitCount: unitCount, + perishable: perishable, amountLeft: amountLeft, addedAt: addedAt, updatedAt: updatedAt, diff --git a/lib/data/meal_type.dart b/lib/data/meal_type.dart index 65bed4d..bb1a2c8 100644 --- a/lib/data/meal_type.dart +++ b/lib/data/meal_type.dart @@ -1,17 +1,23 @@ import 'package:flutter/material.dart'; enum MealType { - breakfast('breakfast', 'Breakfast', Icons.free_breakfast), - lunch('lunch', 'Lunch', Icons.lunch_dining), - dinner('dinner', 'Dinner', Icons.dinner_dining), - snack('snack', 'Snack', Icons.cookie); + breakfast('breakfast', 'Breakfast', Icons.free_breakfast, Color(0xFFE8930C)), + lunch('lunch', 'Lunch', Icons.lunch_dining, Color(0xFF3E9B4F)), + dinner('dinner', 'Dinner', Icons.dinner_dining, Color(0xFF5C6BC0)), + snack('snack', 'Snack', Icons.cookie, Color(0xFFD81B75)); - const MealType(this.value, this.label, this.icon); + const MealType(this.value, this.label, this.icon, this.color); final String value; final String label; final IconData icon; + /// Accent color used for icons and highlights of this meal. + final Color color; + + /// Soft background tint matching [color]. + Color get tint => color.withValues(alpha: 0.14); + static MealType fromValue(String value) => MealType.values.firstWhere( (t) => t.value == value, orElse: () => MealType.snack, diff --git a/lib/data/quantity.dart b/lib/data/quantity.dart new file mode 100644 index 0000000..1a1ff40 --- /dev/null +++ b/lib/data/quantity.dart @@ -0,0 +1,52 @@ +import 'db/database.dart'; + +/// Helpers to reason about pantry quantities: the app never asks for exact +/// grams — everything is an estimated fraction of the package (or a number +/// of units for unit-counted foods like eggs). +extension PantryQuantity on PantryItem { + /// Whether this item is tracked in units (eggs) rather than percent. + bool get isUnitBased => unitCount != null && unitCount! > 0; + + /// Units remaining, rounded to whole units. + int get unitsLeft => + isUnitBased ? (amountLeft * unitCount!).round().clamp(0, unitCount!) : 0; + + /// Whether the package is (practically) finished. + bool get isConsumed => isUnitBased ? unitsLeft == 0 : amountLeft <= 0.005; + + /// "7/12" for unit-based items, "75%" otherwise. + String get amountLabel => isUnitBased + ? '$unitsLeft/${unitCount!}' + : '${(amountLeft * 100).round()}%'; + + /// Grams (or ml, treated as equivalent) in a full package, parsed from the + /// human-readable [packageQuantity]. Null when it cannot be parsed. + double? get packageGrams => parsePackageGrams(packageQuantity); + + /// Estimated kcal in a fraction of the package (e.g. what was just eaten). + /// Null when kcal/100g or the package size is unknown. + double? kcalForFraction(double fraction) { + final grams = packageGrams; + if (grams == null || kcalPer100g == null) return null; + return kcalPer100g! * grams * fraction / 100; + } +} + +/// Parses "500 g", "1,5 kg", "33 cl", "1L"… into grams (ml counted as g, +/// close enough for kcal-per-100g estimates). Returns null when unparseable. +double? parsePackageGrams(String? packageQuantity) { + if (packageQuantity == null) return null; + final match = RegExp( + r'([\d]+(?:[.,]\d+)?)\s*(kg|g|mg|l|dl|cl|ml)\b', + caseSensitive: false, + ).firstMatch(packageQuantity); + if (match == null) return null; + final value = double.parse(match.group(1)!.replaceAll(',', '.')); + return switch (match.group(2)!.toLowerCase()) { + 'kg' || 'l' => value * 1000, + 'dl' => value * 100, + 'cl' => value * 10, + 'mg' => value / 1000, + _ => value, + }; +} diff --git a/lib/data/services/anthropic_service.dart b/lib/data/services/ai_service.dart similarity index 56% rename from lib/data/services/anthropic_service.dart rename to lib/data/services/ai_service.dart index 0d9672b..1a5b4c5 100644 --- a/lib/data/services/anthropic_service.dart +++ b/lib/data/services/ai_service.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'package:http/http.dart' as http; +import 'package:dart_openai/dart_openai.dart'; -/// Thrown when the Anthropic API returns an error. -class AnthropicException implements Exception { - AnthropicException(this.message, {this.statusCode}); +/// Thrown when the OpenAI API returns an error. +class AiException implements Exception { + AiException(this.message, {this.statusCode}); final String message; final int? statusCode; @@ -13,7 +13,7 @@ class AnthropicException implements Exception { bool get isAuthError => statusCode == 401; @override - String toString() => 'AnthropicException($statusCode): $message'; + String toString() => 'AiException($statusCode): $message'; } /// A meal suggestion produced by the AI meal maker. @@ -66,89 +66,61 @@ class GrocerySuggestion { final String reason; } -/// Direct client for the Anthropic Messages API (there is no official Dart -/// SDK). The user supplies their own API key (BYOK). -class AnthropicService { - AnthropicService({required this.apiKey, http.Client? client}) - : _client = client ?? http.Client(); - - static const endpoint = 'https://api.anthropic.com/v1/messages'; - static const model = 'claude-opus-4-8'; +/// Client for the OpenAI Chat Completions API through `dart_openai`. +/// The user supplies their own API key (BYOK). +class AiService { + AiService({required String apiKey}) { + OpenAI.apiKey = apiKey; + OpenAI.requestsTimeOut = const Duration(minutes: 5); + } - final String apiKey; - final http.Client _client; + /// Cheap, fast router model — plenty for summaries and meal ideas. + static const model = 'gpt-5-nano'; - Map get _headers => { - 'content-type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }; + static OpenAIChatCompletionChoiceMessageModel _message( + OpenAIChatMessageRole role, + String text, + ) { + return OpenAIChatCompletionChoiceMessageModel( + role: role, + content: [OpenAIChatCompletionChoiceMessageContentItemModel.text(text)], + ); + } - /// Streams a summary of [text] as it is generated (SSE). + /// Streams a summary of [text] as it is generated. Stream streamArticleSummary({ required String? title, required String text, required String language, - }) async* { - final request = http.Request('POST', Uri.parse(endpoint)) - ..headers.addAll(_headers) - ..body = jsonEncode({ - 'model': model, - 'max_tokens': 16000, - 'stream': true, - 'system': - 'You are an expert at extracting key information from web articles. ' - 'Produce a concise, well-organized summary: a one-sentence TL;DR ' - 'first, then short paragraphs or "- " bullet points for the key ' - 'facts. Ignore navigation, ads and unrelated content. Plain text ' - 'only, no markdown headers. Answer in $language.', - 'messages': [ - { - 'role': 'user', - 'content': - 'Summarize this article${title == null ? '' : ' titled "$title"'}:\n\n$text', - }, - ], - }); - - final response = await _client.send(request); - if (response.statusCode != 200) { - final body = await response.stream.bytesToString(); - throw AnthropicException( - _errorMessage(body), - statusCode: response.statusCode, - ); - } - yield* parseSseTextDeltas(response.stream.transform(utf8.decoder)); - } + }) { + final stream = OpenAI.instance.chat.createStream( + model: model, + messages: [ + _message( + OpenAIChatMessageRole.system, + 'You are an expert at extracting key information from web articles. ' + 'Produce a concise, well-organized summary: a one-sentence TL;DR ' + 'first, then short paragraphs or "- " bullet points for the key ' + 'facts. Ignore navigation, ads and unrelated content. Plain text ' + 'only, no markdown headers. Answer in $language.', + ), + _message( + OpenAIChatMessageRole.user, + 'Summarize this article${title == null ? '' : ' titled "$title"'}:' + '\n\n$text', + ), + ], + ); - /// Parses an Anthropic SSE stream into the text deltas it carries. - /// Exposed for testing. - static Stream parseSseTextDeltas(Stream input) async* { - final lines = input.transform(const LineSplitter()); - await for (final line in lines) { - if (!line.startsWith('data: ')) continue; - final payload = line.substring(6).trim(); - if (payload.isEmpty || payload == '[DONE]') continue; - final Map event; - try { - event = jsonDecode(payload) as Map; - } on FormatException { - continue; - } - switch (event['type']) { - case 'content_block_delta': - final delta = event['delta'] as Map?; - if (delta?['type'] == 'text_delta') { - yield delta!['text'] as String; - } - case 'error': - final error = event['error'] as Map?; - throw AnthropicException( - (error?['message'] as String?) ?? 'Unknown streaming error', - ); - } - } + return stream + .map((event) { + if (event.choices.isEmpty) return ''; + final content = event.choices.first.delta.content; + if (content == null) return ''; + return content.map((item) => item?.text ?? '').join(); + }) + .where((delta) => delta.isNotEmpty) + .handleError((Object e) => throw _wrap(e)); } /// Asks for meal suggestions based on what is in the kitchen. @@ -164,7 +136,7 @@ class AnthropicService { 'addicted to sugar and quick meals, and wants to eat healthier with ' 'minimal effort. Suggest simple, realistic meals that mostly use ' 'what is already in the kitchen. Prefer few steps and short cooking ' - 'times. Answer in $language.', + 'times. Answer in $language, as JSON.', userContent: 'Here is my kitchen inventory as JSON (amount_left_percent is how ' 'much of the package remains):\n${jsonEncode(pantry)}\n\n' @@ -172,6 +144,7 @@ class AnthropicService { 'Suggest exactly 3 healthy, low-effort meals I can make right now. ' 'Only list an ingredient in missing_ingredients if I truly need to ' 'buy it (assume I have water, salt, pepper and basic oil).', + schemaName: 'meal_suggestions', schema: { 'type': 'object', 'properties': { @@ -232,13 +205,14 @@ class AnthropicService { 'You are a grocery-planning assistant for a lazy person trying to ' 'eat healthier. Suggest practical items to buy so that healthy, ' 'low-effort meals are always possible. Favor staples and fresh ' - 'items that complement what they own. Answer in $language.', + 'items that complement what they own. Answer in $language, as JSON.', userContent: 'Kitchen inventory:\n${jsonEncode(pantry)}\n\n' 'Recently eaten: ${jsonEncode(recentlyEaten)}\n' 'Already on my shopping list: ${jsonEncode(alreadyOnList)}\n\n' 'Suggest up to 8 items I should buy (do not repeat items already on ' 'the list), each with a very short reason.', + schemaName: 'grocery_suggestions', schema: { 'type': 'object', 'properties': { @@ -267,58 +241,63 @@ class AnthropicService { Future> _structuredRequest({ required String system, required String userContent, + required String schemaName, required Map schema, }) async { - final response = await _client - .post( - Uri.parse(endpoint), - headers: _headers, - body: jsonEncode({ - 'model': model, - 'max_tokens': 8000, - 'system': system, - 'output_config': { - 'format': {'type': 'json_schema', 'schema': schema}, - }, - 'messages': [ - {'role': 'user', 'content': userContent}, - ], - }), - ) - .timeout(const Duration(minutes: 5)); - - if (response.statusCode != 200) { - throw AnthropicException( - _errorMessage(response.body), - statusCode: response.statusCode, + final OpenAIChatCompletionModel response; + try { + response = await OpenAI.instance.chat.create( + model: model, + messages: [ + _message(OpenAIChatMessageRole.system, system), + _message(OpenAIChatMessageRole.user, userContent), + ], + responseFormat: { + 'type': 'json_schema', + 'json_schema': {'name': schemaName, 'strict': true, 'schema': schema}, + }, ); + } catch (e) { + throw _wrap(e); } - return extractStructuredJson(response.body); - } - /// Pulls the JSON payload out of a structured-output response body. - /// Exposed for testing. - static Map extractStructuredJson(String responseBody) { - final body = jsonDecode(responseBody) as Map; - if (body['stop_reason'] == 'refusal') { - throw AnthropicException('The model declined to answer this request.'); + if (response.choices.isEmpty) { + throw AiException('The model returned an empty response.'); } - final content = (body['content'] as List) - .cast>(); - final textBlock = content.firstWhere( - (b) => b['type'] == 'text', - orElse: () => throw AnthropicException('No text block in response'), - ); - return jsonDecode(textBlock['text'] as String) as Map; + final content = response.choices.first.message.content; + final text = content?.map((item) => item.text ?? '').join() ?? ''; + return extractJson(text); } - static String _errorMessage(String body) { + /// Pulls a JSON object out of a model answer, tolerating code fences and + /// surrounding prose. Exposed for testing. + static Map extractJson(String text) { + var candidate = text.trim(); + if (candidate.isEmpty) { + throw AiException('The model returned an empty response.'); + } + final fence = RegExp(r'```(?:json)?\s*([\s\S]*?)```').firstMatch(candidate); + if (fence != null) candidate = fence.group(1)!.trim(); + if (!candidate.startsWith('{')) { + final start = candidate.indexOf('{'); + final end = candidate.lastIndexOf('}'); + if (start == -1 || end <= start) { + throw AiException('The model did not answer with JSON.'); + } + candidate = candidate.substring(start, end + 1); + } try { - final json = jsonDecode(body) as Map; - final error = json['error'] as Map?; - return (error?['message'] as String?) ?? body; + return jsonDecode(candidate) as Map; } on FormatException { - return body; + throw AiException('The model answered with invalid JSON.'); + } + } + + static AiException _wrap(Object e) { + if (e is AiException) return e; + if (e is RequestFailedException) { + return AiException(e.message, statusCode: e.statusCode); } + return AiException(e.toString()); } } diff --git a/lib/data/services/article_extractor.dart b/lib/data/services/article_extractor.dart index ced213d..4e52bf6 100644 --- a/lib/data/services/article_extractor.dart +++ b/lib/data/services/article_extractor.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html_parser; import 'package:http/http.dart' as http; @@ -10,18 +12,58 @@ class ExtractedArticle { final String text; } -/// Fetches a web page and extracts its readable text in-app. -/// (Replaces the old dependency on the readly.lightin.io readability API — -/// Claude does the heavy lifting on the extracted text anyway.) +/// Extracts the readable text of a web page through the readly.lightin.io +/// readability proxy (strips HTML/JS/CSS server-side and keeps only the +/// article body). Falls back to fetching + stripping in-app when the proxy +/// is unreachable. class ArticleExtractor { ArticleExtractor({http.Client? client}) : _client = client ?? http.Client(); + static const proxyEndpoint = 'https://readly.lightin.io/api/read'; + final http.Client _client; /// Characters beyond this are dropped before sending to the model. static const maxChars = 60000; Future extract(String url) async { + try { + return await _extractViaProxy(url); + } catch (_) { + return _extractInApp(url); + } + } + + Future _extractViaProxy(String url) async { + final uri = Uri.parse(proxyEndpoint).replace(queryParameters: {'url': url}); + final response = await _client + .get(uri, headers: {'Content-Type': 'application/json'}) + .timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw Exception('Readability proxy HTTP ${response.statusCode}'); + } + return parseProxyResponse(response.body, fallbackTitle: url); + } + + /// Parses the proxy's JSON payload (`title`, `textContent`). + /// Exposed for testing. + static ExtractedArticle parseProxyResponse( + String body, { + required String fallbackTitle, + }) { + final json = jsonDecode(body) as Map; + final title = (json['title'] as String?)?.trim(); + final text = (json['textContent'] as String?)?.trim() ?? ''; + if (text.isEmpty) { + throw Exception('Readability proxy returned no text'); + } + return ExtractedArticle( + title: (title == null || title.isEmpty) ? fallbackTitle : title, + text: text.length > maxChars ? text.substring(0, maxChars) : text, + ); + } + + Future _extractInApp(String url) async { final response = await _client .get( Uri.parse(url), diff --git a/lib/data/services/settings_service.dart b/lib/data/services/settings_service.dart index ddae356..0fd2790 100644 --- a/lib/data/services/settings_service.dart +++ b/lib/data/services/settings_service.dart @@ -32,7 +32,7 @@ class SettingsService { }) : _secure = secureStorage ?? const FlutterSecureStorage(), _prefsOverride = prefs; - static const _apiKeyKey = 'anthropicApiKey'; + static const _apiKeyKey = 'openaiApiKey'; static const _languageKey = 'language'; static const _kcalGoalKey = 'dailyKcalGoal'; static const defaultKcalGoal = 2200; diff --git a/lib/features/groceries/groceries_page.dart b/lib/features/groceries/groceries_page.dart index 5c8759d..bb168b1 100644 --- a/lib/features/groceries/groceries_page.dart +++ b/lib/features/groceries/groceries_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; -import '../../data/services/anthropic_service.dart'; +import '../../data/services/ai_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; @@ -35,12 +35,12 @@ class _GroceriesPageState extends ConsumerState { } Future _askAi() async { - final anthropic = await ref.read(anthropicServiceProvider.future); + final ai = await ref.read(aiServiceProvider.future); if (!mounted) return; - if (anthropic == null) { + if (ai == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Add your Anthropic API key in settings first.'), + content: const Text('Add your OpenAI API key in settings first.'), action: SnackBarAction( label: 'Settings', onPressed: () => context.push('/settings'), @@ -60,7 +60,7 @@ class _GroceriesPageState extends ConsumerState { DateTime.now().subtract(const Duration(days: 14)), ); - final suggestions = await anthropic.suggestGroceries( + final suggestions = await ai.suggestGroceries( pantry: [ for (final item in pantry) { @@ -91,7 +91,7 @@ class _GroceriesPageState extends ConsumerState { context, ).showSnackBar(SnackBar(content: Text('Added $added AI suggestions.'))); } - } on AnthropicException catch (e) { + } on AiException catch (e) { if (mounted) { ScaffoldMessenger.of( context, diff --git a/lib/features/kitchen/kitchen_page.dart b/lib/features/kitchen/kitchen_page.dart index 6f12bf0..8684c2b 100644 --- a/lib/features/kitchen/kitchen_page.dart +++ b/lib/features/kitchen/kitchen_page.dart @@ -4,16 +4,56 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; +import '../../data/quantity.dart'; import '../../data/services/off_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; +import '../../widgets/log_portion_sheet.dart'; -class KitchenPage extends ConsumerWidget { +/// Color of the "amount left" indicator: green when plenty, amber when +/// running low, red when almost gone. +Color amountLeftColor(double amountLeft, ColorScheme scheme) { + if (amountLeft > 0.5) return const Color(0xFF3E9B4F); + if (amountLeft > 0.2) return const Color(0xFFE8930C); + return scheme.error; +} + +class KitchenPage extends ConsumerStatefulWidget { const KitchenPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _KitchenPageState(); +} + +class _KitchenPageState extends ConsumerState { + final _searchController = TextEditingController(); + bool _perishableOnly = false; + bool _showFinished = false; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _visibleItems(List pantry) { + final query = _searchController.text.trim().toLowerCase(); + return [ + for (final item in pantry) + if (item.isConsumed == _showFinished && + (!_perishableOnly || item.perishable) && + (query.isEmpty || + item.name.toLowerCase().contains(query) || + (item.brand?.toLowerCase().contains(query) ?? false))) + item, + ]; + } + + @override + Widget build(BuildContext context) { final pantry = ref.watch(pantryProvider).value ?? []; + final visible = _visibleItems(pantry); + final finishedCount = pantry.where((i) => i.isConsumed).length; return Scaffold( appBar: AppBar( @@ -38,11 +78,81 @@ class KitchenPage extends ConsumerWidget { 'Scan the barcodes of what you have at home to build your ' 'inventory — the meal maker uses it to suggest what to cook.', ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), - itemCount: pantry.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, index) => _PantryCard(item: pantry[index]), + : Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search your kitchen…', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close), + onPressed: () => + setState(_searchController.clear), + ), + ), + onChanged: (_) => setState(() {}), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + FilterChip( + avatar: Icon( + Icons.eco, + size: 18, + color: _perishableOnly + ? Theme.of(context).colorScheme.onPrimary + : const Color(0xFFE8930C), + ), + label: const Text('Perishable'), + selected: _perishableOnly, + selectedColor: const Color(0xFFE8930C), + showCheckmark: false, + onSelected: (v) => setState(() => _perishableOnly = v), + ), + const SizedBox(width: 8), + FilterChip( + avatar: Icon( + Icons.hourglass_empty, + size: 18, + color: _showFinished + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + label: Text('Finished ($finishedCount)'), + selected: _showFinished, + selectedColor: Theme.of(context).colorScheme.error, + showCheckmark: false, + onSelected: (v) => setState(() => _showFinished = v), + ), + ], + ), + ), + Expanded( + child: visible.isEmpty + ? const EmptyState( + icon: Icons.search_off, + title: 'Nothing matches', + message: + 'No item matches the current search or filters.', + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), + itemCount: visible.length, + separatorBuilder: (_, _) => + const SizedBox(height: 12), + itemBuilder: (context, index) => + _PantryCard(item: visible[index]), + ), + ), + ], ), ); } @@ -124,6 +234,7 @@ class _PantryCard extends ConsumerStatefulWidget { } class _PantryCardState extends ConsumerState<_PantryCard> { + bool _expanded = false; double? _pendingAmount; @override @@ -131,103 +242,222 @@ class _PantryCardState extends ConsumerState<_PantryCard> { final item = widget.item; final scheme = Theme.of(context).colorScheme; final amount = _pendingAmount ?? item.amountLeft; + final color = amountLeftColor(amount, scheme); return Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _Thumbnail(imageUrl: item.imageUrl), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - Text( - [ - if (item.brand != null) item.brand!, - if (item.packageQuantity != null) - item.packageQuantity!, - if (item.kcalPer100g != null) - '${item.kcalPer100g!.round()} kcal/100g', - ].join(' · '), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, + clipBehavior: Clip.antiAlias, + child: InkWell( + // The adjust slider only appears once you tap the item. + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Thumbnail(imageUrl: item.imageUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.name, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + if (item.perishable) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Icon( + Icons.eco, + size: 16, + color: Color(0xFFE8930C), + ), + ), + ], ), + Text( + [ + if (item.brand != null) item.brand!, + if (item.packageQuantity != null) + item.packageQuantity!, + if (item.unitCount != null) + '${item.unitCount} units', + if (item.kcalPer100g != null) + '${item.kcalPer100g!.round()} kcal/100g', + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), + ), + IconButton( + tooltip: 'I ate some', + icon: const Icon(Icons.restaurant), + color: scheme.primary, + onPressed: item.isConsumed ? null : () => _eatSome(), + ), + IconButton( + tooltip: 'Add to groceries', + icon: const Icon(Icons.add_shopping_cart), + color: scheme.tertiary, + onPressed: () => _addToGroceries(), + ), + PopupMenuButton( + onSelected: (value) => _onMenu(value), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem( + value: 'refill', + child: Text('Refill to 100%'), ), + PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), - ), - PopupMenuButton( - onSelected: (value) => _onMenu(value), - itemBuilder: (_) => const [ - PopupMenuItem(value: 'edit', child: Text('Edit')), - PopupMenuItem( - value: 'shop', - child: Text('Add to groceries'), + ], + ), + const SizedBox(height: 10), + if (_expanded) ...[ + Row( + children: [ + Expanded( + child: Slider( + value: amount, + divisions: item.isUnitBased ? item.unitCount : 20, + activeColor: color, + onChanged: (v) => setState(() => _pendingAmount = v), + onChangeEnd: (v) { + setState(() => _pendingAmount = null); + ref + .read(databaseProvider) + .updatePantryItem( + item.id, + PantryItemsCompanion(amountLeft: Value(v)), + ); + }, + ), ), - PopupMenuItem(value: 'delete', child: Text('Delete')), + SizedBox( + width: 56, + child: Text( + _amountLabel(item, amount), + textAlign: TextAlign.end, + style: TextStyle( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + const SizedBox(width: 8), ], ), - ], - ), - Row( - children: [ - Expanded( - child: Slider( - value: amount, - onChanged: (v) => setState(() => _pendingAmount = v), - onChangeEnd: (v) { - ref - .read(databaseProvider) - .updatePantryItem( - item.id, - PantryItemsCompanion(amountLeft: Value(v)), - ); - }, - ), - ), - SizedBox( - width: 48, - child: Text( - '${(amount * 100).round()}%', - textAlign: TextAlign.end, - style: const TextStyle(fontWeight: FontWeight.w700), + ] else + Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: amount, + minHeight: 6, + color: color, + backgroundColor: scheme.surfaceContainerHighest, + ), + ), + ), + const SizedBox(width: 10), + Text( + item.isConsumed + ? 'Finished' + : _amountLabel(item, amount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], ), ), - const SizedBox(width: 8), - ], - ), - ], + ], + ), ), ), ); } + static String _amountLabel(PantryItem item, double amount) { + if (item.isUnitBased) { + final units = (amount * item.unitCount!).round().clamp( + 0, + item.unitCount!, + ); + return '$units/${item.unitCount}'; + } + return '${(amount * 100).round()}%'; + } + + Future _eatSome() async { + final item = widget.item; + final result = await showEatPantryItemSheet(context, item); + if (result == null) return; + final db = ref.read(databaseProvider); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: item.name, + kcal: result.kcal, + mealType: result.mealType.value, + pantryItemId: Value(item.id), + grams: Value(result.grams), + ), + ); + await db.updatePantryItem( + item.id, + PantryItemsCompanion( + amountLeft: Value((item.amountLeft - result.fraction).clamp(0.0, 1.0)), + ), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Logged ${result.kcal.round()} kcal — stock updated.'), + ), + ); + } + } + + Future _addToGroceries() async { + await ref + .read(databaseProvider) + .addShoppingItem(ShoppingItemsCompanion.insert(name: widget.item.name)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${widget.item.name}" added to groceries.')), + ); + } + } + Future _onMenu(String value) async { final db = ref.read(databaseProvider); switch (value) { case 'edit': await showPantryEditSheet(context, ref, existing: widget.item); - case 'shop': - await db.addShoppingItem( - ShoppingItemsCompanion.insert(name: widget.item.name), + case 'refill': + await db.updatePantryItem( + widget.item.id, + const PantryItemsCompanion(amountLeft: Value(1.0)), ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('"${widget.item.name}" added to groceries.'), - ), - ); - } case 'delete': await db.deletePantryItem(widget.item.id); } @@ -249,15 +479,18 @@ class _Thumbnail extends StatelessWidget { height: 52, child: imageUrl == null ? ColoredBox( - color: scheme.surfaceContainerHigh, - child: Icon(Icons.fastfood, color: scheme.onSurfaceVariant), + color: scheme.secondaryContainer, + child: Icon(Icons.fastfood, color: scheme.onSecondaryContainer), ) : Image.network( imageUrl!, fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox( - color: scheme.surfaceContainerHigh, - child: Icon(Icons.fastfood, color: scheme.onSurfaceVariant), + color: scheme.secondaryContainer, + child: Icon( + Icons.fastfood, + color: scheme.onSecondaryContainer, + ), ), ), ), @@ -301,6 +534,8 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { late final TextEditingController _brand; late final TextEditingController _kcal; late final TextEditingController _package; + late final TextEditingController _units; + late bool _perishable; @override void initState() { @@ -315,6 +550,8 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { _package = TextEditingController( text: e?.packageQuantity ?? p?.quantity ?? '', ); + _units = TextEditingController(text: e?.unitCount?.toString() ?? ''); + _perishable = e?.perishable ?? false; } @override @@ -323,6 +560,7 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { _brand.dispose(); _kcal.dispose(); _package.dispose(); + _units.dispose(); super.dispose(); } @@ -333,6 +571,7 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { final kcal = double.tryParse(_kcal.text.replaceAll(',', '.')); final brand = _brand.text.trim(); final package = _package.text.trim(); + final units = int.tryParse(_units.text.trim()); final p = widget.product; if (widget.existing != null) { @@ -343,6 +582,8 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { brand: Value(brand.isEmpty ? null : brand), kcalPer100g: Value(kcal), packageQuantity: Value(package.isEmpty ? null : package), + unitCount: Value((units ?? 0) > 0 ? units : null), + perishable: Value(_perishable), ), ); } else { @@ -358,6 +599,8 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { sugarsPer100g: Value(p?.sugarsPer100g), fatsPer100g: Value(p?.fatsPer100g), packageQuantity: Value(package.isEmpty ? null : package), + unitCount: Value((units ?? 0) > 0 ? units : null), + perishable: Value(_perishable), ), ); } @@ -409,7 +652,24 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 12), + TextField( + controller: _units, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Units per package (e.g. 12 eggs — optional)', + helperText: 'When set, quantities are tracked in units, not %', + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Perishable'), + subtitle: const Text('Fresh food that should be eaten first'), + secondary: const Icon(Icons.eco, color: Color(0xFFE8930C)), + value: _perishable, + onChanged: (v) => setState(() => _perishable = v), + ), + const SizedBox(height: 8), FilledButton.icon( icon: const Icon(Icons.check), label: Text(widget.existing == null ? 'Add item' : 'Save'), diff --git a/lib/features/meals/meals_page.dart b/lib/features/meals/meals_page.dart index c068a99..bc37612 100644 --- a/lib/features/meals/meals_page.dart +++ b/lib/features/meals/meals_page.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; import '../../data/meal_type.dart'; -import '../../data/services/anthropic_service.dart'; +import '../../data/services/ai_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; @@ -33,7 +33,7 @@ class MealsPage extends ConsumerWidget { AsyncError(:final error) => EmptyState( icon: Icons.error_outline, title: 'Could not get suggestions', - message: error is AnthropicException ? error.message : '$error', + message: error is AiException ? error.message : '$error', action: FilledButton( onPressed: () => ref.read(mealSuggestionsProvider.notifier).generate(), @@ -58,7 +58,7 @@ class _Idle extends ConsumerWidget { icon: Icons.key_off, title: 'AI is not set up yet', message: - 'Add your Anthropic API key in settings to unlock meal ' + 'Add your OpenAI API key in settings to unlock meal ' 'suggestions and article summaries.', action: FilledButton.icon( icon: const Icon(Icons.settings), @@ -159,11 +159,26 @@ class _MealCard extends ConsumerWidget { runSpacing: 8, children: [ Chip( - avatar: const Icon(Icons.schedule, size: 18), + avatar: Icon( + Icons.schedule, + size: 18, + color: scheme.onSecondaryContainer, + ), + backgroundColor: scheme.secondaryContainer, + labelStyle: TextStyle(color: scheme.onSecondaryContainer), label: Text('${meal.timeMinutes} min'), ), Chip( - avatar: const Icon(Icons.local_fire_department, size: 18), + avatar: Icon( + Icons.local_fire_department, + size: 18, + color: scheme.onTertiaryContainer, + ), + backgroundColor: scheme.tertiaryContainer, + labelStyle: TextStyle( + color: scheme.onTertiaryContainer, + fontWeight: FontWeight.w700, + ), label: Text('${meal.kcal.round()} kcal'), ), ], diff --git a/lib/features/reader/summary_page.dart b/lib/features/reader/summary_page.dart index 275dfbf..414bdc8 100644 --- a/lib/features/reader/summary_page.dart +++ b/lib/features/reader/summary_page.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../data/db/database.dart'; -import '../../data/services/anthropic_service.dart'; +import '../../data/services/ai_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; @@ -73,14 +73,13 @@ class _SummaryPageState extends ConsumerState { _needsApiKey = false; }); - final anthropic = await ref.read(anthropicServiceProvider.future); + final ai = await ref.read(aiServiceProvider.future); if (!mounted) return; - if (anthropic == null) { + if (ai == null) { setState(() { _status = _Status.error; _needsApiKey = true; - _error = - 'Add your Anthropic API key in settings to summarize articles.'; + _error = 'Add your OpenAI API key in settings to summarize articles.'; }); return; } @@ -99,7 +98,7 @@ class _SummaryPageState extends ConsumerState { _status = _Status.streaming; }); - _subscription = anthropic + _subscription = ai .streamArticleSummary( title: article.title, text: article.text, @@ -109,7 +108,7 @@ class _SummaryPageState extends ConsumerState { (delta) => setState(() => _summary += delta), onError: (Object e) => setState(() { _status = _Status.error; - _error = e is AnthropicException ? e.message : '$e'; + _error = e is AiException ? e.message : '$e'; }), onDone: () async { if (!mounted) return; @@ -131,7 +130,7 @@ class _SummaryPageState extends ConsumerState { if (!mounted) return; setState(() { _status = _Status.error; - _error = e is AnthropicException ? e.message : '$e'; + _error = e is AiException ? e.message : '$e'; }); } } diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 8d4b721..633fa85 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -62,7 +62,7 @@ class _SettingsPageState extends ConsumerState { body: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ - const SectionHeader('AI (Anthropic)'), + const SectionHeader('AI (OpenAI)'), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -101,8 +101,8 @@ class _SettingsPageState extends ConsumerState { controller: _apiKeyController, obscureText: _obscureKey, decoration: InputDecoration( - labelText: 'Anthropic API key', - hintText: 'sk-ant-…', + labelText: 'OpenAI API key', + hintText: 'sk-…', suffixIcon: IconButton( icon: Icon( _obscureKey ? Icons.visibility_off : Icons.visibility, @@ -119,9 +119,9 @@ class _SettingsPageState extends ConsumerState { ), TextButton.icon( icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Get a key at console.anthropic.com'), + label: const Text('Get a key at platform.openai.com'), onPressed: () => launchUrl( - Uri.parse('https://console.anthropic.com/settings/keys'), + Uri.parse('https://platform.openai.com/api-keys'), mode: LaunchMode.externalApplication, ), ), diff --git a/lib/features/track/track_page.dart b/lib/features/track/track_page.dart index b5ad5b9..eb82279 100644 --- a/lib/features/track/track_page.dart +++ b/lib/features/track/track_page.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; import '../../data/meal_type.dart'; +import '../../data/quantity.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; import '../../widgets/log_portion_sheet.dart'; @@ -73,7 +74,8 @@ class TrackPage extends ConsumerWidget { trailing: Text( '${subtotal.round()} kcal', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: type.color, + fontWeight: FontWeight.w700, ), ), ), @@ -93,7 +95,11 @@ class TrackPage extends ConsumerWidget { onDismissed: (_) => ref.read(databaseProvider).deleteConsumption(entry.id), child: ListTile( - leading: Icon(type.icon), + leading: CircleAvatar( + radius: 18, + backgroundColor: type.tint, + child: Icon(type.icon, size: 20, color: type.color), + ), title: Text(entry.name), subtitle: entry.grams == null ? null @@ -175,7 +181,9 @@ class TrackPage extends ConsumerWidget { context, foodName: product.name, kcalPer100g: product.kcalPer100g, - defaultGrams: product.servingGrams ?? 100, + packageGrams: + parsePackageGrams(product.quantity) ?? product.servingGrams, + packageLabel: product.quantity, ); if (result == null) return; await ref @@ -227,23 +235,25 @@ class TrackPage extends ConsumerWidget { ), ); if (item == null || !context.mounted) return; - final result = await showLogPortionSheet( - context, - foodName: item.name, - kcalPer100g: item.kcalPer100g, - ); + final result = await showEatPantryItemSheet(context, item); if (result == null) return; - await ref - .read(databaseProvider) - .logConsumption( - ConsumptionEntriesCompanion.insert( - name: item.name, - kcal: result.kcal, - mealType: result.mealType.value, - pantryItemId: Value(item.id), - grams: Value(result.grams), - ), - ); + final db = ref.read(databaseProvider); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: item.name, + kcal: result.kcal, + mealType: result.mealType.value, + pantryItemId: Value(item.id), + grams: Value(result.grams), + ), + ); + // The slider estimates what was eaten, so keep the pantry stock in sync. + await db.updatePantryItem( + item.id, + PantryItemsCompanion( + amountLeft: Value((item.amountLeft - result.fraction).clamp(0.0, 1.0)), + ), + ); } Future _quickAdd(BuildContext context, WidgetRef ref) async { @@ -289,6 +299,7 @@ class _KcalHeader extends StatelessWidget { progress: goal == 0 ? 0 : consumed / goal, background: scheme.surfaceContainerHighest, foreground: over ? scheme.error : scheme.primary, + foregroundEnd: over ? scheme.error : scheme.tertiary, ), child: Center( child: Column( @@ -343,11 +354,13 @@ class _RingPainter extends CustomPainter { required this.progress, required this.background, required this.foreground, + required this.foregroundEnd, }); final double progress; final Color background; final Color foreground; + final Color foregroundEnd; @override void paint(Canvas canvas, Size size) { @@ -362,7 +375,12 @@ class _RingPainter extends CustomPainter { final foregroundPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = stroke - ..color = foreground + ..shader = SweepGradient( + startAngle: -math.pi / 2, + endAngle: 3 * math.pi / 2, + transform: const GradientRotation(-math.pi / 2), + colors: [foreground, foregroundEnd], + ).createShader(arcRect) ..strokeCap = StrokeCap.round; canvas.drawArc(arcRect, 0, 2 * math.pi, false, backgroundPaint); @@ -379,6 +397,7 @@ class _RingPainter extends CustomPainter { bool shouldRepaint(_RingPainter oldDelegate) => oldDelegate.progress != progress || oldDelegate.foreground != foreground || + oldDelegate.foregroundEnd != foregroundEnd || oldDelegate.background != background; } diff --git a/lib/providers.dart b/lib/providers.dart index 44d545c..6685cf7 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'data/db/database.dart'; -import 'data/services/anthropic_service.dart'; +import 'data/services/ai_service.dart'; import 'data/services/article_extractor.dart'; import 'data/services/off_service.dart'; import 'data/services/settings_service.dart'; @@ -34,7 +34,7 @@ class SettingsNotifier extends AsyncNotifier { Future setApiKey(String? key) async { await ref.read(settingsServiceProvider).setApiKey(key); - ref.invalidate(anthropicServiceProvider); + ref.invalidate(aiServiceProvider); state = await AsyncValue.guard( () => ref.read(settingsServiceProvider).load(), ); @@ -55,13 +55,13 @@ final settingsProvider = AsyncNotifierProvider( SettingsNotifier.new, ); -/// The Anthropic client, or null while no API key is configured. -final anthropicServiceProvider = FutureProvider((ref) async { +/// The OpenAI client, or null while no API key is configured. +final aiServiceProvider = FutureProvider((ref) async { // Re-created whenever settings change (invalidated on key updates). ref.watch(settingsProvider); final key = await ref.read(settingsServiceProvider).getApiKey(); if (key == null || key.isEmpty) return null; - return AnthropicService(apiKey: key); + return AiService(apiKey: key); }); // ---- Database streams ---- @@ -93,10 +93,10 @@ class MealSuggestionsNotifier AsyncValue>? build() => null; Future generate() async { - final anthropic = await ref.read(anthropicServiceProvider.future); - if (anthropic == null) { + final ai = await ref.read(aiServiceProvider.future); + if (ai == null) { state = AsyncValue.error( - AnthropicException('Add your Anthropic API key in settings first.'), + AiException('Add your OpenAI API key in settings first.'), StackTrace.current, ); return; @@ -110,7 +110,7 @@ class MealSuggestionsNotifier final remaining = settings.dailyKcalGoal - eaten; state = await AsyncValue.guard( - () => anthropic.suggestMeals( + () => ai.suggestMeals( pantry: [ for (final item in pantry) { diff --git a/lib/widgets/log_portion_sheet.dart b/lib/widgets/log_portion_sheet.dart index 7ffdc19..62c1276 100644 --- a/lib/widgets/log_portion_sheet.dart +++ b/lib/widgets/log_portion_sheet.dart @@ -1,26 +1,38 @@ import 'package:flutter/material.dart'; +import '../data/db/database.dart'; import '../data/meal_type.dart'; +import '../data/quantity.dart'; class LogPortionResult { const LogPortionResult({ required this.kcal, required this.mealType, + required this.fraction, this.grams, }); final double kcal; final MealType mealType; + + /// Fraction of the full package that was eaten (0..1). Callers tracking + /// stock subtract this from the item's amountLeft. + final double fraction; final double? grams; } -/// Bottom sheet asking for a portion size (grams) and meal type for a food -/// with known kcal/100 g. Pops with a [LogPortionResult]. +/// Bottom sheet asking "how much of it did you eat?" with a slider over the +/// whole package — nobody knows their portions to the gram. The part of the +/// package that was already consumed earlier is greyed out. Unit-counted +/// foods (eggs…) slide in units, everything else in percent. Future showLogPortionSheet( BuildContext context, { required String foodName, required double? kcalPer100g, - double? defaultGrams, + double? packageGrams, + int? unitCount, + double amountLeft = 1.0, + String? packageLabel, }) { return showModalBottomSheet( context: context, @@ -28,7 +40,10 @@ Future showLogPortionSheet( builder: (context) => _LogPortionSheet( foodName: foodName, kcalPer100g: kcalPer100g, - defaultGrams: defaultGrams, + packageGrams: packageGrams, + unitCount: (unitCount ?? 0) > 0 ? unitCount : null, + amountLeft: amountLeft.clamp(0.0, 1.0), + packageLabel: packageLabel, ), ); } @@ -37,54 +52,93 @@ class _LogPortionSheet extends StatefulWidget { const _LogPortionSheet({ required this.foodName, required this.kcalPer100g, - this.defaultGrams, + required this.packageGrams, + required this.unitCount, + required this.amountLeft, + required this.packageLabel, }); final String foodName; final double? kcalPer100g; - final double? defaultGrams; + final double? packageGrams; + final int? unitCount; + final double amountLeft; + final String? packageLabel; @override State<_LogPortionSheet> createState() => _LogPortionSheetState(); } class _LogPortionSheetState extends State<_LogPortionSheet> { - late final TextEditingController _gramsController; late final TextEditingController _kcalController; + late double _fraction; + bool _kcalEdited = false; MealType _mealType = MealType.suggestedNow(); + bool get _unitBased => widget.unitCount != null; + + int get _unitsLeft => _unitBased + ? (widget.amountLeft * widget.unitCount!).round().clamp( + 0, + widget.unitCount!, + ) + : 0; + @override void initState() { super.initState(); - final grams = widget.defaultGrams ?? 100; - _gramsController = TextEditingController(text: _trim(grams)); - _kcalController = TextEditingController( - text: widget.kcalPer100g == null - ? '' - : _trim(widget.kcalPer100g! * grams / 100), - ); - } - - static String _trim(double v) => - v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(1); - - void _recomputeKcal() { - final grams = double.tryParse(_gramsController.text.replaceAll(',', '.')); - if (grams != null && widget.kcalPer100g != null) { - _kcalController.text = _trim(widget.kcalPer100g! * grams / 100); - } + // Sensible starting bite: one unit, or a quarter of the package. + _fraction = _unitBased + ? (_unitsLeft == 0 ? 0.0 : 1.0 / widget.unitCount!) + : (widget.amountLeft < 0.25 ? widget.amountLeft : 0.25); + _kcalController = TextEditingController(text: _estimatedKcalText()); } @override void dispose() { - _gramsController.dispose(); _kcalController.dispose(); super.dispose(); } + double? _estimatedKcal() { + if (widget.packageGrams == null || widget.kcalPer100g == null) return null; + return widget.kcalPer100g! * widget.packageGrams! * _fraction / 100; + } + + String _estimatedKcalText() { + final kcal = _estimatedKcal(); + return kcal == null ? '' : kcal.round().toString(); + } + + double? _grams() { + if (widget.packageGrams == null) return null; + return widget.packageGrams! * _fraction; + } + + String _portionLabel() { + if (_unitBased) { + final units = (_fraction * widget.unitCount!).round(); + return '$units / ${widget.unitCount}'; + } + final percent = (_fraction * 100).round(); + final grams = _grams(); + return grams == null ? '$percent%' : '$percent% · ≈${grams.round()} g'; + } + + void _onSlider(double value) { + final max = _unitBased ? _unitsLeft / widget.unitCount! : widget.amountLeft; + setState(() { + _fraction = value.clamp(0.0, max); + if (!_kcalEdited) _kcalController.text = _estimatedKcalText(); + }); + } + @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final consumedBefore = widget.amountLeft < 1.0; + return Padding( padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), child: Column( @@ -92,39 +146,71 @@ class _LogPortionSheetState extends State<_LogPortionSheet> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(widget.foodName, style: Theme.of(context).textTheme.titleLarge), - if (widget.kcalPer100g != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '${widget.kcalPer100g!.round()} kcal / 100 g', - style: Theme.of(context).textTheme.bodyMedium, - ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + [ + if (widget.packageLabel != null) widget.packageLabel!, + if (widget.kcalPer100g != null) + '${widget.kcalPer100g!.round()} kcal / 100 g', + ].join(' · '), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), ), - const SizedBox(height: 20), + ), + const SizedBox(height: 16), Row( children: [ - Expanded( - child: TextField( - controller: _gramsController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration(labelText: 'Portion (g)'), - onChanged: (_) => _recomputeKcal(), - ), + Text( + 'How much did you eat?', + style: Theme.of(context).textTheme.labelLarge, ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _kcalController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration(labelText: 'kcal'), + const Spacer(), + Text( + _portionLabel(), + style: TextStyle( + fontWeight: FontWeight.w800, + color: scheme.primary, ), ), ], ), + // The track beyond amountLeft stays grey: that part of the package + // is already gone. + Slider( + value: _fraction, + max: 1.0, + divisions: _unitBased ? widget.unitCount : 20, + secondaryTrackValue: widget.amountLeft, + secondaryActiveColor: scheme.primaryContainer, + inactiveColor: scheme.onSurface.withValues(alpha: 0.28), + onChanged: _onSlider, + ), + if (consumedBefore) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _unitBased + ? 'Grey end of the track: units already used.' + : 'Grey end of the track: part already used.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _kcalController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'kcal', + helperText: _estimatedKcal() == null + ? 'No package data — enter your estimate' + : 'Estimated from the portion, adjust if needed', + ), + onChanged: (_) => _kcalEdited = true, + ), const SizedBox(height: 20), SegmentedButton( segments: [ @@ -139,7 +225,10 @@ class _LogPortionSheetState extends State<_LogPortionSheet> { Center( child: Text( _mealType.label, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _mealType.color, + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 16), @@ -150,14 +239,13 @@ class _LogPortionSheetState extends State<_LogPortionSheet> { final kcal = double.tryParse( _kcalController.text.replaceAll(',', '.'), ); - if (kcal == null) return; + if (kcal == null || _fraction <= 0) return; Navigator.of(context).pop( LogPortionResult( kcal: kcal, mealType: _mealType, - grams: double.tryParse( - _gramsController.text.replaceAll(',', '.'), - ), + fraction: _fraction, + grams: _grams(), ), ); }, @@ -167,3 +255,20 @@ class _LogPortionSheetState extends State<_LogPortionSheet> { ); } } + +/// Convenience for pantry items: opens the sheet pre-configured with the +/// item's package info and current stock. +Future showEatPantryItemSheet( + BuildContext context, + PantryItem item, +) { + return showLogPortionSheet( + context, + foodName: item.name, + kcalPer100g: item.kcalPer100g, + packageGrams: item.packageGrams, + unitCount: item.unitCount, + amountLeft: item.amountLeft, + packageLabel: item.packageQuantity, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 7b1c8a3..192f212 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,9 @@ dependencies: mobile_scanner: ^7.2.0 http: ^1.6.0 + # AI (OpenAI models via dart_openai) + dart_openai: ^6.1.1 + # Article synthesis (legacy feature) share_handler: ^0.0.25 html: ^0.15.6 diff --git a/test/app_test.dart b/test/app_test.dart index 8c2289e..d4d9322 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -1,9 +1,7 @@ -import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:readly/app/app.dart'; -import 'package:readly/data/db/database.dart'; import 'package:readly/data/services/settings_service.dart'; import 'package:readly/providers.dart'; @@ -34,8 +32,20 @@ class _FakeSettingsService extends SettingsService { void main() { testWidgets('app boots and all five tabs navigate', (tester) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - addTearDown(db.close); + // The database layer has its own tests (test/data/database_test.dart); + // here the streams are overridden so no real database is involved. + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsServiceProvider.overrideWithValue(_FakeSettingsService()), + pantryProvider.overrideWith((ref) => Stream.value(const [])), + todayEntriesProvider.overrideWith((ref) => Stream.value(const [])), + shoppingProvider.overrideWith((ref) => Stream.value(const [])), + articlesProvider.overrideWith((ref) => Stream.value(const [])), + ], + child: const ReadlyApp(), + ), + ); // Bounded pumps instead of pumpAndSettle: the app keeps light background // activity (font loading, platform channels) that never fully settles in @@ -45,15 +55,6 @@ void main() { await tester.pump(const Duration(seconds: 1)); } - await tester.pumpWidget( - ProviderScope( - overrides: [ - databaseProvider.overrideWithValue(db), - settingsServiceProvider.overrideWithValue(_FakeSettingsService()), - ], - child: const ReadlyApp(), - ), - ); await pump(); // Track tab is home. @@ -75,10 +76,6 @@ void main() { await tester.tap(find.byIcon(Icons.auto_stories_outlined)); await pump(); expect(find.text('No summaries yet'), findsOneWidget); - - // Flush any lingering timers (stream cleanup, snackbars, etc.) so the - // framework's end-of-test invariants pass. - await tester.pump(const Duration(minutes: 2)); }); test('extractUrl pulls the first link out of shared text', () { diff --git a/test/data/database_test.dart b/test/data/database_test.dart index 93338e0..710ac37 100644 --- a/test/data/database_test.dart +++ b/test/data/database_test.dart @@ -36,6 +36,30 @@ void main() { expect(await db.watchPantry().first, isEmpty); }); + test('pantry: newest items first, unit/perishable columns default', () async { + await db.addPantryItem( + PantryItemsCompanion.insert( + name: 'Old rice', + addedAt: Value(DateTime(2026, 1, 1)), + ), + ); + await db.addPantryItem( + PantryItemsCompanion.insert( + name: 'Fresh eggs', + unitCount: const Value(12), + perishable: const Value(true), + addedAt: Value(DateTime(2026, 7, 1)), + ), + ); + + final pantry = await db.watchPantry().first; + expect(pantry.map((i) => i.name), ['Fresh eggs', 'Old rice']); + expect(pantry.first.unitCount, 12); + expect(pantry.first.perishable, isTrue); + expect(pantry.last.unitCount, isNull); + expect(pantry.last.perishable, isFalse); + }); + test('consumption: only entries inside the window are returned', () async { final today = DateTime(2026, 7, 8, 12); final start = DateTime(2026, 7, 8); diff --git a/test/data/quantity_test.dart b/test/data/quantity_test.dart new file mode 100644 index 0000000..f65f372 --- /dev/null +++ b/test/data/quantity_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/db/database.dart'; +import 'package:readly/data/quantity.dart'; + +PantryItem _item({ + double? kcalPer100g, + String? packageQuantity, + int? unitCount, + double amountLeft = 1.0, +}) { + return PantryItem( + id: 1, + name: 'Test', + kcalPer100g: kcalPer100g, + packageQuantity: packageQuantity, + unitCount: unitCount, + perishable: false, + amountLeft: amountLeft, + addedAt: DateTime(2026), + updatedAt: DateTime(2026), + ); +} + +void main() { + group('parsePackageGrams', () { + test('parses common weight formats', () { + expect(parsePackageGrams('500 g'), 500); + expect(parsePackageGrams('500g'), 500); + expect(parsePackageGrams('1,5 kg'), 1500); + expect(parsePackageGrams('1.5 kg'), 1500); + // Multipacks: the first weight found wins (the per-pot size here). + expect(parsePackageGrams('6 x 125 g'), 125); + }); + + test('treats volumes as gram-equivalents', () { + expect(parsePackageGrams('1 L'), 1000); + expect(parsePackageGrams('33 cl'), 330); + expect(parsePackageGrams('250 ml'), 250); + }); + + test('returns null when unparseable', () { + expect(parsePackageGrams(null), isNull); + expect(parsePackageGrams('a dozen'), isNull); + expect(parsePackageGrams(''), isNull); + }); + }); + + group('PantryQuantity', () { + test('percent-based items format and detect consumption', () { + final item = _item(packageQuantity: '500 g', amountLeft: 0.75); + expect(item.isUnitBased, isFalse); + expect(item.amountLabel, '75%'); + expect(item.isConsumed, isFalse); + expect(_item(amountLeft: 0.0).isConsumed, isTrue); + }); + + test('unit-based items count in units', () { + final eggs = _item(unitCount: 12, amountLeft: 0.5); + expect(eggs.isUnitBased, isTrue); + expect(eggs.unitsLeft, 6); + expect(eggs.amountLabel, '6/12'); + expect(_item(unitCount: 12, amountLeft: 0.01).isConsumed, isTrue); + }); + + test('kcalForFraction estimates from package size', () { + final item = _item(kcalPer100g: 360, packageQuantity: '500 g'); + expect(item.kcalForFraction(0.5), 900); // 250 g of 360 kcal/100g + expect(_item(kcalPer100g: 360).kcalForFraction(0.5), isNull); + expect(_item(packageQuantity: '500 g').kcalForFraction(0.5), isNull); + }); + }); +} diff --git a/test/services/ai_service_test.dart b/test/services/ai_service_test.dart new file mode 100644 index 0000000..8c5c3b4 --- /dev/null +++ b/test/services/ai_service_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/services/ai_service.dart'; + +void main() { + group('extractJson', () { + test('parses a plain JSON object', () { + expect(AiService.extractJson('{"meals":[]}'), {'meals': []}); + }); + + test('strips markdown code fences', () { + expect(AiService.extractJson('```json\n{"items":[]}\n```'), { + 'items': [], + }); + }); + + test('tolerates prose around the JSON object', () { + expect( + AiService.extractJson('Sure! Here you go: {"items":[{"name":"x"}]}'), + { + 'items': [ + {'name': 'x'}, + ], + }, + ); + }); + + test('throws a readable error on empty or non-JSON answers', () { + expect(() => AiService.extractJson(''), throwsA(isA())); + expect( + () => AiService.extractJson('I cannot answer that.'), + throwsA(isA()), + ); + expect( + () => AiService.extractJson('{"broken": '), + throwsA(isA()), + ); + }); + }); + + test('MealSuggestion.fromJson maps every field', () { + final meal = MealSuggestion.fromJson({ + 'title': 'Omelette', + 'description': 'Fast protein.', + 'time_minutes': 10, + 'kcal': 350.5, + 'used_ingredients': ['eggs', 'cheese'], + 'missing_ingredients': ['chives'], + 'steps': ['Beat eggs', 'Cook'], + }); + expect(meal.title, 'Omelette'); + expect(meal.timeMinutes, 10); + expect(meal.kcal, 350.5); + expect(meal.usedIngredients, hasLength(2)); + expect(meal.missingIngredients, ['chives']); + expect(meal.steps, hasLength(2)); + }); + + test('GrocerySuggestion.fromJson maps name and reason', () { + final item = GrocerySuggestion.fromJson({ + 'name': 'Greek yogurt', + 'reason': 'Healthy snack base', + }); + expect(item.name, 'Greek yogurt'); + expect(item.reason, 'Healthy snack base'); + }); +} diff --git a/test/services/anthropic_service_test.dart b/test/services/anthropic_service_test.dart deleted file mode 100644 index 97c5376..0000000 --- a/test/services/anthropic_service_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:readly/data/services/anthropic_service.dart'; - -void main() { - group('parseSseTextDeltas', () { - Stream sse(List lines) => - Stream.fromIterable(lines.map((l) => '$l\n')); - - test('yields text deltas and ignores other events', () async { - final deltas = await AnthropicService.parseSseTextDeltas( - sse([ - 'event: message_start', - 'data: {"type":"message_start","message":{"id":"msg_1"}}', - '', - 'event: content_block_delta', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}', - 'event: content_block_delta', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}', - 'event: message_stop', - 'data: {"type":"message_stop"}', - ]), - ).toList(); - expect(deltas.join(), 'Hello world'); - }); - - test('ignores thinking deltas', () async { - final deltas = await AnthropicService.parseSseTextDeltas( - sse([ - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', - ]), - ).toList(); - expect(deltas, ['ok']); - }); - - test('throws on error events', () { - expect( - AnthropicService.parseSseTextDeltas( - sse([ - 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', - ]), - ).toList(), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Overloaded', - ), - ), - ); - }); - }); - - group('extractStructuredJson', () { - test('parses the JSON text block', () { - final body = jsonEncode({ - 'stop_reason': 'end_turn', - 'content': [ - {'type': 'text', 'text': '{"meals":[]}'}, - ], - }); - expect(AnthropicService.extractStructuredJson(body), { - 'meals': [], - }); - }); - - test('throws a readable error on refusal', () { - final body = jsonEncode({ - 'stop_reason': 'refusal', - 'content': [], - }); - expect( - () => AnthropicService.extractStructuredJson(body), - throwsA(isA()), - ); - }); - }); - - test('MealSuggestion.fromJson maps every field', () { - final meal = MealSuggestion.fromJson({ - 'title': 'Omelette', - 'description': 'Fast protein.', - 'time_minutes': 10, - 'kcal': 350.5, - 'used_ingredients': ['eggs', 'cheese'], - 'missing_ingredients': ['chives'], - 'steps': ['Beat eggs', 'Cook'], - }); - expect(meal.title, 'Omelette'); - expect(meal.timeMinutes, 10); - expect(meal.kcal, 350.5); - expect(meal.usedIngredients, hasLength(2)); - expect(meal.missingIngredients, ['chives']); - expect(meal.steps, hasLength(2)); - }); -} diff --git a/test/services/article_extractor_test.dart b/test/services/article_extractor_test.dart index 3d8fbb1..2467a91 100644 --- a/test/services/article_extractor_test.dart +++ b/test/services/article_extractor_test.dart @@ -2,6 +2,36 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:readly/data/services/article_extractor.dart'; void main() { + group('parseProxyResponse', () { + test('reads title and textContent from the readability proxy', () { + final article = ArticleExtractor.parseProxyResponse( + '{"title":"Big News","textContent":"The clean article text.",' + '"content":"

The clean article text.

"}', + fallbackTitle: 'https://example.com', + ); + expect(article.title, 'Big News'); + expect(article.text, 'The clean article text.'); + }); + + test('falls back to the URL when the title is missing', () { + final article = ArticleExtractor.parseProxyResponse( + '{"textContent":"Some text."}', + fallbackTitle: 'https://example.com/x', + ); + expect(article.title, 'https://example.com/x'); + }); + + test('throws when the proxy returns no text', () { + expect( + () => ArticleExtractor.parseProxyResponse( + '{"title":"Empty","textContent":""}', + fallbackTitle: 'u', + ), + throwsException, + ); + }); + }); + test('extracts title and paragraphs, ignoring scripts and navigation', () { const source = ''' From a8500cfc60626b1824ad01b59231b8b7f78573d8 Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 21:25:56 +0400 Subject: [PATCH 4/8] feat: enhance meal suggestion logic with additional parameters and improved pantry handling --- lib/app/theme.dart | 4 ++-- lib/data/services/ai_service.dart | 22 ++++++++++++++++----- lib/providers.dart | 33 ++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/app/theme.dart b/lib/app/theme.dart index 1390eb4..83b2070 100644 --- a/lib/app/theme.dart +++ b/lib/app/theme.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -/// Clean, rounded Material 3 theme with a fresh green seed. +/// Clean, rounded Material 3 theme seeded with a pale maroon / sepia tone. /// The vibrant variant keeps the sober look but lets secondary/tertiary /// accents actually show some color. ThemeData buildTheme(Brightness brightness) { final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF3E9B4F), + seedColor: const Color(0xFF96604A), brightness: brightness, dynamicSchemeVariant: DynamicSchemeVariant.vibrant, ); diff --git a/lib/data/services/ai_service.dart b/lib/data/services/ai_service.dart index 1a5b4c5..71e7784 100644 --- a/lib/data/services/ai_service.dart +++ b/lib/data/services/ai_service.dart @@ -123,10 +123,14 @@ class AiService { .handleError((Object e) => throw _wrap(e)); } - /// Asks for meal suggestions based on what is in the kitchen. + /// Asks for meal suggestions based on what is in the kitchen, how much of + /// it remains, and what was already eaten today. /// Uses structured outputs so the response is guaranteed valid JSON. Future> suggestMeals({ required List> pantry, + required List> eatenToday, + required double consumedKcal, + required double dailyGoalKcal, required double remainingKcal, required String language, }) async { @@ -135,11 +139,19 @@ class AiService { 'You are a pragmatic home-cooking assistant for a lazy person who is ' 'addicted to sugar and quick meals, and wants to eat healthier with ' 'minimal effort. Suggest simple, realistic meals that mostly use ' - 'what is already in the kitchen. Prefer few steps and short cooking ' - 'times. Answer in $language, as JSON.', + 'what is already in the kitchen, in quantities that respect what is ' + 'actually left. Give priority to perishable ingredients so nothing ' + 'goes to waste. Balance the rest of the day nutritionally against ' + 'what was already eaten (e.g. lighter and more vegetables after a ' + 'heavy or sugary day). Prefer few steps and short cooking times. ' + 'Answer in $language, as JSON.', userContent: - 'Here is my kitchen inventory as JSON (amount_left_percent is how ' - 'much of the package remains):\n${jsonEncode(pantry)}\n\n' + 'Here is my kitchen inventory as JSON (amount_left_percent or ' + 'units_left say how much of each package remains; perishable items ' + 'should be used first):\n${jsonEncode(pantry)}\n\n' + 'What I already ate today (${consumedKcal.round()} kcal consumed of ' + 'my ${dailyGoalKcal.round()} kcal daily goal):\n' + '${eatenToday.isEmpty ? 'nothing yet' : jsonEncode(eatenToday)}\n\n' 'I have about ${remainingKcal.round()} kcal left for today. ' 'Suggest exactly 3 healthy, low-effort meals I can make right now. ' 'Only list an ingredient in missing_ingredients if I truly need to ' diff --git a/lib/providers.dart b/lib/providers.dart index 6685cf7..17b3adc 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'data/db/database.dart'; +import 'data/quantity.dart'; import 'data/services/ai_service.dart'; import 'data/services/article_extractor.dart'; import 'data/services/off_service.dart'; @@ -34,7 +35,9 @@ class SettingsNotifier extends AsyncNotifier { Future setApiKey(String? key) async { await ref.read(settingsServiceProvider).setApiKey(key); - ref.invalidate(aiServiceProvider); + // No explicit invalidate: aiServiceProvider watches this provider, so + // publishing the new state below rebuilds it (invalidating from inside + // the watched notifier trips riverpod's circular-dependency check). state = await AsyncValue.guard( () => ref.read(settingsServiceProvider).load(), ); @@ -113,15 +116,31 @@ class MealSuggestionsNotifier () => ai.suggestMeals( pantry: [ for (final item in pantry) + if (!item.isConsumed) + { + 'name': item.name, + if (item.brand != null) 'brand': item.brand, + if (item.isUnitBased) ...{ + 'units_left': item.unitsLeft, + 'units_per_package': item.unitCount, + } else + 'amount_left_percent': (item.amountLeft * 100).round(), + 'perishable': item.perishable, + if (item.kcalPer100g != null) 'kcal_per_100g': item.kcalPer100g, + if (item.packageQuantity != null) + 'package_size': item.packageQuantity, + }, + ], + eatenToday: [ + for (final entry in entries) { - 'name': item.name, - if (item.brand != null) 'brand': item.brand, - 'amount_left_percent': (item.amountLeft * 100).round(), - if (item.kcalPer100g != null) 'kcal_per_100g': item.kcalPer100g, - if (item.packageQuantity != null) - 'package_size': item.packageQuantity, + 'name': entry.name, + 'kcal': entry.kcal.round(), + 'meal': entry.mealType, }, ], + consumedKcal: eaten, + dailyGoalKcal: settings.dailyKcalGoal.toDouble(), remainingKcal: remaining.clamp(300, 4000).toDouble(), language: settings.language, ), From b8841eb78afa25398b558cddbc04bce003517b8c Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 21:43:45 +0400 Subject: [PATCH 5/8] feat: enhance meal suggestions and pantry integration - Introduced saved meals provider to persist meal suggestions across app restarts. - Updated MealsPage to utilize saved meals and reflect their status (done/undone). - Modified MealSuggestionsNotifier to replace saved meals in the database after generation. - Implemented functionality to log meals and update pantry quantities based on used ingredients. - Added a new UI component for updating kitchen quantities after meal preparation. - Created tests for saved meals replacement and pantry item matching logic. --- lib/data/db/database.dart | 52 +- lib/data/db/database.g.dart | 913 +++++++++++++++++++++++++ lib/features/meals/meals_page.dart | 252 ++++++- lib/providers.dart | 44 +- test/data/database_test.dart | 27 + test/features/meals_matching_test.dart | 35 + 6 files changed, 1280 insertions(+), 43 deletions(-) create mode 100644 test/features/meals_matching_test.dart diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index fe32ee0..7f6fca2 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -57,6 +57,26 @@ class ShoppingItems extends Table { DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); } +/// AI-generated meal suggestions, persisted so they survive app restarts. +/// Replaced wholesale on each new generation. +@DataClassName('SavedMeal') +class SavedMeals extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get title => text()(); + TextColumn get description => text()(); + IntColumn get timeMinutes => integer()(); + RealColumn get kcal => real()(); + + /// JSON-encoded list of ingredient names. + TextColumn get usedIngredients => text()(); + TextColumn get missingIngredients => text()(); + TextColumn get steps => text()(); + + /// Set once the user tapped "I made it". + BoolColumn get done => boolean().withDefault(const Constant(false))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} + class Articles extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get url => text()(); @@ -66,7 +86,13 @@ class Articles extends Table { } @DriftDatabase( - tables: [PantryItems, ConsumptionEntries, ShoppingItems, Articles], + tables: [ + PantryItems, + ConsumptionEntries, + ShoppingItems, + SavedMeals, + Articles, + ], ) class AppDatabase extends _$AppDatabase { AppDatabase() : super(driftDatabase(name: 'readly')); @@ -74,7 +100,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.e); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -83,6 +109,9 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(pantryItems, pantryItems.unitCount); await m.addColumn(pantryItems, pantryItems.perishable); } + if (from < 3) { + await m.createTable(savedMeals); + } }, ); @@ -155,6 +184,25 @@ class AppDatabase extends _$AppDatabase { Future clearDoneShopping() => (delete(shoppingItems)..where((t) => t.done.equals(true))).go(); + // ---- Saved meal suggestions ---- + + Stream> watchSavedMeals() => + (select(savedMeals)..orderBy([(t) => OrderingTerm.asc(t.id)])).watch(); + + /// A new generation replaces the previous batch entirely. + Future replaceSavedMeals(List meals) => + transaction(() async { + await delete(savedMeals).go(); + for (final meal in meals) { + await into(savedMeals).insert(meal); + } + }); + + Future setSavedMealDone(int id) => + (update(savedMeals)..where((t) => t.id.equals(id))).write( + const SavedMealsCompanion(done: Value(true)), + ); + // ---- Articles ---- Stream> watchArticles() => (select( diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index 6b92ea3..5465f5d 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -1772,6 +1772,621 @@ class ShoppingItemsCompanion extends UpdateCompanion { } } +class $SavedMealsTable extends SavedMeals + with TableInfo<$SavedMealsTable, SavedMeal> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SavedMealsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _timeMinutesMeta = const VerificationMeta( + 'timeMinutes', + ); + @override + late final GeneratedColumn timeMinutes = GeneratedColumn( + 'time_minutes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _kcalMeta = const VerificationMeta('kcal'); + @override + late final GeneratedColumn kcal = GeneratedColumn( + 'kcal', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _usedIngredientsMeta = const VerificationMeta( + 'usedIngredients', + ); + @override + late final GeneratedColumn usedIngredients = GeneratedColumn( + 'used_ingredients', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _missingIngredientsMeta = + const VerificationMeta('missingIngredients'); + @override + late final GeneratedColumn missingIngredients = + GeneratedColumn( + 'missing_ingredients', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _stepsMeta = const VerificationMeta('steps'); + @override + late final GeneratedColumn steps = GeneratedColumn( + 'steps', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _doneMeta = const VerificationMeta('done'); + @override + late final GeneratedColumn done = GeneratedColumn( + 'done', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("done" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + title, + description, + timeMinutes, + kcal, + usedIngredients, + missingIngredients, + steps, + done, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'saved_meals'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } else if (isInserting) { + context.missing(_descriptionMeta); + } + if (data.containsKey('time_minutes')) { + context.handle( + _timeMinutesMeta, + timeMinutes.isAcceptableOrUnknown( + data['time_minutes']!, + _timeMinutesMeta, + ), + ); + } else if (isInserting) { + context.missing(_timeMinutesMeta); + } + if (data.containsKey('kcal')) { + context.handle( + _kcalMeta, + kcal.isAcceptableOrUnknown(data['kcal']!, _kcalMeta), + ); + } else if (isInserting) { + context.missing(_kcalMeta); + } + if (data.containsKey('used_ingredients')) { + context.handle( + _usedIngredientsMeta, + usedIngredients.isAcceptableOrUnknown( + data['used_ingredients']!, + _usedIngredientsMeta, + ), + ); + } else if (isInserting) { + context.missing(_usedIngredientsMeta); + } + if (data.containsKey('missing_ingredients')) { + context.handle( + _missingIngredientsMeta, + missingIngredients.isAcceptableOrUnknown( + data['missing_ingredients']!, + _missingIngredientsMeta, + ), + ); + } else if (isInserting) { + context.missing(_missingIngredientsMeta); + } + if (data.containsKey('steps')) { + context.handle( + _stepsMeta, + steps.isAcceptableOrUnknown(data['steps']!, _stepsMeta), + ); + } else if (isInserting) { + context.missing(_stepsMeta); + } + if (data.containsKey('done')) { + context.handle( + _doneMeta, + done.isAcceptableOrUnknown(data['done']!, _doneMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SavedMeal map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SavedMeal( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + timeMinutes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}time_minutes'], + )!, + kcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}kcal'], + )!, + usedIngredients: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}used_ingredients'], + )!, + missingIngredients: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}missing_ingredients'], + )!, + steps: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}steps'], + )!, + done: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}done'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $SavedMealsTable createAlias(String alias) { + return $SavedMealsTable(attachedDatabase, alias); + } +} + +class SavedMeal extends DataClass implements Insertable { + final int id; + final String title; + final String description; + final int timeMinutes; + final double kcal; + + /// JSON-encoded list of ingredient names. + final String usedIngredients; + final String missingIngredients; + final String steps; + + /// Set once the user tapped "I made it". + final bool done; + final DateTime createdAt; + const SavedMeal({ + required this.id, + required this.title, + required this.description, + required this.timeMinutes, + required this.kcal, + required this.usedIngredients, + required this.missingIngredients, + required this.steps, + required this.done, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['description'] = Variable(description); + map['time_minutes'] = Variable(timeMinutes); + map['kcal'] = Variable(kcal); + map['used_ingredients'] = Variable(usedIngredients); + map['missing_ingredients'] = Variable(missingIngredients); + map['steps'] = Variable(steps); + map['done'] = Variable(done); + map['created_at'] = Variable(createdAt); + return map; + } + + SavedMealsCompanion toCompanion(bool nullToAbsent) { + return SavedMealsCompanion( + id: Value(id), + title: Value(title), + description: Value(description), + timeMinutes: Value(timeMinutes), + kcal: Value(kcal), + usedIngredients: Value(usedIngredients), + missingIngredients: Value(missingIngredients), + steps: Value(steps), + done: Value(done), + createdAt: Value(createdAt), + ); + } + + factory SavedMeal.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SavedMeal( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + timeMinutes: serializer.fromJson(json['timeMinutes']), + kcal: serializer.fromJson(json['kcal']), + usedIngredients: serializer.fromJson(json['usedIngredients']), + missingIngredients: serializer.fromJson( + json['missingIngredients'], + ), + steps: serializer.fromJson(json['steps']), + done: serializer.fromJson(json['done']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'timeMinutes': serializer.toJson(timeMinutes), + 'kcal': serializer.toJson(kcal), + 'usedIngredients': serializer.toJson(usedIngredients), + 'missingIngredients': serializer.toJson(missingIngredients), + 'steps': serializer.toJson(steps), + 'done': serializer.toJson(done), + 'createdAt': serializer.toJson(createdAt), + }; + } + + SavedMeal copyWith({ + int? id, + String? title, + String? description, + int? timeMinutes, + double? kcal, + String? usedIngredients, + String? missingIngredients, + String? steps, + bool? done, + DateTime? createdAt, + }) => SavedMeal( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + timeMinutes: timeMinutes ?? this.timeMinutes, + kcal: kcal ?? this.kcal, + usedIngredients: usedIngredients ?? this.usedIngredients, + missingIngredients: missingIngredients ?? this.missingIngredients, + steps: steps ?? this.steps, + done: done ?? this.done, + createdAt: createdAt ?? this.createdAt, + ); + SavedMeal copyWithCompanion(SavedMealsCompanion data) { + return SavedMeal( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + description: data.description.present + ? data.description.value + : this.description, + timeMinutes: data.timeMinutes.present + ? data.timeMinutes.value + : this.timeMinutes, + kcal: data.kcal.present ? data.kcal.value : this.kcal, + usedIngredients: data.usedIngredients.present + ? data.usedIngredients.value + : this.usedIngredients, + missingIngredients: data.missingIngredients.present + ? data.missingIngredients.value + : this.missingIngredients, + steps: data.steps.present ? data.steps.value : this.steps, + done: data.done.present ? data.done.value : this.done, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('SavedMeal(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('timeMinutes: $timeMinutes, ') + ..write('kcal: $kcal, ') + ..write('usedIngredients: $usedIngredients, ') + ..write('missingIngredients: $missingIngredients, ') + ..write('steps: $steps, ') + ..write('done: $done, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + description, + timeMinutes, + kcal, + usedIngredients, + missingIngredients, + steps, + done, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SavedMeal && + other.id == this.id && + other.title == this.title && + other.description == this.description && + other.timeMinutes == this.timeMinutes && + other.kcal == this.kcal && + other.usedIngredients == this.usedIngredients && + other.missingIngredients == this.missingIngredients && + other.steps == this.steps && + other.done == this.done && + other.createdAt == this.createdAt); +} + +class SavedMealsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value description; + final Value timeMinutes; + final Value kcal; + final Value usedIngredients; + final Value missingIngredients; + final Value steps; + final Value done; + final Value createdAt; + const SavedMealsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.timeMinutes = const Value.absent(), + this.kcal = const Value.absent(), + this.usedIngredients = const Value.absent(), + this.missingIngredients = const Value.absent(), + this.steps = const Value.absent(), + this.done = const Value.absent(), + this.createdAt = const Value.absent(), + }); + SavedMealsCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String description, + required int timeMinutes, + required double kcal, + required String usedIngredients, + required String missingIngredients, + required String steps, + this.done = const Value.absent(), + this.createdAt = const Value.absent(), + }) : title = Value(title), + description = Value(description), + timeMinutes = Value(timeMinutes), + kcal = Value(kcal), + usedIngredients = Value(usedIngredients), + missingIngredients = Value(missingIngredients), + steps = Value(steps); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? description, + Expression? timeMinutes, + Expression? kcal, + Expression? usedIngredients, + Expression? missingIngredients, + Expression? steps, + Expression? done, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (timeMinutes != null) 'time_minutes': timeMinutes, + if (kcal != null) 'kcal': kcal, + if (usedIngredients != null) 'used_ingredients': usedIngredients, + if (missingIngredients != null) 'missing_ingredients': missingIngredients, + if (steps != null) 'steps': steps, + if (done != null) 'done': done, + if (createdAt != null) 'created_at': createdAt, + }); + } + + SavedMealsCompanion copyWith({ + Value? id, + Value? title, + Value? description, + Value? timeMinutes, + Value? kcal, + Value? usedIngredients, + Value? missingIngredients, + Value? steps, + Value? done, + Value? createdAt, + }) { + return SavedMealsCompanion( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + timeMinutes: timeMinutes ?? this.timeMinutes, + kcal: kcal ?? this.kcal, + usedIngredients: usedIngredients ?? this.usedIngredients, + missingIngredients: missingIngredients ?? this.missingIngredients, + steps: steps ?? this.steps, + done: done ?? this.done, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (timeMinutes.present) { + map['time_minutes'] = Variable(timeMinutes.value); + } + if (kcal.present) { + map['kcal'] = Variable(kcal.value); + } + if (usedIngredients.present) { + map['used_ingredients'] = Variable(usedIngredients.value); + } + if (missingIngredients.present) { + map['missing_ingredients'] = Variable(missingIngredients.value); + } + if (steps.present) { + map['steps'] = Variable(steps.value); + } + if (done.present) { + map['done'] = Variable(done.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SavedMealsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('timeMinutes: $timeMinutes, ') + ..write('kcal: $kcal, ') + ..write('usedIngredients: $usedIngredients, ') + ..write('missingIngredients: $missingIngredients, ') + ..write('steps: $steps, ') + ..write('done: $done, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + class $ArticlesTable extends Articles with TableInfo<$ArticlesTable, Article> { @override final GeneratedDatabase attachedDatabase; @@ -2118,6 +2733,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ConsumptionEntriesTable consumptionEntries = $ConsumptionEntriesTable(this); late final $ShoppingItemsTable shoppingItems = $ShoppingItemsTable(this); + late final $SavedMealsTable savedMeals = $SavedMealsTable(this); late final $ArticlesTable articles = $ArticlesTable(this); @override Iterable> get allTables => @@ -2127,6 +2743,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { pantryItems, consumptionEntries, shoppingItems, + savedMeals, articles, ]; } @@ -3010,6 +3627,300 @@ typedef $$ShoppingItemsTableProcessedTableManager = ShoppingItem, PrefetchHooks Function() >; +typedef $$SavedMealsTableCreateCompanionBuilder = + SavedMealsCompanion Function({ + Value id, + required String title, + required String description, + required int timeMinutes, + required double kcal, + required String usedIngredients, + required String missingIngredients, + required String steps, + Value done, + Value createdAt, + }); +typedef $$SavedMealsTableUpdateCompanionBuilder = + SavedMealsCompanion Function({ + Value id, + Value title, + Value description, + Value timeMinutes, + Value kcal, + Value usedIngredients, + Value missingIngredients, + Value steps, + Value done, + Value createdAt, + }); + +class $$SavedMealsTableFilterComposer + extends Composer<_$AppDatabase, $SavedMealsTable> { + $$SavedMealsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timeMinutes => $composableBuilder( + column: $table.timeMinutes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get usedIngredients => $composableBuilder( + column: $table.usedIngredients, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get missingIngredients => $composableBuilder( + column: $table.missingIngredients, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get steps => $composableBuilder( + column: $table.steps, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get done => $composableBuilder( + column: $table.done, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SavedMealsTableOrderingComposer + extends Composer<_$AppDatabase, $SavedMealsTable> { + $$SavedMealsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timeMinutes => $composableBuilder( + column: $table.timeMinutes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get usedIngredients => $composableBuilder( + column: $table.usedIngredients, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get missingIngredients => $composableBuilder( + column: $table.missingIngredients, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get steps => $composableBuilder( + column: $table.steps, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get done => $composableBuilder( + column: $table.done, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SavedMealsTableAnnotationComposer + extends Composer<_$AppDatabase, $SavedMealsTable> { + $$SavedMealsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get timeMinutes => $composableBuilder( + column: $table.timeMinutes, + builder: (column) => column, + ); + + GeneratedColumn get kcal => + $composableBuilder(column: $table.kcal, builder: (column) => column); + + GeneratedColumn get usedIngredients => $composableBuilder( + column: $table.usedIngredients, + builder: (column) => column, + ); + + GeneratedColumn get missingIngredients => $composableBuilder( + column: $table.missingIngredients, + builder: (column) => column, + ); + + GeneratedColumn get steps => + $composableBuilder(column: $table.steps, builder: (column) => column); + + GeneratedColumn get done => + $composableBuilder(column: $table.done, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$SavedMealsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SavedMealsTable, + SavedMeal, + $$SavedMealsTableFilterComposer, + $$SavedMealsTableOrderingComposer, + $$SavedMealsTableAnnotationComposer, + $$SavedMealsTableCreateCompanionBuilder, + $$SavedMealsTableUpdateCompanionBuilder, + ( + SavedMeal, + BaseReferences<_$AppDatabase, $SavedMealsTable, SavedMeal>, + ), + SavedMeal, + PrefetchHooks Function() + > { + $$SavedMealsTableTableManager(_$AppDatabase db, $SavedMealsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SavedMealsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SavedMealsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SavedMealsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value timeMinutes = const Value.absent(), + Value kcal = const Value.absent(), + Value usedIngredients = const Value.absent(), + Value missingIngredients = const Value.absent(), + Value steps = const Value.absent(), + Value done = const Value.absent(), + Value createdAt = const Value.absent(), + }) => SavedMealsCompanion( + id: id, + title: title, + description: description, + timeMinutes: timeMinutes, + kcal: kcal, + usedIngredients: usedIngredients, + missingIngredients: missingIngredients, + steps: steps, + done: done, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required String description, + required int timeMinutes, + required double kcal, + required String usedIngredients, + required String missingIngredients, + required String steps, + Value done = const Value.absent(), + Value createdAt = const Value.absent(), + }) => SavedMealsCompanion.insert( + id: id, + title: title, + description: description, + timeMinutes: timeMinutes, + kcal: kcal, + usedIngredients: usedIngredients, + missingIngredients: missingIngredients, + steps: steps, + done: done, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SavedMealsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SavedMealsTable, + SavedMeal, + $$SavedMealsTableFilterComposer, + $$SavedMealsTableOrderingComposer, + $$SavedMealsTableAnnotationComposer, + $$SavedMealsTableCreateCompanionBuilder, + $$SavedMealsTableUpdateCompanionBuilder, + (SavedMeal, BaseReferences<_$AppDatabase, $SavedMealsTable, SavedMeal>), + SavedMeal, + PrefetchHooks Function() + >; typedef $$ArticlesTableCreateCompanionBuilder = ArticlesCompanion Function({ Value id, @@ -3208,6 +4119,8 @@ class $AppDatabaseManager { $$ConsumptionEntriesTableTableManager(_db, _db.consumptionEntries); $$ShoppingItemsTableTableManager get shoppingItems => $$ShoppingItemsTableTableManager(_db, _db.shoppingItems); + $$SavedMealsTableTableManager get savedMeals => + $$SavedMealsTableTableManager(_db, _db.savedMeals); $$ArticlesTableTableManager get articles => $$ArticlesTableTableManager(_db, _db.articles); } diff --git a/lib/features/meals/meals_page.dart b/lib/features/meals/meals_page.dart index bc37612..cf4ae1c 100644 --- a/lib/features/meals/meals_page.dart +++ b/lib/features/meals/meals_page.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,16 +7,22 @@ import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; import '../../data/meal_type.dart'; +import '../../data/quantity.dart'; import '../../data/services/ai_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; +import '../kitchen/kitchen_page.dart' show amountLeftColor; + +List _decodeList(String json) => + (jsonDecode(json) as List).cast(); class MealsPage extends ConsumerWidget { const MealsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final suggestions = ref.watch(mealSuggestionsProvider); + final generation = ref.watch(mealSuggestionsProvider); + final meals = ref.watch(savedMealsProvider).value ?? []; final hasApiKey = ref.watch(settingsProvider).value?.hasApiKey ?? false; return Scaffold( @@ -27,8 +35,7 @@ class MealsPage extends ConsumerWidget { ), ], ), - body: switch (suggestions) { - null => _Idle(hasApiKey: hasApiKey), + body: switch (generation) { AsyncLoading() => const _Loading(), AsyncError(:final error) => EmptyState( icon: Icons.error_outline, @@ -40,7 +47,11 @@ class MealsPage extends ConsumerWidget { child: const Text('Try again'), ), ), - AsyncValue(:final value?) => _SuggestionList(meals: value), + // Idle or done: the saved batch (persisted across restarts) rules. + _ => + meals.isEmpty + ? _Idle(hasApiKey: hasApiKey) + : _SuggestionList(meals: meals), }, ); } @@ -108,7 +119,7 @@ class _Loading extends StatelessWidget { class _SuggestionList extends ConsumerWidget { const _SuggestionList({required this.meals}); - final List meals; + final List meals; @override Widget build(BuildContext context, WidgetRef ref) { @@ -134,11 +145,15 @@ class _SuggestionList extends ConsumerWidget { class _MealCard extends ConsumerWidget { const _MealCard({required this.meal}); - final MealSuggestion meal; + final SavedMeal meal; @override Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; + final usedIngredients = _decodeList(meal.usedIngredients); + final missingIngredients = _decodeList(meal.missingIngredients); + final steps = _decodeList(meal.steps); + return Card( child: Padding( padding: const EdgeInsets.all(20), @@ -183,7 +198,7 @@ class _MealCard extends ConsumerWidget { ), ], ), - if (meal.usedIngredients.isNotEmpty) ...[ + if (usedIngredients.isNotEmpty) ...[ const SizedBox(height: 12), Text( 'From your kitchen', @@ -191,11 +206,11 @@ class _MealCard extends ConsumerWidget { ), const SizedBox(height: 4), Text( - meal.usedIngredients.join(' · '), + usedIngredients.join(' · '), style: TextStyle(color: scheme.onSurfaceVariant), ), ], - if (meal.missingIngredients.isNotEmpty) ...[ + if (missingIngredients.isNotEmpty) ...[ const SizedBox(height: 12), Text( 'Missing — tap to add to groceries', @@ -206,7 +221,7 @@ class _MealCard extends ConsumerWidget { spacing: 8, runSpacing: 8, children: [ - for (final ingredient in meal.missingIngredients) + for (final ingredient in missingIngredients) ActionChip( avatar: const Icon(Icons.add_shopping_cart, size: 18), label: Text(ingredient), @@ -216,7 +231,7 @@ class _MealCard extends ConsumerWidget { ], ), ], - if (meal.steps.isNotEmpty) ...[ + if (steps.isNotEmpty) ...[ const SizedBox(height: 8), ExpansionTile( tilePadding: EdgeInsets.zero, @@ -226,7 +241,7 @@ class _MealCard extends ConsumerWidget { style: Theme.of(context).textTheme.labelLarge, ), children: [ - for (final (index, step) in meal.steps.indexed) + for (final (index, step) in steps.indexed) ListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -242,11 +257,17 @@ class _MealCard extends ConsumerWidget { const SizedBox(height: 8), Align( alignment: Alignment.centerRight, - child: FilledButton.tonalIcon( - icon: const Icon(Icons.check), - label: const Text('I made it'), - onPressed: () => _logMeal(context, ref), - ), + child: meal.done + ? FilledButton.tonalIcon( + icon: Icon(Icons.check_circle, color: scheme.primary), + label: const Text('Already done'), + onPressed: null, + ) + : FilledButton.tonalIcon( + icon: const Icon(Icons.check), + label: const Text('I made it'), + onPressed: () => _logMeal(context, ref, usedIngredients), + ), ), ], ), @@ -275,7 +296,11 @@ class _MealCard extends ConsumerWidget { } } - Future _logMeal(BuildContext context, WidgetRef ref) async { + Future _logMeal( + BuildContext context, + WidgetRef ref, + List usedIngredients, + ) async { final type = await showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -291,7 +316,7 @@ class _MealCard extends ConsumerWidget { ), for (final type in MealType.values) ListTile( - leading: Icon(type.icon), + leading: Icon(type.icon, color: type.color), title: Text(type.label), onTap: () => Navigator.pop(sheetContext, type), ), @@ -300,19 +325,188 @@ class _MealCard extends ConsumerWidget { ), ); if (type == null) return; - await ref - .read(databaseProvider) - .logConsumption( - ConsumptionEntriesCompanion.insert( - name: meal.title, - kcal: meal.kcal, - mealType: type.value, - ), - ); - if (context.mounted) { + final db = ref.read(databaseProvider); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: meal.title, + kcal: meal.kcal, + mealType: type.value, + ), + ); + await db.setSavedMealDone(meal.id); + + // Cooking used up ingredients: offer to adjust the kitchen quantities. + if (!context.mounted) return; + final pantry = await ref.read(pantryProvider.future); + final used = matchUsedPantryItems(pantry, usedIngredients); + if (!context.mounted) return; + if (used.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Logged ${meal.kcal.round()} kcal. Enjoy!')), ); + return; } + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _UpdateKitchenSheet(mealTitle: meal.title, items: used), + ); + } +} + +/// Fuzzy-matches the AI's free-text ingredient names against pantry items. +/// Exposed for testing. +List matchUsedPantryItems( + List pantry, + List ingredients, +) { + Set words(String s) => s + .toLowerCase() + .split(RegExp(r'[^a-zà-ÿ0-9]+')) + .where((w) => w.length >= 3) + .toSet(); + + bool matches(PantryItem item, String ingredient) { + final name = item.name.toLowerCase(); + final ing = ingredient.toLowerCase(); + if (name.contains(ing) || ing.contains(name)) return true; + return words(item.name).intersection(words(ingredient)).isNotEmpty; + } + + return [ + for (final item in pantry) + if (!item.isConsumed && ingredients.any((i) => matches(item, i))) item, + ]; +} + +/// After "I made it": sliders to estimate what is left of each ingredient +/// the meal used. +class _UpdateKitchenSheet extends ConsumerStatefulWidget { + const _UpdateKitchenSheet({required this.mealTitle, required this.items}); + + final String mealTitle; + final List items; + + @override + ConsumerState<_UpdateKitchenSheet> createState() => + _UpdateKitchenSheetState(); +} + +class _UpdateKitchenSheetState extends ConsumerState<_UpdateKitchenSheet> { + late final Map _amounts = { + for (final item in widget.items) item.id: item.amountLeft, + }; + + Future _save() async { + final db = ref.read(databaseProvider); + for (final item in widget.items) { + final amount = _amounts[item.id]!; + if (amount != item.amountLeft) { + await db.updatePantryItem( + item.id, + PantryItemsCompanion(amountLeft: Value(amount)), + ); + } + } + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Update your kitchen', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'Estimate what is left of the ingredients "${widget.mealTitle}" ' + 'used.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 8), + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final item in widget.items) + _AmountRow( + item: item, + amount: _amounts[item.id]!, + onChanged: (v) => setState(() => _amounts[item.id] = v), + ), + ], + ), + ), + const SizedBox(height: 8), + FilledButton.icon( + icon: const Icon(Icons.check), + label: const Text('Save quantities'), + onPressed: _save, + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Skip'), + ), + ], + ), + ), + ); + } +} + +class _AmountRow extends StatelessWidget { + const _AmountRow({ + required this.item, + required this.amount, + required this.onChanged, + }); + + final PantryItem item; + final double amount; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = amountLeftColor(amount, scheme); + final label = item.isUnitBased + ? '${(amount * item.unitCount!).round()}/${item.unitCount}' + : '${(amount * 100).round()}%'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.name, style: Theme.of(context).textTheme.titleSmall), + Row( + children: [ + Expanded( + child: Slider( + value: amount, + divisions: item.isUnitBased ? item.unitCount : 20, + activeColor: color, + onChanged: onChanged, + ), + ), + SizedBox( + width: 56, + child: Text( + label, + textAlign: TextAlign.end, + style: TextStyle(fontWeight: FontWeight.w700, color: color), + ), + ), + ], + ), + ], + ); } } diff --git a/lib/providers.dart b/lib/providers.dart index 17b3adc..7bfb39b 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'data/db/database.dart'; @@ -88,12 +90,19 @@ final articlesProvider = StreamProvider>( (ref) => ref.watch(databaseProvider).watchArticles(), ); -// ---- AI results kept in memory ---- +/// The last generated meal suggestions, persisted in the database so they +/// survive app restarts. +final savedMealsProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchSavedMeals(), +); + +// ---- AI generation state ---- -class MealSuggestionsNotifier - extends Notifier>?> { +/// Tracks only the in-flight generation (loading/error); the resulting meals +/// live in the database (see [savedMealsProvider]). +class MealSuggestionsNotifier extends Notifier?> { @override - AsyncValue>? build() => null; + AsyncValue? build() => null; Future generate() async { final ai = await ref.read(aiServiceProvider.future); @@ -112,8 +121,8 @@ class MealSuggestionsNotifier final eaten = entries.fold(0, (sum, e) => sum + e.kcal); final remaining = settings.dailyKcalGoal - eaten; - state = await AsyncValue.guard( - () => ai.suggestMeals( + state = await AsyncValue.guard(() async { + final meals = await ai.suggestMeals( pantry: [ for (final item in pantry) if (!item.isConsumed) @@ -143,15 +152,26 @@ class MealSuggestionsNotifier dailyGoalKcal: settings.dailyKcalGoal.toDouble(), remainingKcal: remaining.clamp(300, 4000).toDouble(), language: settings.language, - ), - ); + ); + await ref.read(databaseProvider).replaceSavedMeals([ + for (final meal in meals) + SavedMealsCompanion.insert( + title: meal.title, + description: meal.description, + timeMinutes: meal.timeMinutes, + kcal: meal.kcal, + usedIngredients: jsonEncode(meal.usedIngredients), + missingIngredients: jsonEncode(meal.missingIngredients), + steps: jsonEncode(meal.steps), + ), + ]); + }); } void clear() => state = null; } final mealSuggestionsProvider = - NotifierProvider< - MealSuggestionsNotifier, - AsyncValue>? - >(MealSuggestionsNotifier.new); + NotifierProvider?>( + MealSuggestionsNotifier.new, + ); diff --git a/test/data/database_test.dart b/test/data/database_test.dart index 710ac37..fd43ddc 100644 --- a/test/data/database_test.dart +++ b/test/data/database_test.dart @@ -102,6 +102,33 @@ void main() { expect(items.map((i) => i.name), ['Eggs']); }); + test('saved meals: replaced wholesale, done flag persists', () async { + SavedMealsCompanion meal(String title) => SavedMealsCompanion.insert( + title: title, + description: 'desc', + timeMinutes: 10, + kcal: 400, + usedIngredients: '["eggs"]', + missingIngredients: '[]', + steps: '["cook"]', + ); + + await db.replaceSavedMeals([meal('Omelette'), meal('Salad')]); + var meals = await db.watchSavedMeals().first; + expect(meals.map((m) => m.title), ['Omelette', 'Salad']); + expect(meals.first.done, isFalse); + + await db.setSavedMealDone(meals.first.id); + meals = await db.watchSavedMeals().first; + expect(meals.first.done, isTrue); + expect(meals.last.done, isFalse); + + // A new generation wipes the old batch (and its done flags). + await db.replaceSavedMeals([meal('Soup')]); + meals = await db.watchSavedMeals().first; + expect(meals.map((m) => m.title), ['Soup']); + }); + test('articles: saved summaries can be fetched back by id', () async { final id = await db.saveArticle( ArticlesCompanion.insert( diff --git a/test/features/meals_matching_test.dart b/test/features/meals_matching_test.dart new file mode 100644 index 0000000..211591f --- /dev/null +++ b/test/features/meals_matching_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/db/database.dart'; +import 'package:readly/features/meals/meals_page.dart'; + +PantryItem _item(String name, {double amountLeft = 1.0}) { + return PantryItem( + id: name.hashCode, + name: name, + perishable: false, + amountLeft: amountLeft, + addedAt: DateTime(2026), + updatedAt: DateTime(2026), + ); +} + +void main() { + test('matches ingredients to pantry items by containment and words', () { + final pantry = [ + _item('Œufs de plein air'), + _item('Pâtes complètes Barilla'), + _item('Chocolat noir'), + ]; + final used = matchUsedPantryItems(pantry, ['œufs', 'pâtes']); + expect(used.map((i) => i.name), [ + 'Œufs de plein air', + 'Pâtes complètes Barilla', + ]); + }); + + test('ignores finished items and unrelated ingredients', () { + final pantry = [_item('Riz basmati', amountLeft: 0.0), _item('Tomates')]; + expect(matchUsedPantryItems(pantry, ['riz']), isEmpty); + expect(matchUsedPantryItems(pantry, ['courgette']), isEmpty); + }); +} From 67ca5e3a02ab770a7cc427579401f927faa9d1b7 Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 22:36:49 +0400 Subject: [PATCH 6/8] feat: add roadmap for v3.2 and enhance pantry and grocery functionalities with undo support --- PLAN.md | 90 -------- ROADMAP.md | 231 +++++++++++++++++++++ lib/features/groceries/groceries_page.dart | 80 ++++++- lib/features/kitchen/kitchen_page.dart | 78 ++++--- lib/features/track/track_page.dart | 83 +++++++- lib/widgets/log_portion_sheet.dart | 38 ++++ lib/widgets/scanner_page.dart | 38 ++++ 7 files changed, 507 insertions(+), 131 deletions(-) delete mode 100644 PLAN.md create mode 100644 ROADMAP.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index f2923ad..0000000 --- a/PLAN.md +++ /dev/null @@ -1,90 +0,0 @@ -# Readly 3.0 — Overhaul Plan - -Transform Readly from a single-purpose "article summarizer" into a personal **kcal tracker + meal maker**, with the article synthesis kept as a side feature. Personal app: no accounts, no backend, everything on-device. - -## Architecture decisions - -| Area | Choice | Why | -|---|---|---| -| UI framework | Flutter (Material 3, seed-color theme, rounded 20–28px radii) | Existing codebase, modern look for free | -| State management | `flutter_riverpod` 3.x (no codegen) | Reactive, testable, minimal boilerplate | -| Navigation | `go_router` + `StatefulShellRoute` (5-tab `NavigationBar`) | Preserves per-tab state, deep-linkable | -| Local database | `drift` (SQLite) + `drift_flutter` | Type-safe, reactive streams drive the UI, testable | -| Barcode scanning | `mobile_scanner` 7.x | Maintained, CameraX-based | -| Food data | Open Food Facts API v2 (plain `http`, `User-Agent` set) | Free, no key, FR products well covered | -| AI | ~~Anthropic raw HTTP~~ → `dart_openai` (`gpt-5-nano`), BYOK key in `flutter_secure_storage` | v3.1: user prefers the OpenAI models router; streaming for summaries, `json_schema` response format for meals/groceries | -| Article extraction | `readly.lightin.io/api/read` readability proxy, in-app `html` stripping as fallback | v3.1: the proxy strips HTML/JS/CSS server-side and keeps only the article text — better signal than in-app stripping | -| Fonts | `google_fonts` (Outfit / Manrope style) | Clean rounded look without bundling font files | -| Lint/CI | `flutter_lints` 6 + stricter rules; GitHub Actions: analyze → test → build | There was no analyze/test step at all | - -## Pages (5 tabs + settings) - -1. **Track** (home) — today's kcal ring vs. daily goal, meal log (breakfast/lunch/dinner/snack), quick-add, barcode-scan-to-log (OFF lookup → portion → kcal), log straight from pantry. -2. **Kitchen** — pantry stock. Scan or manual add; each item stores OFF nutrition (kcal/100 g, macros) and a "quantity left" slider (0–100 %). Edit/delete. -3. **Meals** — AI meal maker. Sends pantry (with quantities left), remaining kcal today and language to Claude → 3 low-effort meal suggestions (title, time, kcal, steps, used + missing ingredients). Missing ingredients → one-tap add to groceries. "I made it" → logs kcal. -4. **Groceries** — checklist. Manual add + AI proposition (pantry + recent consumption → suggested purchases with reasons). Check off, clear done. -5. **Read** — legacy feature. Paste URL or Android share-sheet → extract text → stream Claude summary → saved history. - -**Settings** (gear on every tab): Anthropic API key (secure storage), summary/meal language, daily kcal goal. - -## Data model (drift tables) - -- `PantryItems` — barcode?, name, brand?, imageUrl?, kcalPer100g?, proteins/carbs/sugars/fats per 100 g?, packageQuantity?, amountLeft (0..1), addedAt, updatedAt -- `ConsumptionEntries` — name, kcal, mealType, pantryItemId?, grams?, loggedAt -- `ShoppingItems` — name, note? (AI reason), done, source (manual/ai), addedAt -- `Articles` — url, title, summary, createdAt (synthesis history) - -## Task list - -### Phase 0 — Toolchain & hygiene -- [x] Audit existing code (1 200 lines, 11 files) and Android config -- [x] Rewrite `pubspec.yaml`: drop `chat_gpt_sdk`, `animated_image_list`; add riverpod, go_router, drift(+dev,build_runner), mobile_scanner, google_fonts; bump SDK to `^3.12.0`, version `3.0.0` -- [x] Stricter `analysis_options.yaml` (flutter_lints 6 + extra rules) -- [x] Android: add `CAMERA` permission, drop deprecated manifest `package` attr, Java 11 -- [x] CI: format + analyze + test + build APK, current Flutter, drop `dart format` misuse - -### Phase 1 — Core plumbing -- [x] Theme (Material 3, light+dark, rounded shapes, google_fonts) -- [x] Router (5-branch StatefulShellRoute + /settings + /scan) -- [x] Drift database + DAOs + codegen -- [x] SettingsService (API key secure, goal/language prefs) -- [x] AnthropicService: SSE streaming (`summarize`) + structured-output (`suggestMeals`, `suggestGroceries`) against `claude-opus-4-8` -- [x] OpenFoodFactsService: product by barcode (v2 API, staging-safe parsing) -- [x] ArticleExtractor: fetch URL → title + readable text -- [x] Share-intent hook → Read tab - -### Phase 2 — Features -- [x] Track page (ring painter, grouped log, quick add sheet, scan-to-log flow, log-from-pantry) -- [x] Kitchen page (list, scan-to-add flow, manual add/edit sheet, amount-left slider, delete) -- [x] Meals page (suggestion cards, missing→groceries, "I made it" → log) -- [x] Groceries page (checklist, manual add, AI proposition, clear done) -- [x] Read page (URL field, history list, streaming summary view) -- [x] Settings page - -### Phase 3 — Quality -- [x] Unit tests: OFF response parsing, SSE stream parsing, AI JSON parsing, article extraction, DB queries -- [x] Widget test: app boots, 5 tabs navigate -- [x] `flutter analyze` clean, `dart format` clean -- [x] `flutter build apk` passes -- [x] Delete dead code (old pages/services), update README - -## Readly 3.1 — feedback pass (2026-07) - -Guiding idea: **sliders are estimates, not scales.** Nobody knows to the gram how much -they ate or how much is left — every quantity in the app is a fraction of the package -(or a number of units for eggs & co), with the already-consumed part greyed out. - -- [x] AI: swap Anthropic → `dart_openai` with `gpt-5-nano` (BYOK OpenAI key in settings) -- [x] Read: restore the `readly.lightin.io/api/read` readability proxy (in-app extraction kept as fallback) -- [x] DB v2: `PantryItems.unitCount` (unit-tracked foods) + `PantryItems.perishable`, migration included -- [x] Kitchen: newest item on top; search bar; "Perishable" + "Finished" filter chips -- [x] Kitchen: slider hidden by default (thin colored gauge instead), appears on tap; % for weight-based items, units for unit-based (eggs → 0–12) -- [x] Kitchen: quick actions on each card — "I ate some" (portion slider → logs kcal + decrements stock) and "add to groceries" -- [x] Track: portion slider (fraction of the package, consumed part greyed) instead of the "portion (g)" field; pantry logs keep stock in sync -- [x] Color pass: vibrant scheme variant, per-meal-type accent colors, gradient kcal ring, level-colored stock gauges — still sober - -### Later / ideas (not in this pass) -- [ ] Decrement pantry quantities automatically when a meal is cooked -- [ ] Weekly kcal/macro charts -- [ ] Off-line queue for OFF lookups -- [ ] iOS share-extension re-test (Android is the target device) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..dedfb19 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,231 @@ +# Readly Roadmap — v3.2 and beyond + +A multi-week improvement plan for Readly (personal kcal tracker + meal maker + article +summarizer). Written so that **each task card can be dispatched to an independent agent**: +every card carries its own context, the files involved, and acceptance criteria. + +## Product principles (read before touching anything) + +1. **Sliders are estimates, not scales.** Every quantity (eaten / used / remaining) is a + fraction of a package or a number of units — never ask for exact grams in a text field. + Reference implementation: `lib/widgets/log_portion_sheet.dart`. +2. **Sober but colorful.** Sepia/pale-maroon seed (`0xFF96604A`, Material 3 vibrant + variant). Semantic colors stay: stock gauges green/amber/red, per-meal-type accents + (`lib/data/meal_type.dart`). No redesigns; add color through accents. +3. **Everything on-device.** No accounts, no backend. Drift/SQLite is the source of truth; + UI consumes reactive streams via Riverpod `StreamProvider`s (`lib/providers.dart`). +4. **Low effort above all.** The user is lazy (his words). Every flow should be + completable in 1–3 taps. Cross-feature shortcuts (kitchen→track, groceries→kitchen) + beat new screens. +5. **AI is BYOK OpenAI** (`gpt-5-nano` through `dart_openai`). gpt-5 models reject + `temperature` and `max_tokens` — never pass them. Structured outputs use + `json_schema` (see `lib/data/services/ai_service.dart`). +6. **UI language is English**; the user writes feedback in French. Target device: Android. + +## Conventions for agents + +- State: Riverpod 3, no codegen. DB: drift; schema bumps need a migration in + `lib/data/db/database.dart` + `dart run build_runner build --delete-conflicting-outputs`. +- Quality gate before finishing any task: `flutter analyze` (0 issues), `flutter test` + (all green), `dart format lib test`, `flutter build apk --debug` compiles. + Release signing needs the user's `android/key.properties` — debug build is the CI proxy. +- Add/extend tests beside the code you touch (`test/` mirrors `lib/`). +- Riverpod gotcha: never `ref.invalidate()` a provider from inside a notifier that the + provider watches (circular-dependency crash). + +## Weak points (honest assessment, 2026-07) + +| # | Weakness | Impact | Addressed by | +|---|----------|--------|--------------| +| W1 | Track shows **today only** — no history, no trends, no way to check yesterday | Can't learn from behavior | R-10, R-11, R-13 | +| W2 | **Deletes are irreversible** (swipe = gone) | Data-loss anxiety | R-01 | +| W3 | **Macros stored but unused** (proteins/carbs/fats sit in PantryItems, never shown) | Half the nutrition story missing | R-12 | +| W4 | **Silos between tabs**: buying groceries doesn't fill the kitchen; kitchen doesn't nudge the tracker | Manual double entry | R-02, R-03, R-21 | +| W5 | **No offline story**: OFF lookups fail without network, nothing cached | Scans die in the supermarket basement | R-25 | +| W6 | **Data lives in one SQLite file with no backup** — phone dies, data dies | Total loss risk | R-30 | +| W7 | Scanner has **no manual fallback** for damaged/unreadable barcodes | Dead end in a core flow | R-04 | +| W8 | Perishable flag exists but there is **no expiry pressure** (no dates, no nudges) | Food waste continues | R-02, R-20 | +| W9 | "I made it" logs the **full recipe kcal** even if you ate half | Overstated intake | R-23 | +| W10 | AI knows the kitchen but **meal suggestions ignore the time of day** | Dinner ideas at breakfast | R-24 | +| W11 | No onboarding/tips — features like unit-tracking or filters are **discoverable only by accident** | Features unused | R-31 | +| W12 | Theme follows system only; **no manual light/dark choice** | Minor comfort | R-33 | + +## Phases + +Sizes: S ≈ ½ day · M ≈ 1–2 days · L ≈ 3+ days. Priorities: P1 do first, P2 next, P3 nice. + +--- + +### Phase 1 — Quick wins & flow glue (week 1) + +#### R-01 · Undo for destructive actions — S · P1 ✅ (done 2026-07) +Swipe-deletes (consumption entries, shopping items) and pantry deletes show a snackbar +with an **Undo** action that re-inserts the row (new id is fine; keep original timestamps). +Files: `track_page.dart`, `kitchen_page.dart`, `groceries_page.dart`. +Accept: delete → tap Undo → item is back with same data; snackbar auto-dismisses. + +#### R-02 · "Eat soon" perishables card on Track — S · P1 ✅ (done 2026-07) +Track shows, under the kcal header, a tinted card listing perishable, non-finished pantry +items as chips. Tapping a chip runs the shared eat-flow (portion slider → log + stock +decrement). Extract the eat-flow (sheet+log+decrement, currently in kitchen's `_eatSome`) +into a shared helper so Track/Kitchen/future callers reuse it. +Files: `track_page.dart`, `log_portion_sheet.dart` (helper), `kitchen_page.dart` (reuse). +Accept: perishable item appears as chip; tap → sheet → logged + stock reduced; card hidden +when no perishables. + +#### R-03 · Groceries → Kitchen hand-off — M · P1 ✅ (done 2026-07) +Checking off a shopping item offers "Add to kitchen": if a pantry item matches the name +(case-insensitive) → refill it to 100%; otherwise open the pantry add-sheet prefilled with +the item's name. Files: `groceries_page.dart`, `kitchen_page.dart` (sheet gains an +`initialName` param). Accept: check item → snackbar action → pantry updated/created. + +#### R-04 · Manual barcode entry — S · P1 ✅ (done 2026-07) +Scanner page gets a keyboard fallback (bottom field "…or type the barcode" + submit) that +pops the same way a scan does. Files: `widgets/scanner_page.dart`. +Accept: typing 13 digits + submit behaves exactly like a successful camera scan. + +#### R-05 · Micro-polish batch — S · P2 +Light haptics on log/eat/check actions (`HapticFeedback.lightImpact`), consistent snackbar +durations, `FloatingActionButton` heroTag audit, pull-to-refresh on Meals regenerates. +Accept: no visual regressions; analyze clean. + +--- + +### Phase 2 — Insight & history (week 2) + +#### R-10 · Day navigation on Track — M · P1 +Replace the fixed "today" with a selected-day state (provider holding a `DateTime`). +Chevrons + horizontal swipe on the header navigate days; "Today" pill jumps back. Entries +stream & kcal ring follow the selected day (`watchEntriesBetween` already takes a range). +Files: `providers.dart` (selectedDayProvider), `track_page.dart`. +Accept: swipe left = yesterday with its own entries/ring; logging while viewing a past day +logs to that day (portion sheet gains a date awareness) or is blocked with a hint — pick +logging-to-that-day. + +#### R-11 · Weekly kcal chart — M · P1 +A 7-day bar chart card on Track (below the log) — hand-drawn `CustomPaint` bars (no chart +package): one bar per day vs goal line, bars colored by within/over goal. Tapping a bar +navigates to that day (needs R-10). Data: aggregate `entriesSince(now-7d)` in a provider. +Files: `providers.dart`, `track_page.dart` (or new `widgets/weekly_chart.dart`). +Accept: chart matches logged data; goal line visible; tap-to-navigate works. + +#### R-12 · Macro tracking — L · P2 +Schema v4: add `proteins`, `carbs`, `fats` (nullable REAL, per portion) to +`ConsumptionEntries`. Capture at log time: pantry/OFF items know per-100g values and the +portion sheet knows grams → compute silently. Track header shows three small progress +chips (protein/carb/fat vs rough targets derived from the kcal goal: 25/45/30% split). +Files: `database.dart` (+migration), `log_portion_sheet.dart`, `track_page.dart`, +`providers.dart`. Accept: logging a pantry item with macros fills the chips; entries +without data don't break totals; migration preserves existing rows. + +#### R-13 · Streaks & gentle stats — S · P3 (needs R-10/R-11) +"N days in a row within goal" line under the chart; no guilt-tripping when over (copy: +"fresh start today"). Accept: streak computed from history, capped display at 99+. + +--- + +### Phase 3 — Smarter kitchen & meals (week 3) + +#### R-20 · Expiry dates & pressure — M · P2 +Optional `expiresAt` date on PantryItems (schema bump; date picker in edit sheet, quick +presets +3d/+1w/+2w). Kitchen sorts expiring-soon first within the list, shows "expires in +2 d" in red/amber on cards; "Eat soon" card (R-02) prioritizes by expiry. AI meal prompt +gains `expires_in_days`. Files: `database.dart`, `kitchen_page.dart`, `providers.dart`, +`ai_service.dart`, `track_page.dart`. Accept: expired items visibly flagged; prompt JSON +contains expiry. + +#### R-21 · Low stock → groceries in one tap — S · P2 +Kitchen app-bar action "Add low stock to groceries": every non-finished item ≤ 20% (or +≤ 2 units) not already on the list is added (source `auto`). Confirmation snackbar with +count + Undo. Files: `kitchen_page.dart`, `database.dart` (bulk insert helper). +Accept: duplicates skipped case-insensitively; undo removes the batch. + +#### R-22 · Meal favorites & cook history — M · P2 +"I made it" also archives the meal (new table `CookedMeals` or a `favorite`/`cookedAt` +approach on SavedMeals — prefer a separate table so regenerations don't erase history). +New section on Meals page: "Cook again" horizontal cards (title, kcal, one-tap log with +portion slider, one-tap "suggest variations" that seeds the AI prompt). Files: +`database.dart`, `meals_page.dart`, `providers.dart`, `ai_service.dart`. +Accept: cooked meals survive regeneration & restarts; re-log works. + +#### R-23 · Partial "I made it" — S · P1 +After picking the meal type, reuse the portion slider (fraction of the recipe, default +100%) so eating half logs half the kcal. Files: `meals_page.dart` (reuse +`showLogPortionSheet` with `packageGrams: null`, kcal scaled by fraction). +Accept: 50% → half kcal logged; kitchen-update sheet still offered. + +#### R-24 · Time-of-day aware suggestions — S · P1 +Pass `MealType.suggestedNow()` and the local time to `suggestMeals`; system prompt asks +for meals fitting that moment (breakfast ideas in the morning…). Files: `ai_service.dart`, +`providers.dart`. Accept: prompt contains the moment; no schema change. + +#### R-25 · Offline OFF cache — M · P2 +Cache OFF product JSON by barcode (new drift table `OffCache` with `fetchedAt`, TTL 30 d). +`OpenFoodFactsService` checks cache first, falls back to network, writes back; on network +failure serve stale cache with a "cached" hint. Files: `off_service.dart`, `database.dart`, +`providers.dart` (service needs the db — inject via constructor). Accept: airplane-mode +re-scan of a known barcode still resolves; unit tests with a fake client. + +--- + +### Phase 4 — Trust, onboarding & polish (week 4) + +#### R-30 · Data export / import — M · P1 +Settings gains "Export data" (all tables → single JSON, versioned envelope, shared via +`share_handler`/`SharePlus` or saved to Downloads) and "Import data" (file picker → +validate version → replace-or-merge dialog → transaction). Files: new +`lib/data/services/backup_service.dart`, `settings_page.dart`, tests with an in-memory db. +Accept: export→wipe→import round-trips every table including done flags and timestamps. + +#### R-31 · Tips & first-run guidance — S · P2 +A dismissible one-liner tip card system (`TipsCard(id, text)` stored-dismissed in +SharedPreferences): Kitchen → "Tap an item to adjust what's left"; Track → "Scan a barcode +right from here"; Meals → "Suggestions use what's in your kitchen". Max one tip visible +per screen. Files: new `widgets/tip_card.dart`, the three pages, `settings_service.dart`. +Accept: dismiss persists across restarts; "Reset tips" in settings. + +#### R-32 · App icon & splash in sepia — S · P3 +Regenerate launcher icon (`assets/logo.png` recolor) + adaptive icon background to match +seed `0xFF96604A`; splash screen color. Files: `pubspec.yaml` (flutter_launcher_icons), +`android/`. Accept: icon visibly sepia on device. + +#### R-33 · Theme mode toggle — S · P2 +Settings: System / Light / Dark segmented control persisted in prefs; `ReadlyApp` reads a +themeModeProvider. Files: `settings_service.dart`, `providers.dart`, `app/app.dart`, +`settings_page.dart`. Accept: choice survives restart. + +#### R-34 · CI artifact & release hygiene — S · P3 +GitHub Actions uploads the debug APK as a workflow artifact; README section documents the +`android/key.properties` release-signing setup. Files: `.github/workflows/*`, `README.md`. +Accept: artifact downloadable from a CI run. + +#### R-35 · Read tab quality-of-life — S · P3 +History search field, "share summary" action on the summary page, retry button when the +proxy fails. Files: `reader_page.dart`, `summary_page.dart`. Accept: search filters live. + +#### R-36 · Friendly error copy audit — S · P3 +Sweep every `catch` → user-facing message: no raw exceptions in snackbars; map common +cases (no network, 401 bad key, quota) to plain-English strings with a fix hint. +Files: all pages + `ai_service.dart`, `off_service.dart`. Accept: no `$e` shown raw. + +--- + +## Later / ideas parking lot + +- Home-screen widget (today's ring + quick log) — heavy platform work. +- Voice quick-log ("I ate a croissant") → AI parses to name+kcal. +- Water tracking toggle. +- Recipe photo → ingredient extraction (vision model). +- Multi-day meal planning ("plan my week from the kitchen"). +- Wear OS glance. + +## Suggested dispatch order + +1. ~~R-01, R-02, R-03, R-04~~ (done — v3.2) +2. R-23 + R-24 (small, immediate AI value) → one agent +3. R-10 + R-11 + R-13 (Track history bundle) → one agent +4. R-30 (backup — protects everything else) → one agent +5. R-25 + R-20 + R-21 (kitchen intelligence) → one agent +6. R-12 (macros, largest schema change — after the Track bundle lands) +7. R-31, R-33, R-05, R-35, R-36 (polish sweep) → one agent +8. R-22, R-32, R-34 as capacity allows diff --git a/lib/features/groceries/groceries_page.dart b/lib/features/groceries/groceries_page.dart index bb168b1..aca0cc4 100644 --- a/lib/features/groceries/groceries_page.dart +++ b/lib/features/groceries/groceries_page.dart @@ -7,6 +7,7 @@ import '../../data/db/database.dart'; import '../../data/services/ai_service.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; +import '../kitchen/kitchen_page.dart' show showPantryEditSheet; class GroceriesPage extends ConsumerStatefulWidget { const GroceriesPage({super.key}); @@ -108,6 +109,77 @@ class _GroceriesPageState extends ConsumerState { } } + Future _deleteWithUndo(ShoppingItem item) async { + final db = ref.read(databaseProvider); + final messenger = ScaffoldMessenger.of(context); + await db.deleteShoppingItem(item.id); + messenger.showSnackBar( + SnackBar( + content: Text('Removed "${item.name}".'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => db.addShoppingItem( + ShoppingItemsCompanion.insert( + name: item.name, + note: Value(item.note), + done: Value(item.done), + source: Value(item.source), + addedAt: Value(item.addedAt), + ), + ), + ), + ), + ); + } + + /// Checking something off means it was bought — offer to put it in the + /// kitchen (refill if it already exists there, otherwise quick-add). + Future _setDone(ShoppingItem item, bool done) async { + final db = ref.read(databaseProvider); + final messenger = ScaffoldMessenger.of(context); + await db.setShoppingDone(item.id, done); + if (!done) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text('Bought "${item.name}"!'), + action: SnackBarAction( + label: 'To kitchen', + onPressed: () => _addBoughtToKitchen(item.name), + ), + ), + ); + } + + Future _addBoughtToKitchen(String name) async { + final db = ref.read(databaseProvider); + final messenger = ScaffoldMessenger.of(context); + final pantry = await ref.read(pantryProvider.future); + final query = name.trim().toLowerCase(); + PantryItem? match; + for (final item in pantry) { + final itemName = item.name.toLowerCase(); + if (itemName == query || + itemName.contains(query) || + query.contains(itemName)) { + match = item; + break; + } + } + if (match != null) { + await db.updatePantryItem( + match.id, + const PantryItemsCompanion(amountLeft: Value(1.0)), + ); + messenger.showSnackBar( + SnackBar(content: Text('"${match.name}" refilled to 100%.')), + ); + return; + } + if (!mounted) return; + await showPantryEditSheet(context, ref, initialName: name.trim()); + } + @override Widget build(BuildContext context) { final items = ref.watch(shoppingProvider).value ?? []; @@ -181,14 +253,10 @@ class _GroceriesPageState extends ConsumerState { color: Theme.of(context).colorScheme.errorContainer, child: const Icon(Icons.delete_outline), ), - onDismissed: (_) => ref - .read(databaseProvider) - .deleteShoppingItem(item.id), + onDismissed: (_) => _deleteWithUndo(item), child: CheckboxListTile( value: item.done, - onChanged: (value) => ref - .read(databaseProvider) - .setShoppingDone(item.id, value ?? false), + onChanged: (value) => _setDone(item, value ?? false), title: Text( item.name, style: item.done diff --git a/lib/features/kitchen/kitchen_page.dart b/lib/features/kitchen/kitchen_page.dart index 8684c2b..8282daf 100644 --- a/lib/features/kitchen/kitchen_page.dart +++ b/lib/features/kitchen/kitchen_page.dart @@ -408,34 +408,7 @@ class _PantryCardState extends ConsumerState<_PantryCard> { return '${(amount * 100).round()}%'; } - Future _eatSome() async { - final item = widget.item; - final result = await showEatPantryItemSheet(context, item); - if (result == null) return; - final db = ref.read(databaseProvider); - await db.logConsumption( - ConsumptionEntriesCompanion.insert( - name: item.name, - kcal: result.kcal, - mealType: result.mealType.value, - pantryItemId: Value(item.id), - grams: Value(result.grams), - ), - ); - await db.updatePantryItem( - item.id, - PantryItemsCompanion( - amountLeft: Value((item.amountLeft - result.fraction).clamp(0.0, 1.0)), - ), - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Logged ${result.kcal.round()} kcal — stock updated.'), - ), - ); - } - } + Future _eatSome() => eatPantryItemFlow(context, ref, widget.item); Future _addToGroceries() async { await ref @@ -459,7 +432,35 @@ class _PantryCardState extends ConsumerState<_PantryCard> { const PantryItemsCompanion(amountLeft: Value(1.0)), ); case 'delete': - await db.deletePantryItem(widget.item.id); + final item = widget.item; + final messenger = ScaffoldMessenger.of(context); + await db.deletePantryItem(item.id); + messenger.showSnackBar( + SnackBar( + content: Text('Deleted "${item.name}".'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => db.addPantryItem( + PantryItemsCompanion.insert( + name: item.name, + barcode: Value(item.barcode), + brand: Value(item.brand), + imageUrl: Value(item.imageUrl), + kcalPer100g: Value(item.kcalPer100g), + proteinsPer100g: Value(item.proteinsPer100g), + carbsPer100g: Value(item.carbsPer100g), + sugarsPer100g: Value(item.sugarsPer100g), + fatsPer100g: Value(item.fatsPer100g), + packageQuantity: Value(item.packageQuantity), + unitCount: Value(item.unitCount), + perishable: Value(item.perishable), + amountLeft: Value(item.amountLeft), + addedAt: Value(item.addedAt), + ), + ), + ), + ), + ); } } } @@ -498,14 +499,16 @@ class _Thumbnail extends StatelessWidget { } } -/// Add/edit sheet, optionally pre-filled from an Open Food Facts [product] -/// or an [existing] pantry item. +/// Add/edit sheet, optionally pre-filled from an Open Food Facts [product], +/// an [existing] pantry item, or just an [initialName] (e.g. coming from a +/// checked-off grocery). Future showPantryEditSheet( BuildContext context, WidgetRef ref, { OffProduct? product, PantryItem? existing, String? barcode, + String? initialName, }) { return showModalBottomSheet( context: context, @@ -514,16 +517,23 @@ Future showPantryEditSheet( product: product, existing: existing, barcode: barcode, + initialName: initialName, ), ); } class _PantryEditSheet extends ConsumerStatefulWidget { - const _PantryEditSheet({this.product, this.existing, this.barcode}); + const _PantryEditSheet({ + this.product, + this.existing, + this.barcode, + this.initialName, + }); final OffProduct? product; final PantryItem? existing; final String? barcode; + final String? initialName; @override ConsumerState<_PantryEditSheet> createState() => _PantryEditSheetState(); @@ -542,7 +552,9 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { super.initState(); final p = widget.product; final e = widget.existing; - _name = TextEditingController(text: e?.name ?? p?.name ?? ''); + _name = TextEditingController( + text: e?.name ?? p?.name ?? widget.initialName ?? '', + ); _brand = TextEditingController(text: e?.brand ?? p?.brand ?? ''); _kcal = TextEditingController( text: (e?.kcalPer100g ?? p?.kcalPer100g)?.round().toString() ?? '', diff --git a/lib/features/track/track_page.dart b/lib/features/track/track_page.dart index eb82279..9d70b5b 100644 --- a/lib/features/track/track_page.dart +++ b/lib/features/track/track_page.dart @@ -40,6 +40,7 @@ class TrackPage extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), children: [ _KcalHeader(consumed: consumed, goal: goal), + const _EatSoonCard(), if (entries.isEmpty) const Padding( padding: EdgeInsets.only(top: 48), @@ -92,8 +93,29 @@ class TrackPage extends ConsumerWidget { color: Theme.of(context).colorScheme.errorContainer, child: const Icon(Icons.delete_outline), ), - onDismissed: (_) => - ref.read(databaseProvider).deleteConsumption(entry.id), + onDismissed: (_) async { + final db = ref.read(databaseProvider); + final messenger = ScaffoldMessenger.of(context); + await db.deleteConsumption(entry.id); + messenger.showSnackBar( + SnackBar( + content: Text('Removed "${entry.name}".'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: entry.name, + kcal: entry.kcal, + mealType: entry.mealType, + pantryItemId: Value(entry.pantryItemId), + grams: Value(entry.grams), + loggedAt: Value(entry.loggedAt), + ), + ), + ), + ), + ); + }, child: ListTile( leading: CircleAvatar( radius: 18, @@ -275,6 +297,63 @@ class TrackPage extends ConsumerWidget { } } +/// Perishable items that are not finished yet — eat these first. +/// Hidden when there is nothing perishable in the kitchen. +class _EatSoonCard extends ConsumerWidget { + const _EatSoonCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pantry = ref.watch(pantryProvider).value ?? []; + final perishables = [ + for (final item in pantry) + if (item.perishable && !item.isConsumed) item, + ]; + if (perishables.isEmpty) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Card( + color: scheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.eco, size: 18, color: Color(0xFFE8930C)), + const SizedBox(width: 8), + Text( + 'Eat these first', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: scheme.onSecondaryContainer, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final item in perishables.take(6)) + ActionChip( + avatar: const Icon(Icons.restaurant, size: 16), + label: Text('${item.name} · ${item.amountLabel}'), + onPressed: () => eatPantryItemFlow(context, ref, item), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + class _KcalHeader extends StatelessWidget { const _KcalHeader({required this.consumed, required this.goal}); diff --git a/lib/widgets/log_portion_sheet.dart b/lib/widgets/log_portion_sheet.dart index 62c1276..d6bbc7d 100644 --- a/lib/widgets/log_portion_sheet.dart +++ b/lib/widgets/log_portion_sheet.dart @@ -1,8 +1,11 @@ +import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/db/database.dart'; import '../data/meal_type.dart'; import '../data/quantity.dart'; +import '../providers.dart'; class LogPortionResult { const LogPortionResult({ @@ -272,3 +275,38 @@ Future showEatPantryItemSheet( packageLabel: item.packageQuantity, ); } + +/// The full "I ate some of this" flow shared by Kitchen and Track: portion +/// sheet → log the kcal → keep the pantry stock in sync. Returns true when +/// something was logged. +Future eatPantryItemFlow( + BuildContext context, + WidgetRef ref, + PantryItem item, +) async { + final messenger = ScaffoldMessenger.of(context); + final result = await showEatPantryItemSheet(context, item); + if (result == null) return false; + final db = ref.read(databaseProvider); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: item.name, + kcal: result.kcal, + mealType: result.mealType.value, + pantryItemId: Value(item.id), + grams: Value(result.grams), + ), + ); + await db.updatePantryItem( + item.id, + PantryItemsCompanion( + amountLeft: Value((item.amountLeft - result.fraction).clamp(0.0, 1.0)), + ), + ); + messenger.showSnackBar( + SnackBar( + content: Text('Logged ${result.kcal.round()} kcal — stock updated.'), + ), + ); + return true; +} diff --git a/lib/widgets/scanner_page.dart b/lib/widgets/scanner_page.dart index 9fef913..5f52574 100644 --- a/lib/widgets/scanner_page.dart +++ b/lib/widgets/scanner_page.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; /// Full screen barcode scanner. Pops with the scanned code as a [String]. +/// A keyboard fallback at the bottom covers damaged/unreadable barcodes. class ScannerPage extends StatefulWidget { const ScannerPage({super.key}); @@ -14,6 +15,7 @@ class _ScannerPageState extends State { final _controller = MobileScannerController( formats: [BarcodeFormat.ean13, BarcodeFormat.ean8, BarcodeFormat.upcA], ); + final _manualController = TextEditingController(); bool _handled = false; void _onDetect(BarcodeCapture capture) { @@ -27,9 +29,17 @@ class _ScannerPageState extends State { context.pop(code); } + void _submitManual() { + final code = _manualController.text.replaceAll(RegExp(r'\D'), ''); + if (code.length < 8 || _handled) return; + _handled = true; + context.pop(code); + } + @override void dispose() { _controller.dispose(); + _manualController.dispose(); super.dispose(); } @@ -37,6 +47,7 @@ class _ScannerPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, + resizeToAvoidBottomInset: true, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, @@ -68,6 +79,33 @@ class _ScannerPageState extends State { ), ), ), + Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: TextField( + controller: _manualController, + keyboardType: TextInputType.number, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: '…or type the barcode', + hintStyle: const TextStyle(color: Colors.white54), + filled: true, + fillColor: Colors.white12, + suffixIcon: IconButton( + icon: const Icon( + Icons.arrow_forward, + color: Colors.white70, + ), + onPressed: _submitManual, + ), + ), + onSubmitted: (_) => _submitManual(), + ), + ), + ), + ), ], ), ); From 8af9ca9be122c5a56d3c768f1237dfa077e23b85 Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 23:18:39 +0400 Subject: [PATCH 7/8] feat: enhance meals tracking and logging features - Refactor MealsPage to display meals and cooked history more effectively. - Introduce _MealsBody widget to manage meal suggestions and cooked meals display. - Add functionality to log cooked meals with a bottom sheet for meal type selection. - Update SettingsPage to include daily burn kcal input for better tracking. - Implement TrackPage improvements for logging food entries with date context. - Add new progress tracking features to calculate streaks and kcal deficits. - Introduce restaurant meal presets for quick logging in the TrackPage. - Create an EditEntrySheet for modifying logged food entries. - Enhance database interactions for logging and managing meals and pantry items. - Add tests for progress calculations and database functionalities. --- .github/workflows/cicd.yml | 13 +- lib/data/db/database.dart | 48 +- lib/data/db/database.g.dart | 705 ++++++++++++++++++++- lib/data/kitchen_category.dart | 23 + lib/data/progress.dart | 50 ++ lib/data/services/ai_service.dart | 99 ++- lib/data/services/settings_service.dart | 14 + lib/features/groceries/groceries_page.dart | 49 +- lib/features/kitchen/kitchen_page.dart | 96 ++- lib/features/meals/meals_page.dart | 156 ++++- lib/features/settings/settings_page.dart | 23 + lib/features/track/track_page.dart | 501 +++++++++++++-- lib/providers.dart | 73 +++ lib/widgets/log_portion_sheet.dart | 8 +- test/app_test.dart | 5 + test/data/database_test.dart | 26 + test/data/progress_test.dart | 80 +++ 17 files changed, 1840 insertions(+), 129 deletions(-) create mode 100644 lib/data/kitchen_category.dart create mode 100644 lib/data/progress.dart create mode 100644 test/data/progress_test.dart diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e7629a1..20685a6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,5 +1,8 @@ name: ci -on: [ pull_request ] +on: + pull_request: + push: + branches: [ master ] jobs: check-and-build: runs-on: ubuntu-latest @@ -21,5 +24,11 @@ jobs: run: flutter analyze - name: Test run: flutter test - - name: Build APK + - name: Build debug APK run: flutter build apk --debug + - name: Upload debug APK + uses: actions/upload-artifact@v6 + with: + name: readly-debug-apk + path: build/app/outputs/flutter-apk/app-debug.apk + retention-days: 30 diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 7f6fca2..dab43e0 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -25,6 +25,9 @@ class PantryItems extends Table { /// Perishable foods should be eaten first. BoolColumn get perishable => boolean().withDefault(const Constant(false))(); + /// Storage "drawer": fridge | freezer | cupboard | snacks | drinks | other. + TextColumn get category => text().withDefault(const Constant('cupboard'))(); + /// Estimated fraction left in the package, 0.0 to 1.0. RealColumn get amountLeft => real().withDefault(const Constant(1.0))(); DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); @@ -54,9 +57,23 @@ class ShoppingItems extends Table { /// manual | ai TextColumn get source => text().withDefault(const Constant('manual'))(); + + /// AI estimates: how much to buy ("500 g", "6 pots") and a rough price. + TextColumn get quantity => text().nullable()(); + RealColumn get estimatedPrice => real().nullable()(); DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); } +/// Every meal the user actually cooked ("I made it"), kept forever so the +/// Meals tab can offer to cook them again. +@DataClassName('CookedMeal') +class CookedMeals extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get title => text()(); + RealColumn get kcal => real()(); + DateTimeColumn get cookedAt => dateTime().withDefault(currentDateAndTime)(); +} + /// AI-generated meal suggestions, persisted so they survive app restarts. /// Replaced wholesale on each new generation. @DataClassName('SavedMeal') @@ -91,6 +108,7 @@ class Articles extends Table { ConsumptionEntries, ShoppingItems, SavedMeals, + CookedMeals, Articles, ], ) @@ -100,7 +118,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.e); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -112,6 +130,15 @@ class AppDatabase extends _$AppDatabase { if (from < 3) { await m.createTable(savedMeals); } + if (from < 4) { + await m.addColumn(pantryItems, pantryItems.category); + await m.addColumn(shoppingItems, shoppingItems.quantity); + await m.addColumn(shoppingItems, shoppingItems.estimatedPrice); + await m.createTable(cookedMeals); + // Existing perishables obviously live in the fridge. + await (update(pantryItems)..where((t) => t.perishable.equals(true))) + .write(const PantryItemsCompanion(category: Value('fridge'))); + } }, ); @@ -158,6 +185,11 @@ class AppDatabase extends _$AppDatabase { Future logConsumption(ConsumptionEntriesCompanion entry) => into(consumptionEntries).insert(entry); + Future updateConsumption(int id, ConsumptionEntriesCompanion changes) => + (update(consumptionEntries)..where((t) => t.id.equals(id))).write( + changes, + ); + Future deleteConsumption(int id) => (delete(consumptionEntries)..where((t) => t.id.equals(id))).go(); @@ -203,6 +235,20 @@ class AppDatabase extends _$AppDatabase { const SavedMealsCompanion(done: Value(true)), ); + // ---- Cooked meal history ---- + + Stream> watchCookedMeals({int limit = 15}) => + (select(cookedMeals) + ..orderBy([(t) => OrderingTerm.desc(t.cookedAt)]) + ..limit(limit)) + .watch(); + + Future addCookedMeal(CookedMealsCompanion meal) => + into(cookedMeals).insert(meal); + + Future deleteCookedMeal(int id) => + (delete(cookedMeals)..where((t) => t.id.equals(id))).go(); + // ---- Articles ---- Stream> watchArticles() => (select( diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index 5465f5d..e0da760 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -154,6 +154,18 @@ class $PantryItemsTable extends PantryItems ), defaultValue: const Constant(false), ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('cupboard'), + ); static const VerificationMeta _amountLeftMeta = const VerificationMeta( 'amountLeft', ); @@ -205,6 +217,7 @@ class $PantryItemsTable extends PantryItems packageQuantity, unitCount, perishable, + category, amountLeft, addedAt, updatedAt, @@ -316,6 +329,12 @@ class $PantryItemsTable extends PantryItems perishable.isAcceptableOrUnknown(data['perishable']!, _perishableMeta), ); } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } if (data.containsKey('amount_left')) { context.handle( _amountLeftMeta, @@ -395,6 +414,10 @@ class $PantryItemsTable extends PantryItems DriftSqlType.bool, data['${effectivePrefix}perishable'], )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, amountLeft: attachedDatabase.typeMapping.read( DriftSqlType.double, data['${effectivePrefix}amount_left'], @@ -438,6 +461,9 @@ class PantryItem extends DataClass implements Insertable { /// Perishable foods should be eaten first. final bool perishable; + /// Storage "drawer": fridge | freezer | cupboard | snacks | drinks | other. + final String category; + /// Estimated fraction left in the package, 0.0 to 1.0. final double amountLeft; final DateTime addedAt; @@ -456,6 +482,7 @@ class PantryItem extends DataClass implements Insertable { this.packageQuantity, this.unitCount, required this.perishable, + required this.category, required this.amountLeft, required this.addedAt, required this.updatedAt, @@ -496,6 +523,7 @@ class PantryItem extends DataClass implements Insertable { map['unit_count'] = Variable(unitCount); } map['perishable'] = Variable(perishable); + map['category'] = Variable(category); map['amount_left'] = Variable(amountLeft); map['added_at'] = Variable(addedAt); map['updated_at'] = Variable(updatedAt); @@ -537,6 +565,7 @@ class PantryItem extends DataClass implements Insertable { ? const Value.absent() : Value(unitCount), perishable: Value(perishable), + category: Value(category), amountLeft: Value(amountLeft), addedAt: Value(addedAt), updatedAt: Value(updatedAt), @@ -562,6 +591,7 @@ class PantryItem extends DataClass implements Insertable { packageQuantity: serializer.fromJson(json['packageQuantity']), unitCount: serializer.fromJson(json['unitCount']), perishable: serializer.fromJson(json['perishable']), + category: serializer.fromJson(json['category']), amountLeft: serializer.fromJson(json['amountLeft']), addedAt: serializer.fromJson(json['addedAt']), updatedAt: serializer.fromJson(json['updatedAt']), @@ -584,6 +614,7 @@ class PantryItem extends DataClass implements Insertable { 'packageQuantity': serializer.toJson(packageQuantity), 'unitCount': serializer.toJson(unitCount), 'perishable': serializer.toJson(perishable), + 'category': serializer.toJson(category), 'amountLeft': serializer.toJson(amountLeft), 'addedAt': serializer.toJson(addedAt), 'updatedAt': serializer.toJson(updatedAt), @@ -604,6 +635,7 @@ class PantryItem extends DataClass implements Insertable { Value packageQuantity = const Value.absent(), Value unitCount = const Value.absent(), bool? perishable, + String? category, double? amountLeft, DateTime? addedAt, DateTime? updatedAt, @@ -627,6 +659,7 @@ class PantryItem extends DataClass implements Insertable { : this.packageQuantity, unitCount: unitCount.present ? unitCount.value : this.unitCount, perishable: perishable ?? this.perishable, + category: category ?? this.category, amountLeft: amountLeft ?? this.amountLeft, addedAt: addedAt ?? this.addedAt, updatedAt: updatedAt ?? this.updatedAt, @@ -660,6 +693,7 @@ class PantryItem extends DataClass implements Insertable { perishable: data.perishable.present ? data.perishable.value : this.perishable, + category: data.category.present ? data.category.value : this.category, amountLeft: data.amountLeft.present ? data.amountLeft.value : this.amountLeft, @@ -684,6 +718,7 @@ class PantryItem extends DataClass implements Insertable { ..write('packageQuantity: $packageQuantity, ') ..write('unitCount: $unitCount, ') ..write('perishable: $perishable, ') + ..write('category: $category, ') ..write('amountLeft: $amountLeft, ') ..write('addedAt: $addedAt, ') ..write('updatedAt: $updatedAt') @@ -706,6 +741,7 @@ class PantryItem extends DataClass implements Insertable { packageQuantity, unitCount, perishable, + category, amountLeft, addedAt, updatedAt, @@ -727,6 +763,7 @@ class PantryItem extends DataClass implements Insertable { other.packageQuantity == this.packageQuantity && other.unitCount == this.unitCount && other.perishable == this.perishable && + other.category == this.category && other.amountLeft == this.amountLeft && other.addedAt == this.addedAt && other.updatedAt == this.updatedAt); @@ -746,6 +783,7 @@ class PantryItemsCompanion extends UpdateCompanion { final Value packageQuantity; final Value unitCount; final Value perishable; + final Value category; final Value amountLeft; final Value addedAt; final Value updatedAt; @@ -763,6 +801,7 @@ class PantryItemsCompanion extends UpdateCompanion { this.packageQuantity = const Value.absent(), this.unitCount = const Value.absent(), this.perishable = const Value.absent(), + this.category = const Value.absent(), this.amountLeft = const Value.absent(), this.addedAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -781,6 +820,7 @@ class PantryItemsCompanion extends UpdateCompanion { this.packageQuantity = const Value.absent(), this.unitCount = const Value.absent(), this.perishable = const Value.absent(), + this.category = const Value.absent(), this.amountLeft = const Value.absent(), this.addedAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -799,6 +839,7 @@ class PantryItemsCompanion extends UpdateCompanion { Expression? packageQuantity, Expression? unitCount, Expression? perishable, + Expression? category, Expression? amountLeft, Expression? addedAt, Expression? updatedAt, @@ -817,6 +858,7 @@ class PantryItemsCompanion extends UpdateCompanion { if (packageQuantity != null) 'package_quantity': packageQuantity, if (unitCount != null) 'unit_count': unitCount, if (perishable != null) 'perishable': perishable, + if (category != null) 'category': category, if (amountLeft != null) 'amount_left': amountLeft, if (addedAt != null) 'added_at': addedAt, if (updatedAt != null) 'updated_at': updatedAt, @@ -837,6 +879,7 @@ class PantryItemsCompanion extends UpdateCompanion { Value? packageQuantity, Value? unitCount, Value? perishable, + Value? category, Value? amountLeft, Value? addedAt, Value? updatedAt, @@ -855,6 +898,7 @@ class PantryItemsCompanion extends UpdateCompanion { packageQuantity: packageQuantity ?? this.packageQuantity, unitCount: unitCount ?? this.unitCount, perishable: perishable ?? this.perishable, + category: category ?? this.category, amountLeft: amountLeft ?? this.amountLeft, addedAt: addedAt ?? this.addedAt, updatedAt: updatedAt ?? this.updatedAt, @@ -903,6 +947,9 @@ class PantryItemsCompanion extends UpdateCompanion { if (perishable.present) { map['perishable'] = Variable(perishable.value); } + if (category.present) { + map['category'] = Variable(category.value); + } if (amountLeft.present) { map['amount_left'] = Variable(amountLeft.value); } @@ -931,6 +978,7 @@ class PantryItemsCompanion extends UpdateCompanion { ..write('packageQuantity: $packageQuantity, ') ..write('unitCount: $unitCount, ') ..write('perishable: $perishable, ') + ..write('category: $category, ') ..write('amountLeft: $amountLeft, ') ..write('addedAt: $addedAt, ') ..write('updatedAt: $updatedAt') @@ -1448,6 +1496,28 @@ class $ShoppingItemsTable extends ShoppingItems requiredDuringInsert: false, defaultValue: const Constant('manual'), ); + static const VerificationMeta _quantityMeta = const VerificationMeta( + 'quantity', + ); + @override + late final GeneratedColumn quantity = GeneratedColumn( + 'quantity', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _estimatedPriceMeta = const VerificationMeta( + 'estimatedPrice', + ); + @override + late final GeneratedColumn estimatedPrice = GeneratedColumn( + 'estimated_price', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); static const VerificationMeta _addedAtMeta = const VerificationMeta( 'addedAt', ); @@ -1461,7 +1531,16 @@ class $ShoppingItemsTable extends ShoppingItems defaultValue: currentDateAndTime, ); @override - List get $columns => [id, name, note, done, source, addedAt]; + List get $columns => [ + id, + name, + note, + done, + source, + quantity, + estimatedPrice, + addedAt, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1503,6 +1582,21 @@ class $ShoppingItemsTable extends ShoppingItems source.isAcceptableOrUnknown(data['source']!, _sourceMeta), ); } + if (data.containsKey('quantity')) { + context.handle( + _quantityMeta, + quantity.isAcceptableOrUnknown(data['quantity']!, _quantityMeta), + ); + } + if (data.containsKey('estimated_price')) { + context.handle( + _estimatedPriceMeta, + estimatedPrice.isAcceptableOrUnknown( + data['estimated_price']!, + _estimatedPriceMeta, + ), + ); + } if (data.containsKey('added_at')) { context.handle( _addedAtMeta, @@ -1538,6 +1632,14 @@ class $ShoppingItemsTable extends ShoppingItems DriftSqlType.string, data['${effectivePrefix}source'], )!, + quantity: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}quantity'], + ), + estimatedPrice: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}estimated_price'], + ), addedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}added_at'], @@ -1561,6 +1663,10 @@ class ShoppingItem extends DataClass implements Insertable { /// manual | ai final String source; + + /// AI estimates: how much to buy ("500 g", "6 pots") and a rough price. + final String? quantity; + final double? estimatedPrice; final DateTime addedAt; const ShoppingItem({ required this.id, @@ -1568,6 +1674,8 @@ class ShoppingItem extends DataClass implements Insertable { this.note, required this.done, required this.source, + this.quantity, + this.estimatedPrice, required this.addedAt, }); @override @@ -1580,6 +1688,12 @@ class ShoppingItem extends DataClass implements Insertable { } map['done'] = Variable(done); map['source'] = Variable(source); + if (!nullToAbsent || quantity != null) { + map['quantity'] = Variable(quantity); + } + if (!nullToAbsent || estimatedPrice != null) { + map['estimated_price'] = Variable(estimatedPrice); + } map['added_at'] = Variable(addedAt); return map; } @@ -1591,6 +1705,12 @@ class ShoppingItem extends DataClass implements Insertable { note: note == null && nullToAbsent ? const Value.absent() : Value(note), done: Value(done), source: Value(source), + quantity: quantity == null && nullToAbsent + ? const Value.absent() + : Value(quantity), + estimatedPrice: estimatedPrice == null && nullToAbsent + ? const Value.absent() + : Value(estimatedPrice), addedAt: Value(addedAt), ); } @@ -1606,6 +1726,8 @@ class ShoppingItem extends DataClass implements Insertable { note: serializer.fromJson(json['note']), done: serializer.fromJson(json['done']), source: serializer.fromJson(json['source']), + quantity: serializer.fromJson(json['quantity']), + estimatedPrice: serializer.fromJson(json['estimatedPrice']), addedAt: serializer.fromJson(json['addedAt']), ); } @@ -1618,6 +1740,8 @@ class ShoppingItem extends DataClass implements Insertable { 'note': serializer.toJson(note), 'done': serializer.toJson(done), 'source': serializer.toJson(source), + 'quantity': serializer.toJson(quantity), + 'estimatedPrice': serializer.toJson(estimatedPrice), 'addedAt': serializer.toJson(addedAt), }; } @@ -1628,6 +1752,8 @@ class ShoppingItem extends DataClass implements Insertable { Value note = const Value.absent(), bool? done, String? source, + Value quantity = const Value.absent(), + Value estimatedPrice = const Value.absent(), DateTime? addedAt, }) => ShoppingItem( id: id ?? this.id, @@ -1635,6 +1761,10 @@ class ShoppingItem extends DataClass implements Insertable { note: note.present ? note.value : this.note, done: done ?? this.done, source: source ?? this.source, + quantity: quantity.present ? quantity.value : this.quantity, + estimatedPrice: estimatedPrice.present + ? estimatedPrice.value + : this.estimatedPrice, addedAt: addedAt ?? this.addedAt, ); ShoppingItem copyWithCompanion(ShoppingItemsCompanion data) { @@ -1644,6 +1774,10 @@ class ShoppingItem extends DataClass implements Insertable { note: data.note.present ? data.note.value : this.note, done: data.done.present ? data.done.value : this.done, source: data.source.present ? data.source.value : this.source, + quantity: data.quantity.present ? data.quantity.value : this.quantity, + estimatedPrice: data.estimatedPrice.present + ? data.estimatedPrice.value + : this.estimatedPrice, addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt, ); } @@ -1656,13 +1790,24 @@ class ShoppingItem extends DataClass implements Insertable { ..write('note: $note, ') ..write('done: $done, ') ..write('source: $source, ') + ..write('quantity: $quantity, ') + ..write('estimatedPrice: $estimatedPrice, ') ..write('addedAt: $addedAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name, note, done, source, addedAt); + int get hashCode => Object.hash( + id, + name, + note, + done, + source, + quantity, + estimatedPrice, + addedAt, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1672,6 +1817,8 @@ class ShoppingItem extends DataClass implements Insertable { other.note == this.note && other.done == this.done && other.source == this.source && + other.quantity == this.quantity && + other.estimatedPrice == this.estimatedPrice && other.addedAt == this.addedAt); } @@ -1681,6 +1828,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { final Value note; final Value done; final Value source; + final Value quantity; + final Value estimatedPrice; final Value addedAt; const ShoppingItemsCompanion({ this.id = const Value.absent(), @@ -1688,6 +1837,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { this.note = const Value.absent(), this.done = const Value.absent(), this.source = const Value.absent(), + this.quantity = const Value.absent(), + this.estimatedPrice = const Value.absent(), this.addedAt = const Value.absent(), }); ShoppingItemsCompanion.insert({ @@ -1696,6 +1847,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { this.note = const Value.absent(), this.done = const Value.absent(), this.source = const Value.absent(), + this.quantity = const Value.absent(), + this.estimatedPrice = const Value.absent(), this.addedAt = const Value.absent(), }) : name = Value(name); static Insertable custom({ @@ -1704,6 +1857,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { Expression? note, Expression? done, Expression? source, + Expression? quantity, + Expression? estimatedPrice, Expression? addedAt, }) { return RawValuesInsertable({ @@ -1712,6 +1867,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { if (note != null) 'note': note, if (done != null) 'done': done, if (source != null) 'source': source, + if (quantity != null) 'quantity': quantity, + if (estimatedPrice != null) 'estimated_price': estimatedPrice, if (addedAt != null) 'added_at': addedAt, }); } @@ -1722,6 +1879,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { Value? note, Value? done, Value? source, + Value? quantity, + Value? estimatedPrice, Value? addedAt, }) { return ShoppingItemsCompanion( @@ -1730,6 +1889,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { note: note ?? this.note, done: done ?? this.done, source: source ?? this.source, + quantity: quantity ?? this.quantity, + estimatedPrice: estimatedPrice ?? this.estimatedPrice, addedAt: addedAt ?? this.addedAt, ); } @@ -1752,6 +1913,12 @@ class ShoppingItemsCompanion extends UpdateCompanion { if (source.present) { map['source'] = Variable(source.value); } + if (quantity.present) { + map['quantity'] = Variable(quantity.value); + } + if (estimatedPrice.present) { + map['estimated_price'] = Variable(estimatedPrice.value); + } if (addedAt.present) { map['added_at'] = Variable(addedAt.value); } @@ -1766,6 +1933,8 @@ class ShoppingItemsCompanion extends UpdateCompanion { ..write('note: $note, ') ..write('done: $done, ') ..write('source: $source, ') + ..write('quantity: $quantity, ') + ..write('estimatedPrice: $estimatedPrice, ') ..write('addedAt: $addedAt') ..write(')')) .toString(); @@ -2387,6 +2556,300 @@ class SavedMealsCompanion extends UpdateCompanion { } } +class $CookedMealsTable extends CookedMeals + with TableInfo<$CookedMealsTable, CookedMeal> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CookedMealsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _kcalMeta = const VerificationMeta('kcal'); + @override + late final GeneratedColumn kcal = GeneratedColumn( + 'kcal', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _cookedAtMeta = const VerificationMeta( + 'cookedAt', + ); + @override + late final GeneratedColumn cookedAt = GeneratedColumn( + 'cooked_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, title, kcal, cookedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cooked_meals'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('kcal')) { + context.handle( + _kcalMeta, + kcal.isAcceptableOrUnknown(data['kcal']!, _kcalMeta), + ); + } else if (isInserting) { + context.missing(_kcalMeta); + } + if (data.containsKey('cooked_at')) { + context.handle( + _cookedAtMeta, + cookedAt.isAcceptableOrUnknown(data['cooked_at']!, _cookedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CookedMeal map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CookedMeal( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + kcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}kcal'], + )!, + cookedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cooked_at'], + )!, + ); + } + + @override + $CookedMealsTable createAlias(String alias) { + return $CookedMealsTable(attachedDatabase, alias); + } +} + +class CookedMeal extends DataClass implements Insertable { + final int id; + final String title; + final double kcal; + final DateTime cookedAt; + const CookedMeal({ + required this.id, + required this.title, + required this.kcal, + required this.cookedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['kcal'] = Variable(kcal); + map['cooked_at'] = Variable(cookedAt); + return map; + } + + CookedMealsCompanion toCompanion(bool nullToAbsent) { + return CookedMealsCompanion( + id: Value(id), + title: Value(title), + kcal: Value(kcal), + cookedAt: Value(cookedAt), + ); + } + + factory CookedMeal.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CookedMeal( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + kcal: serializer.fromJson(json['kcal']), + cookedAt: serializer.fromJson(json['cookedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'kcal': serializer.toJson(kcal), + 'cookedAt': serializer.toJson(cookedAt), + }; + } + + CookedMeal copyWith({ + int? id, + String? title, + double? kcal, + DateTime? cookedAt, + }) => CookedMeal( + id: id ?? this.id, + title: title ?? this.title, + kcal: kcal ?? this.kcal, + cookedAt: cookedAt ?? this.cookedAt, + ); + CookedMeal copyWithCompanion(CookedMealsCompanion data) { + return CookedMeal( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + kcal: data.kcal.present ? data.kcal.value : this.kcal, + cookedAt: data.cookedAt.present ? data.cookedAt.value : this.cookedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CookedMeal(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('kcal: $kcal, ') + ..write('cookedAt: $cookedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, kcal, cookedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CookedMeal && + other.id == this.id && + other.title == this.title && + other.kcal == this.kcal && + other.cookedAt == this.cookedAt); +} + +class CookedMealsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value kcal; + final Value cookedAt; + const CookedMealsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.kcal = const Value.absent(), + this.cookedAt = const Value.absent(), + }); + CookedMealsCompanion.insert({ + this.id = const Value.absent(), + required String title, + required double kcal, + this.cookedAt = const Value.absent(), + }) : title = Value(title), + kcal = Value(kcal); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? kcal, + Expression? cookedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (kcal != null) 'kcal': kcal, + if (cookedAt != null) 'cooked_at': cookedAt, + }); + } + + CookedMealsCompanion copyWith({ + Value? id, + Value? title, + Value? kcal, + Value? cookedAt, + }) { + return CookedMealsCompanion( + id: id ?? this.id, + title: title ?? this.title, + kcal: kcal ?? this.kcal, + cookedAt: cookedAt ?? this.cookedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (kcal.present) { + map['kcal'] = Variable(kcal.value); + } + if (cookedAt.present) { + map['cooked_at'] = Variable(cookedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CookedMealsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('kcal: $kcal, ') + ..write('cookedAt: $cookedAt') + ..write(')')) + .toString(); + } +} + class $ArticlesTable extends Articles with TableInfo<$ArticlesTable, Article> { @override final GeneratedDatabase attachedDatabase; @@ -2734,6 +3197,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { $ConsumptionEntriesTable(this); late final $ShoppingItemsTable shoppingItems = $ShoppingItemsTable(this); late final $SavedMealsTable savedMeals = $SavedMealsTable(this); + late final $CookedMealsTable cookedMeals = $CookedMealsTable(this); late final $ArticlesTable articles = $ArticlesTable(this); @override Iterable> get allTables => @@ -2744,6 +3208,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { consumptionEntries, shoppingItems, savedMeals, + cookedMeals, articles, ]; } @@ -2763,6 +3228,7 @@ typedef $$PantryItemsTableCreateCompanionBuilder = Value packageQuantity, Value unitCount, Value perishable, + Value category, Value amountLeft, Value addedAt, Value updatedAt, @@ -2782,6 +3248,7 @@ typedef $$PantryItemsTableUpdateCompanionBuilder = Value packageQuantity, Value unitCount, Value perishable, + Value category, Value amountLeft, Value addedAt, Value updatedAt, @@ -2861,6 +3328,11 @@ class $$PantryItemsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => ColumnFilters(column), @@ -2951,6 +3423,11 @@ class $$PantryItemsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => ColumnOrderings(column), @@ -3029,6 +3506,9 @@ class $$PantryItemsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + GeneratedColumn get amountLeft => $composableBuilder( column: $table.amountLeft, builder: (column) => column, @@ -3085,6 +3565,7 @@ class $$PantryItemsTableTableManager Value packageQuantity = const Value.absent(), Value unitCount = const Value.absent(), Value perishable = const Value.absent(), + Value category = const Value.absent(), Value amountLeft = const Value.absent(), Value addedAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -3102,6 +3583,7 @@ class $$PantryItemsTableTableManager packageQuantity: packageQuantity, unitCount: unitCount, perishable: perishable, + category: category, amountLeft: amountLeft, addedAt: addedAt, updatedAt: updatedAt, @@ -3121,6 +3603,7 @@ class $$PantryItemsTableTableManager Value packageQuantity = const Value.absent(), Value unitCount = const Value.absent(), Value perishable = const Value.absent(), + Value category = const Value.absent(), Value amountLeft = const Value.absent(), Value addedAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -3138,6 +3621,7 @@ class $$PantryItemsTableTableManager packageQuantity: packageQuantity, unitCount: unitCount, perishable: perishable, + category: category, amountLeft: amountLeft, addedAt: addedAt, updatedAt: updatedAt, @@ -3421,6 +3905,8 @@ typedef $$ShoppingItemsTableCreateCompanionBuilder = Value note, Value done, Value source, + Value quantity, + Value estimatedPrice, Value addedAt, }); typedef $$ShoppingItemsTableUpdateCompanionBuilder = @@ -3430,6 +3916,8 @@ typedef $$ShoppingItemsTableUpdateCompanionBuilder = Value note, Value done, Value source, + Value quantity, + Value estimatedPrice, Value addedAt, }); @@ -3467,6 +3955,16 @@ class $$ShoppingItemsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get quantity => $composableBuilder( + column: $table.quantity, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get estimatedPrice => $composableBuilder( + column: $table.estimatedPrice, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get addedAt => $composableBuilder( column: $table.addedAt, builder: (column) => ColumnFilters(column), @@ -3507,6 +4005,16 @@ class $$ShoppingItemsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get quantity => $composableBuilder( + column: $table.quantity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get estimatedPrice => $composableBuilder( + column: $table.estimatedPrice, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get addedAt => $composableBuilder( column: $table.addedAt, builder: (column) => ColumnOrderings(column), @@ -3537,6 +4045,14 @@ class $$ShoppingItemsTableAnnotationComposer GeneratedColumn get source => $composableBuilder(column: $table.source, builder: (column) => column); + GeneratedColumn get quantity => + $composableBuilder(column: $table.quantity, builder: (column) => column); + + GeneratedColumn get estimatedPrice => $composableBuilder( + column: $table.estimatedPrice, + builder: (column) => column, + ); + GeneratedColumn get addedAt => $composableBuilder(column: $table.addedAt, builder: (column) => column); } @@ -3577,6 +4093,8 @@ class $$ShoppingItemsTableTableManager Value note = const Value.absent(), Value done = const Value.absent(), Value source = const Value.absent(), + Value quantity = const Value.absent(), + Value estimatedPrice = const Value.absent(), Value addedAt = const Value.absent(), }) => ShoppingItemsCompanion( id: id, @@ -3584,6 +4102,8 @@ class $$ShoppingItemsTableTableManager note: note, done: done, source: source, + quantity: quantity, + estimatedPrice: estimatedPrice, addedAt: addedAt, ), createCompanionCallback: @@ -3593,6 +4113,8 @@ class $$ShoppingItemsTableTableManager Value note = const Value.absent(), Value done = const Value.absent(), Value source = const Value.absent(), + Value quantity = const Value.absent(), + Value estimatedPrice = const Value.absent(), Value addedAt = const Value.absent(), }) => ShoppingItemsCompanion.insert( id: id, @@ -3600,6 +4122,8 @@ class $$ShoppingItemsTableTableManager note: note, done: done, source: source, + quantity: quantity, + estimatedPrice: estimatedPrice, addedAt: addedAt, ), withReferenceMapper: (p0) => p0 @@ -3921,6 +4445,181 @@ typedef $$SavedMealsTableProcessedTableManager = SavedMeal, PrefetchHooks Function() >; +typedef $$CookedMealsTableCreateCompanionBuilder = + CookedMealsCompanion Function({ + Value id, + required String title, + required double kcal, + Value cookedAt, + }); +typedef $$CookedMealsTableUpdateCompanionBuilder = + CookedMealsCompanion Function({ + Value id, + Value title, + Value kcal, + Value cookedAt, + }); + +class $$CookedMealsTableFilterComposer + extends Composer<_$AppDatabase, $CookedMealsTable> { + $$CookedMealsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cookedAt => $composableBuilder( + column: $table.cookedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CookedMealsTableOrderingComposer + extends Composer<_$AppDatabase, $CookedMealsTable> { + $$CookedMealsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kcal => $composableBuilder( + column: $table.kcal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cookedAt => $composableBuilder( + column: $table.cookedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CookedMealsTableAnnotationComposer + extends Composer<_$AppDatabase, $CookedMealsTable> { + $$CookedMealsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get kcal => + $composableBuilder(column: $table.kcal, builder: (column) => column); + + GeneratedColumn get cookedAt => + $composableBuilder(column: $table.cookedAt, builder: (column) => column); +} + +class $$CookedMealsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CookedMealsTable, + CookedMeal, + $$CookedMealsTableFilterComposer, + $$CookedMealsTableOrderingComposer, + $$CookedMealsTableAnnotationComposer, + $$CookedMealsTableCreateCompanionBuilder, + $$CookedMealsTableUpdateCompanionBuilder, + ( + CookedMeal, + BaseReferences<_$AppDatabase, $CookedMealsTable, CookedMeal>, + ), + CookedMeal, + PrefetchHooks Function() + > { + $$CookedMealsTableTableManager(_$AppDatabase db, $CookedMealsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CookedMealsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CookedMealsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CookedMealsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value kcal = const Value.absent(), + Value cookedAt = const Value.absent(), + }) => CookedMealsCompanion( + id: id, + title: title, + kcal: kcal, + cookedAt: cookedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required double kcal, + Value cookedAt = const Value.absent(), + }) => CookedMealsCompanion.insert( + id: id, + title: title, + kcal: kcal, + cookedAt: cookedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CookedMealsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CookedMealsTable, + CookedMeal, + $$CookedMealsTableFilterComposer, + $$CookedMealsTableOrderingComposer, + $$CookedMealsTableAnnotationComposer, + $$CookedMealsTableCreateCompanionBuilder, + $$CookedMealsTableUpdateCompanionBuilder, + ( + CookedMeal, + BaseReferences<_$AppDatabase, $CookedMealsTable, CookedMeal>, + ), + CookedMeal, + PrefetchHooks Function() + >; typedef $$ArticlesTableCreateCompanionBuilder = ArticlesCompanion Function({ Value id, @@ -4121,6 +4820,8 @@ class $AppDatabaseManager { $$ShoppingItemsTableTableManager(_db, _db.shoppingItems); $$SavedMealsTableTableManager get savedMeals => $$SavedMealsTableTableManager(_db, _db.savedMeals); + $$CookedMealsTableTableManager get cookedMeals => + $$CookedMealsTableTableManager(_db, _db.cookedMeals); $$ArticlesTableTableManager get articles => $$ArticlesTableTableManager(_db, _db.articles); } diff --git a/lib/data/kitchen_category.dart b/lib/data/kitchen_category.dart new file mode 100644 index 0000000..118e21e --- /dev/null +++ b/lib/data/kitchen_category.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +/// The "drawers" the kitchen list is organized into. +enum KitchenCategory { + fridge('fridge', 'Fridge', Icons.kitchen), + freezer('freezer', 'Freezer', Icons.ac_unit), + cupboard('cupboard', 'Cupboard', Icons.inventory_2_outlined), + snacks('snacks', 'Snacks', Icons.cookie_outlined), + drinks('drinks', 'Drinks', Icons.local_cafe_outlined), + other('other', 'Other', Icons.category_outlined); + + const KitchenCategory(this.value, this.label, this.icon); + + final String value; + final String label; + final IconData icon; + + static KitchenCategory fromValue(String? value) => + KitchenCategory.values.firstWhere( + (c) => c.value == value, + orElse: () => KitchenCategory.other, + ); +} diff --git a/lib/data/progress.dart b/lib/data/progress.dart new file mode 100644 index 0000000..e0da6e0 --- /dev/null +++ b/lib/data/progress.dart @@ -0,0 +1,50 @@ +/// Streak & cumulative-deficit math for the Track page. +/// +/// The model is deliberately simple: the user estimates a natural daily +/// expenditure (maintenance, default 2200 kcal). Every streak day that ends +/// under the daily goal banks `maintenance - eaten` kcal of deficit, and +/// 7700 kcal of cumulative deficit ≈ 1 kg of fat lost. +library; + +class ProgressStats { + const ProgressStats({required this.streakDays, required this.kcalDeficit}); + + /// Consecutive days (ending today) at or under the daily goal. + final int streakDays; + + /// Kcal banked below maintenance over the streak (finished days only — + /// today's deficit isn't counted until the day is over). + final double kcalDeficit; + + static const kcalPerKg = 7700; + + double get kgLost => kcalDeficit / kcalPerKg; +} + +/// [kcalByDay] maps a calendar day (midnight-keyed `DateTime(y, m, d)`) to +/// the total kcal eaten that day. Days with nothing logged must be absent. +ProgressStats computeProgress({ + required Map kcalByDay, + required int goalKcal, + required int burnKcal, + required DateTime today, +}) { + final day0 = DateTime(today.year, today.month, today.day); + final todayKcal = kcalByDay[day0] ?? 0; + + // Blowing today's goal breaks the streak immediately — that is the point. + if (todayKcal > goalKcal) { + return const ProgressStats(streakDays: 0, kcalDeficit: 0); + } + + var streak = todayKcal > 0 ? 1 : 0; + var deficit = 0.0; + for (var i = 1; ; i++) { + final kcal = kcalByDay[day0.subtract(Duration(days: i))]; + // A day with no logs ends the streak: not logging counts as cheating. + if (kcal == null || kcal <= 0 || kcal > goalKcal) break; + streak++; + deficit += (burnKcal - kcal).clamp(0, double.infinity); + } + return ProgressStats(streakDays: streak, kcalDeficit: deficit); +} diff --git a/lib/data/services/ai_service.dart b/lib/data/services/ai_service.dart index 71e7784..c2bea08 100644 --- a/lib/data/services/ai_service.dart +++ b/lib/data/services/ai_service.dart @@ -53,17 +53,30 @@ class MealSuggestion { /// A grocery purchase suggestion produced by the AI. class GrocerySuggestion { - const GrocerySuggestion({required this.name, required this.reason}); + const GrocerySuggestion({ + required this.name, + required this.reason, + this.quantity, + this.priceEur, + }); factory GrocerySuggestion.fromJson(Map json) { return GrocerySuggestion( name: json['name'] as String, reason: json['reason'] as String, + quantity: json['quantity'] as String?, + priceEur: (json['price_eur'] as num?)?.toDouble(), ); } final String name; final String reason; + + /// Suggested amount to buy, e.g. "500 g" or "6 pots". + final String? quantity; + + /// Rough French-supermarket price estimate. + final double? priceEur; } /// Client for the OpenAI Chat Completions API through `dart_openai`. @@ -77,6 +90,22 @@ class AiService { /// Cheap, fast router model — plenty for summaries and meal ideas. static const model = 'gpt-5-nano'; + /// The owner's profile, injected into the food prompts so suggestions fit + /// his actual life. Personal app — hardcoding this is a feature, not a bug. + /// Prompts are written in French on purpose: the model's French culinary + /// vector space is where the good, realistic food ideas live. + static const _userProfile = + 'Profil du propriétaire : homme de 25 ans, 120 kg, travail de bureau ' + 'sédentaire, en déficit calorique volontaire (~1900 kcal/jour pour une ' + 'dépense estimée à ~2200). Il est déjà descendu à 96 kg puis a repris du ' + 'poids en retombant dans les excès — l\'enjeu est la DURABILITÉ. Accro ' + 'au sucre et aux produits transformés (chips au vinaigre, fromage, ' + 'barres chocolatées, plats gras). Déteste éplucher, découper et les ' + 'recettes longues. Le midi en semaine, il mange souvent au restaurant ' + 'avec des collègues (poké, curry, bento…). Le soir après le travail, ' + 'gros risque de craquage sur du sucré ou du gras : des dîners ' + 'rassasiants, riches en protéines et pauvres en sucre sont essentiels.'; + static OpenAIChatCompletionChoiceMessageModel _message( OpenAIChatMessageRole role, String text, @@ -136,26 +165,29 @@ class AiService { }) async { final result = await _structuredRequest( system: - 'You are a pragmatic home-cooking assistant for a lazy person who is ' - 'addicted to sugar and quick meals, and wants to eat healthier with ' - 'minimal effort. Suggest simple, realistic meals that mostly use ' - 'what is already in the kitchen, in quantities that respect what is ' - 'actually left. Give priority to perishable ingredients so nothing ' - 'goes to waste. Balance the rest of the day nutritionally against ' - 'what was already eaten (e.g. lighter and more vegetables after a ' - 'heavy or sugary day). Prefer few steps and short cooking times. ' - 'Answer in $language, as JSON.', + 'Tu es un assistant de cuisine pragmatique, ancré dans la culture ' + 'gastronomique française : simple, bon, réaliste. $_userProfile\n' + 'Propose des plats qui utilisent en priorité ce qui est déjà dans ' + 'la cuisine, en respectant les quantités réellement restantes. ' + 'Donne la priorité aux ingrédients périssables pour éviter le ' + 'gaspillage. Équilibre le reste de la journée par rapport à ce qui ' + 'a déjà été mangé (plus léger et plus de légumes après une journée ' + 'chargée ou sucrée). Très peu d\'étapes, temps courts, protéines ' + 'rassasiantes, peu de sucre. Réponds en $language, au format JSON.', userContent: - 'Here is my kitchen inventory as JSON (amount_left_percent or ' - 'units_left say how much of each package remains; perishable items ' - 'should be used first):\n${jsonEncode(pantry)}\n\n' - 'What I already ate today (${consumedKcal.round()} kcal consumed of ' - 'my ${dailyGoalKcal.round()} kcal daily goal):\n' - '${eatenToday.isEmpty ? 'nothing yet' : jsonEncode(eatenToday)}\n\n' - 'I have about ${remainingKcal.round()} kcal left for today. ' - 'Suggest exactly 3 healthy, low-effort meals I can make right now. ' - 'Only list an ingredient in missing_ingredients if I truly need to ' - 'buy it (assume I have water, salt, pepper and basic oil).', + 'Voici l\'inventaire de ma cuisine en JSON (amount_left_percent ou ' + 'units_left indiquent ce qui reste de chaque paquet ; ' + 'perishable = à consommer en priorité) :\n${jsonEncode(pantry)}\n\n' + 'Ce que j\'ai déjà mangé aujourd\'hui ' + '(${consumedKcal.round()} kcal consommées sur un objectif de ' + '${dailyGoalKcal.round()} kcal) :\n' + '${eatenToday.isEmpty ? 'rien pour l\'instant' : jsonEncode(eatenToday)}\n\n' + 'Il est ${DateTime.now().hour} h et il me reste environ ' + '${remainingKcal.round()} kcal pour aujourd\'hui. Propose exactement ' + '3 repas sains et sans effort, adaptés à ce moment de la journée, ' + 'que je peux faire maintenant. Ne mets un ingrédient dans ' + 'missing_ingredients que si je dois vraiment l\'acheter (je possède ' + 'eau, sel, poivre et huile de base).', schemaName: 'meal_suggestions', schema: { 'type': 'object', @@ -214,16 +246,21 @@ class AiService { }) async { final result = await _structuredRequest( system: - 'You are a grocery-planning assistant for a lazy person trying to ' - 'eat healthier. Suggest practical items to buy so that healthy, ' - 'low-effort meals are always possible. Favor staples and fresh ' - 'items that complement what they own. Answer in $language, as JSON.', + 'Tu es un assistant de courses ancré dans la culture gastronomique ' + 'française. $_userProfile\n' + 'Suggère des achats pratiques pour que des repas sains et sans ' + 'effort soient toujours possibles : des basiques qui se gardent, du ' + 'frais facile à utiliser sans préparation, et des alternatives ' + 'moins sucrées / moins grasses à ses envies habituelles. Réponds ' + 'en $language, au format JSON.', userContent: - 'Kitchen inventory:\n${jsonEncode(pantry)}\n\n' - 'Recently eaten: ${jsonEncode(recentlyEaten)}\n' - 'Already on my shopping list: ${jsonEncode(alreadyOnList)}\n\n' - 'Suggest up to 8 items I should buy (do not repeat items already on ' - 'the list), each with a very short reason.', + 'Inventaire de la cuisine :\n${jsonEncode(pantry)}\n\n' + 'Mangé récemment : ${jsonEncode(recentlyEaten)}\n' + 'Déjà sur ma liste de courses : ${jsonEncode(alreadyOnList)}\n\n' + 'Suggère jusqu\'à 8 articles à acheter (sans répéter ceux déjà sur ' + 'la liste), chacun avec : une raison très courte, la quantité ' + 'conseillée (ex "500 g", "6 pots") et un prix estimé en euros dans ' + 'un supermarché français (approximation grossière acceptée).', schemaName: 'grocery_suggestions', schema: { 'type': 'object', @@ -235,8 +272,10 @@ class AiService { 'properties': { 'name': {'type': 'string'}, 'reason': {'type': 'string'}, + 'quantity': {'type': 'string'}, + 'price_eur': {'type': 'number'}, }, - 'required': ['name', 'reason'], + 'required': ['name', 'reason', 'quantity', 'price_eur'], 'additionalProperties': false, }, }, diff --git a/lib/data/services/settings_service.dart b/lib/data/services/settings_service.dart index 0fd2790..19cf51f 100644 --- a/lib/data/services/settings_service.dart +++ b/lib/data/services/settings_service.dart @@ -6,21 +6,29 @@ class AppSettings { required this.hasApiKey, required this.language, required this.dailyKcalGoal, + required this.dailyBurnKcal, }); final bool hasApiKey; final String language; final int dailyKcalGoal; + /// Estimated natural daily energy expenditure (maintenance kcal). The + /// difference with what is eaten builds the cumulative-deficit stat + /// (7700 kcal ≈ 1 kg of fat). + final int dailyBurnKcal; + AppSettings copyWith({ bool? hasApiKey, String? language, int? dailyKcalGoal, + int? dailyBurnKcal, }) { return AppSettings( hasApiKey: hasApiKey ?? this.hasApiKey, language: language ?? this.language, dailyKcalGoal: dailyKcalGoal ?? this.dailyKcalGoal, + dailyBurnKcal: dailyBurnKcal ?? this.dailyBurnKcal, ); } } @@ -35,7 +43,9 @@ class SettingsService { static const _apiKeyKey = 'openaiApiKey'; static const _languageKey = 'language'; static const _kcalGoalKey = 'dailyKcalGoal'; + static const _kcalBurnKey = 'dailyBurnKcal'; static const defaultKcalGoal = 2200; + static const defaultKcalBurn = 2200; static const defaultLanguage = 'english'; final FlutterSecureStorage _secure; @@ -59,10 +69,12 @@ class SettingsService { final apiKey = await getApiKey(); final language = await _prefs.getString(_languageKey); final goal = await _prefs.getInt(_kcalGoalKey); + final burn = await _prefs.getInt(_kcalBurnKey); return AppSettings( hasApiKey: apiKey != null && apiKey.isNotEmpty, language: language ?? defaultLanguage, dailyKcalGoal: goal ?? defaultKcalGoal, + dailyBurnKcal: burn ?? defaultKcalBurn, ); } @@ -70,4 +82,6 @@ class SettingsService { _prefs.setString(_languageKey, language); Future setDailyKcalGoal(int goal) => _prefs.setInt(_kcalGoalKey, goal); + + Future setDailyBurnKcal(int burn) => _prefs.setInt(_kcalBurnKey, burn); } diff --git a/lib/features/groceries/groceries_page.dart b/lib/features/groceries/groceries_page.dart index aca0cc4..55d7f8a 100644 --- a/lib/features/groceries/groceries_page.dart +++ b/lib/features/groceries/groceries_page.dart @@ -83,6 +83,8 @@ class _GroceriesPageState extends ConsumerState { name: suggestion.name, note: Value(suggestion.reason), source: const Value('ai'), + quantity: Value(suggestion.quantity), + estimatedPrice: Value(suggestion.priceEur), ), ); added++; @@ -109,6 +111,18 @@ class _GroceriesPageState extends ConsumerState { } } + /// "note · 500 g · ~2.50 €" — whatever is known about the item. + static Widget? _subtitleFor(ShoppingItem item) { + final parts = [ + if (item.note != null) item.note!, + if (item.quantity != null) item.quantity!, + if (item.estimatedPrice != null) + '~${item.estimatedPrice!.toStringAsFixed(2)} €', + ]; + if (parts.isEmpty) return null; + return Text(parts.join(' · '), maxLines: 2, overflow: TextOverflow.ellipsis); + } + Future _deleteWithUndo(ShoppingItem item) async { final db = ref.read(databaseProvider); final messenger = ScaffoldMessenger.of(context); @@ -124,6 +138,8 @@ class _GroceriesPageState extends ConsumerState { note: Value(item.note), done: Value(item.done), source: Value(item.source), + quantity: Value(item.quantity), + estimatedPrice: Value(item.estimatedPrice), addedAt: Value(item.addedAt), ), ), @@ -184,6 +200,10 @@ class _GroceriesPageState extends ConsumerState { Widget build(BuildContext context) { final items = ref.watch(shoppingProvider).value ?? []; final hasDone = items.any((i) => i.done); + // Rough cart total for what still has to be bought. + final remainingPrice = items + .where((i) => !i.done && i.estimatedPrice != null) + .fold(0, (sum, i) => sum + i.estimatedPrice!); return Scaffold( appBar: AppBar( @@ -230,6 +250,27 @@ class _GroceriesPageState extends ConsumerState { onSubmitted: (_) => _addManual(), ), ), + if (remainingPrice > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: Row( + children: [ + Icon( + Icons.shopping_cart_checkout, + size: 18, + color: Theme.of(context).colorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Estimated cart: ≈ ${remainingPrice.toStringAsFixed(2)} €', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ], + ), + ), Expanded( child: items.isEmpty ? const EmptyState( @@ -265,13 +306,7 @@ class _GroceriesPageState extends ConsumerState { ) : null, ), - subtitle: item.note == null - ? null - : Text( - item.note!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + subtitle: _subtitleFor(item), secondary: item.source == 'ai' ? const Icon(Icons.auto_awesome, size: 18) : null, diff --git a/lib/features/kitchen/kitchen_page.dart b/lib/features/kitchen/kitchen_page.dart index 8282daf..ede042d 100644 --- a/lib/features/kitchen/kitchen_page.dart +++ b/lib/features/kitchen/kitchen_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; +import '../../data/kitchen_category.dart'; import '../../data/quantity.dart'; import '../../data/services/off_service.dart'; import '../../providers.dart'; @@ -143,14 +144,7 @@ class _KitchenPageState extends ConsumerState { message: 'No item matches the current search or filters.', ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), - itemCount: visible.length, - separatorBuilder: (_, _) => - const SizedBox(height: 12), - itemBuilder: (context, index) => - _PantryCard(item: visible[index]), - ), + : _DrawerList(items: visible), ), ], ), @@ -224,6 +218,53 @@ class _KitchenPageState extends ConsumerState { } } +/// The pantry organized into collapsible "drawers" (fridge, cupboard…). +class _DrawerList extends StatelessWidget { + const _DrawerList({required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + final grouped = >{}; + for (final item in items) { + grouped + .putIfAbsent(KitchenCategory.fromValue(item.category), () => []) + .add(item); + } + final scheme = Theme.of(context).colorScheme; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 96), + children: [ + for (final category in KitchenCategory.values) + if (grouped[category] case final drawer?) + ExpansionTile( + initiallyExpanded: true, + maintainState: true, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Icon(category.icon, color: scheme.primary), + title: Text( + '${category.label} · ${drawer.length}', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + children: [ + for (final item in drawer) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _PantryCard(item: item), + ), + ], + ), + ], + ); + } +} + class _PantryCard extends ConsumerStatefulWidget { const _PantryCard({required this.item}); @@ -454,6 +495,7 @@ class _PantryCardState extends ConsumerState<_PantryCard> { packageQuantity: Value(item.packageQuantity), unitCount: Value(item.unitCount), perishable: Value(item.perishable), + category: Value(item.category), amountLeft: Value(item.amountLeft), addedAt: Value(item.addedAt), ), @@ -546,12 +588,16 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { late final TextEditingController _package; late final TextEditingController _units; late bool _perishable; + late KitchenCategory _category; + bool _categoryTouched = false; @override void initState() { super.initState(); final p = widget.product; final e = widget.existing; + _category = KitchenCategory.fromValue(e?.category ?? 'cupboard'); + _categoryTouched = e != null; _name = TextEditingController( text: e?.name ?? p?.name ?? widget.initialName ?? '', ); @@ -596,6 +642,7 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { packageQuantity: Value(package.isEmpty ? null : package), unitCount: Value((units ?? 0) > 0 ? units : null), perishable: Value(_perishable), + category: Value(_category.value), ), ); } else { @@ -613,6 +660,7 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { packageQuantity: Value(package.isEmpty ? null : package), unitCount: Value((units ?? 0) > 0 ? units : null), perishable: Value(_perishable), + category: Value(_category.value), ), ); } @@ -673,13 +721,43 @@ class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { helperText: 'When set, quantities are tracked in units, not %', ), ), + DropdownMenu( + key: ValueKey(_category), + initialSelection: _category, + label: const Text('Drawer'), + expandedInsets: EdgeInsets.zero, + dropdownMenuEntries: [ + for (final category in KitchenCategory.values) + DropdownMenuEntry( + value: category, + label: category.label, + leadingIcon: Icon(category.icon, size: 20), + ), + ], + onSelected: (value) { + if (value == null) return; + setState(() { + _category = value; + _categoryTouched = true; + }); + }, + ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Perishable'), subtitle: const Text('Fresh food that should be eaten first'), secondary: const Icon(Icons.eco, color: Color(0xFFE8930C)), value: _perishable, - onChanged: (v) => setState(() => _perishable = v), + onChanged: (v) => setState(() { + _perishable = v; + // Perishable food obviously lives in the fridge — until the + // user says otherwise. + if (!_categoryTouched) { + _category = v + ? KitchenCategory.fridge + : KitchenCategory.cupboard; + } + }), ), const SizedBox(height: 8), FilledButton.icon( diff --git a/lib/features/meals/meals_page.dart b/lib/features/meals/meals_page.dart index cf4ae1c..52420e6 100644 --- a/lib/features/meals/meals_page.dart +++ b/lib/features/meals/meals_page.dart @@ -48,10 +48,7 @@ class MealsPage extends ConsumerWidget { ), ), // Idle or done: the saved batch (persisted across restarts) rules. - _ => - meals.isEmpty - ? _Idle(hasApiKey: hasApiKey) - : _SuggestionList(meals: meals), + _ => _MealsBody(meals: meals, hasApiKey: hasApiKey), }, ); } @@ -116,30 +113,126 @@ class _Loading extends StatelessWidget { } } -class _SuggestionList extends ConsumerWidget { - const _SuggestionList({required this.meals}); +/// Suggestions (or the idle pitch) followed by the cook history. +class _MealsBody extends ConsumerWidget { + const _MealsBody({required this.meals, required this.hasApiKey}); final List meals; + final bool hasApiKey; @override Widget build(BuildContext context, WidgetRef ref) { + final cooked = ref.watch(cookedMealsProvider).value ?? []; + if (meals.isEmpty && cooked.isEmpty) return _Idle(hasApiKey: hasApiKey); + return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ - for (final meal in meals) ...[ - _MealCard(meal: meal), - const SizedBox(height: 12), + if (meals.isEmpty) + _Idle(hasApiKey: hasApiKey) + else ...[ + for (final meal in meals) ...[ + _MealCard(meal: meal), + const SizedBox(height: 12), + ], + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Suggest something else'), + onPressed: () => + ref.read(mealSuggestionsProvider.notifier).generate(), + ), + ], + if (cooked.isNotEmpty) ...[ + const SectionHeader('Cooked before'), + Card( + child: Column( + children: [ + for (final meal in cooked) + ListTile( + leading: CircleAvatar( + radius: 18, + backgroundColor: Theme.of( + context, + ).colorScheme.tertiaryContainer, + child: Icon( + Icons.restaurant_menu, + size: 20, + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + title: Text(meal.title), + subtitle: Text( + '${_agoLabel(meal.cookedAt)} · ${meal.kcal.round()} kcal', + ), + trailing: IconButton( + tooltip: 'I made it again', + icon: const Icon(Icons.replay), + onPressed: () => _relogCooked(context, ref, meal), + ), + ), + ], + ), + ), ], - const SizedBox(height: 8), - OutlinedButton.icon( - icon: const Icon(Icons.refresh), - label: const Text('Suggest something else'), - onPressed: () => - ref.read(mealSuggestionsProvider.notifier).generate(), - ), ], ); } + + static String _agoLabel(DateTime date) { + final days = DateTime.now().difference(date).inDays; + if (days <= 0) return 'today'; + if (days == 1) return 'yesterday'; + if (days < 30) return '$days days ago'; + return '${(days / 30).floor()} month${days >= 60 ? 's' : ''} ago'; + } + + Future _relogCooked( + BuildContext context, + WidgetRef ref, + CookedMeal meal, + ) async { + final type = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Log "${meal.title}" (${meal.kcal.round()} kcal) as:', + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + ), + for (final type in MealType.values) + ListTile( + leading: Icon(type.icon, color: type.color), + title: Text(type.label), + onTap: () => Navigator.pop(sheetContext, type), + ), + ], + ), + ), + ); + if (type == null) return; + final db = ref.read(databaseProvider); + await db.logConsumption( + ConsumptionEntriesCompanion.insert( + name: meal.title, + kcal: meal.kcal, + mealType: type.value, + ), + ); + await db.addCookedMeal( + CookedMealsCompanion.insert(title: meal.title, kcal: meal.kcal), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Logged ${meal.kcal.round()} kcal. Enjoy!')), + ); + } + } } class _MealCard extends ConsumerWidget { @@ -237,10 +330,36 @@ class _MealCard extends ConsumerWidget { tilePadding: EdgeInsets.zero, shape: const Border(), title: Text( - 'Steps', + 'Ingredients & steps', style: Theme.of(context).textTheme.labelLarge, ), children: [ + // Everything the recipe needs, missing items flagged. + for (final ingredient in usedIngredients) + ListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.check_circle_outline, + size: 20, + color: scheme.primary, + ), + title: Text(ingredient), + ), + for (final ingredient in missingIngredients) + ListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.remove_shopping_cart_outlined, + size: 20, + color: scheme.error, + ), + title: Text('$ingredient (to buy)'), + ), + const Divider(), for (final (index, step) in steps.indexed) ListTile( dense: true, @@ -334,6 +453,9 @@ class _MealCard extends ConsumerWidget { ), ); await db.setSavedMealDone(meal.id); + await db.addCookedMeal( + CookedMealsCompanion.insert(title: meal.title, kcal: meal.kcal), + ); // Cooking used up ingredients: offer to adjust the kitchen quantities. if (!context.mounted) return; diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 633fa85..d263109 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -15,6 +15,7 @@ class SettingsPage extends ConsumerStatefulWidget { class _SettingsPageState extends ConsumerState { final _apiKeyController = TextEditingController(); final _goalController = TextEditingController(); + final _burnController = TextEditingController(); bool _obscureKey = true; @override @@ -23,6 +24,7 @@ class _SettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider).value; if (settings != null) { _goalController.text = settings.dailyKcalGoal.toString(); + _burnController.text = settings.dailyBurnKcal.toString(); } } @@ -30,6 +32,7 @@ class _SettingsPageState extends ConsumerState { void dispose() { _apiKeyController.dispose(); _goalController.dispose(); + _burnController.dispose(); super.dispose(); } @@ -52,6 +55,13 @@ class _SettingsPageState extends ConsumerState { if (mounted) FocusScope.of(context).unfocus(); } + Future _saveBurn() async { + final burn = int.tryParse(_burnController.text.trim()); + if (burn == null || burn <= 0) return; + await ref.read(settingsProvider.notifier).setDailyBurnKcal(burn); + if (mounted) FocusScope.of(context).unfocus(); + } + @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider).value; @@ -160,6 +170,19 @@ class _SettingsPageState extends ConsumerState { onSubmitted: (_) => _saveGoal(), onTapOutside: (_) => _saveGoal(), ), + const SizedBox(height: 16), + TextField( + controller: _burnController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Maintenance kcal (daily burn estimate)', + helperText: + 'Used for the streak deficit → kg lost estimate ' + '(7700 kcal ≈ 1 kg)', + ), + onSubmitted: (_) => _saveBurn(), + onTapOutside: (_) => _saveBurn(), + ), ], ), ), diff --git a/lib/features/track/track_page.dart b/lib/features/track/track_page.dart index 9d70b5b..d89f233 100644 --- a/lib/features/track/track_page.dart +++ b/lib/features/track/track_page.dart @@ -7,23 +7,62 @@ import 'package:go_router/go_router.dart'; import '../../data/db/database.dart'; import '../../data/meal_type.dart'; +import '../../data/progress.dart'; import '../../data/quantity.dart'; import '../../providers.dart'; import '../../widgets/common.dart'; import '../../widgets/log_portion_sheet.dart'; +/// Rough kcal estimates for typical work-lunch restaurant meals — logging +/// something imprecise beats logging nothing. +const restaurantPresets = <(String, double)>[ + ('Poké saumon', 650), + ('Poké poulet', 600), + ('Poké africain', 750), + ('Curry indien + riz', 850), + ('Bento', 700), + ('Kebab', 950), + ('Burger + frites', 1100), + ('Salade repas', 500), +]; + +String _dayLabel(DateTime day) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final diff = today.difference(day).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${weekdays[day.weekday - 1]} ${day.day} ${months[day.month - 1]}'; +} + class TrackPage extends ConsumerWidget { const TrackPage({super.key}); + bool _isToday(DateTime day) { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day) == day; + } + + /// Logs land at "now" for today, midday for a past day being reviewed. + DateTime? _logTimestamp(DateTime day) => + _isToday(day) ? null : day.add(const Duration(hours: 12)); + @override Widget build(BuildContext context, WidgetRef ref) { - final entries = ref.watch(todayEntriesProvider).value ?? []; + final day = ref.watch(selectedDayProvider); + final entries = ref.watch(selectedDayEntriesProvider).value ?? []; final goal = ref.watch(settingsProvider).value?.dailyKcalGoal ?? 2200; final consumed = entries.fold(0, (sum, e) => sum + e.kcal); + final isToday = _isToday(day); return Scaffold( appBar: AppBar( - title: const Text('Today'), + title: const Text('Track'), actions: [ IconButton( icon: const Icon(Icons.settings_outlined), @@ -34,22 +73,26 @@ class TrackPage extends ConsumerWidget { floatingActionButton: FloatingActionButton.extended( icon: const Icon(Icons.add), label: const Text('Log food'), - onPressed: () => _showLogMenu(context, ref), + onPressed: () => _showLogMenu(context, ref, day), ), body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 96), children: [ + _DayNavigator(day: day, isToday: isToday), _KcalHeader(consumed: consumed, goal: goal), + const SizedBox(height: 12), + const _ProgressCard(), const _EatSoonCard(), if (entries.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 48), + Padding( + padding: const EdgeInsets.only(top: 48), child: EmptyState( icon: Icons.restaurant, - title: 'Nothing logged yet', - message: - 'Scan a barcode, pick something from your kitchen or ' - 'quick-add whatever you just ate.', + title: isToday ? 'Nothing logged yet' : 'Nothing logged', + message: isToday + ? 'Scan a barcode, pick something from your kitchen or ' + 'quick-add whatever you just ate.' + : 'No food was logged on this day.', ), ) else @@ -130,6 +173,7 @@ class TrackPage extends ConsumerWidget { '${entry.kcal.round()} kcal', style: const TextStyle(fontWeight: FontWeight.w700), ), + onTap: () => _editEntry(context, ref, entry), ), ), ], @@ -138,7 +182,30 @@ class TrackPage extends ConsumerWidget { ]; } - void _showLogMenu(BuildContext context, WidgetRef ref) { + Future _editEntry( + BuildContext context, + WidgetRef ref, + ConsumptionEntry entry, + ) async { + final result = await showModalBottomSheet<(String, double, MealType)>( + context: context, + isScrollControlled: true, + builder: (_) => _EditEntrySheet(entry: entry), + ); + if (result == null) return; + await ref + .read(databaseProvider) + .updateConsumption( + entry.id, + ConsumptionEntriesCompanion( + name: Value(result.$1), + kcal: Value(result.$2), + mealType: Value(result.$3.value), + ), + ); + } + + void _showLogMenu(BuildContext context, WidgetRef ref, DateTime day) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -151,7 +218,7 @@ class TrackPage extends ConsumerWidget { subtitle: const Text('Look up nutrition on Open Food Facts'), onTap: () { Navigator.pop(sheetContext); - _scanAndLog(context, ref); + _scanAndLog(context, ref, day); }, ), ListTile( @@ -160,16 +227,16 @@ class TrackPage extends ConsumerWidget { subtitle: const Text('Log something from your pantry'), onTap: () { Navigator.pop(sheetContext); - _logFromPantry(context, ref); + _logFromPantry(context, ref, day); }, ), ListTile( leading: const Icon(Icons.bolt), title: const Text('Quick add'), - subtitle: const Text('Just a name and kcal'), + subtitle: const Text('A name and kcal — restaurant presets too'), onTap: () { Navigator.pop(sheetContext); - _quickAdd(context, ref); + _quickAdd(context, ref, day); }, ), ], @@ -178,7 +245,11 @@ class TrackPage extends ConsumerWidget { ); } - Future _scanAndLog(BuildContext context, WidgetRef ref) async { + Future _scanAndLog( + BuildContext context, + WidgetRef ref, + DateTime day, + ) async { final code = await context.push('/scan'); if (code == null || !context.mounted) return; @@ -196,7 +267,7 @@ class TrackPage extends ConsumerWidget { content: Text('Product not found — use quick add instead.'), ), ); - await _quickAdd(context, ref); + await _quickAdd(context, ref, day); return; } final result = await showLogPortionSheet( @@ -208,6 +279,7 @@ class TrackPage extends ConsumerWidget { packageLabel: product.quantity, ); if (result == null) return; + final loggedAt = _logTimestamp(day); await ref .read(databaseProvider) .logConsumption( @@ -216,6 +288,9 @@ class TrackPage extends ConsumerWidget { kcal: result.kcal, mealType: result.mealType.value, grams: Value(result.grams), + loggedAt: loggedAt == null + ? const Value.absent() + : Value(loggedAt), ), ); } catch (e) { @@ -224,7 +299,11 @@ class TrackPage extends ConsumerWidget { } } - Future _logFromPantry(BuildContext context, WidgetRef ref) async { + Future _logFromPantry( + BuildContext context, + WidgetRef ref, + DateTime day, + ) async { final pantry = await ref.read(pantryProvider.future); if (!context.mounted) return; if (pantry.isEmpty) { @@ -244,47 +323,36 @@ class TrackPage extends ConsumerWidget { shrinkWrap: true, children: [ for (final item in pantry) - ListTile( - leading: const Icon(Icons.inventory_2_outlined), - title: Text(item.name), - subtitle: item.kcalPer100g == null - ? null - : Text('${item.kcalPer100g!.round()} kcal / 100 g'), - onTap: () => Navigator.pop(sheetContext, item), - ), + if (!item.isConsumed) + ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(item.name), + subtitle: item.kcalPer100g == null + ? null + : Text('${item.kcalPer100g!.round()} kcal / 100 g'), + trailing: Text(item.amountLabel), + onTap: () => Navigator.pop(sheetContext, item), + ), ], ), ), ); if (item == null || !context.mounted) return; - final result = await showEatPantryItemSheet(context, item); - if (result == null) return; - final db = ref.read(databaseProvider); - await db.logConsumption( - ConsumptionEntriesCompanion.insert( - name: item.name, - kcal: result.kcal, - mealType: result.mealType.value, - pantryItemId: Value(item.id), - grams: Value(result.grams), - ), - ); - // The slider estimates what was eaten, so keep the pantry stock in sync. - await db.updatePantryItem( - item.id, - PantryItemsCompanion( - amountLeft: Value((item.amountLeft - result.fraction).clamp(0.0, 1.0)), - ), - ); + await eatPantryItemFlow(context, ref, item, loggedAt: _logTimestamp(day)); } - Future _quickAdd(BuildContext context, WidgetRef ref) async { + Future _quickAdd( + BuildContext context, + WidgetRef ref, + DateTime day, + ) async { final result = await showModalBottomSheet<(String, double, MealType)>( context: context, isScrollControlled: true, builder: (context) => const _QuickAddSheet(), ); if (result == null) return; + final loggedAt = _logTimestamp(day); await ref .read(databaseProvider) .logConsumption( @@ -292,11 +360,170 @@ class TrackPage extends ConsumerWidget { name: result.$1, kcal: result.$2, mealType: result.$3.value, + loggedAt: loggedAt == null ? const Value.absent() : Value(loggedAt), ), ); } } +/// "‹ Today ›" — browse previous days; the forward chevron stops at today. +class _DayNavigator extends ConsumerWidget { + const _DayNavigator({required this.day, required this.isToday}); + + final DateTime day; + final bool isToday; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + return Column( + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => ref + .read(selectedDayProvider.notifier) + .state = day.subtract(const Duration(days: 1)), + ), + Expanded( + child: GestureDetector( + onTap: isToday + ? null + : () { + final now = DateTime.now(); + ref.read(selectedDayProvider.notifier).state = + DateTime(now.year, now.month, now.day); + }, + child: Text( + _dayLabel(day), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: isToday ? null : scheme.primary, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: isToday + ? null + : () => ref + .read(selectedDayProvider.notifier) + .state = day.add(const Duration(days: 1)), + ), + ], + ), + if (!isToday) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Viewing a past day — new logs are saved to this date. ' + 'Tap the date to jump back to today.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } +} + +/// Streak ("days without cheat") + estimated mass lost from the cumulative +/// kcal deficit banked over the streak. +class _ProgressCard extends ConsumerWidget { + const _ProgressCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stats = ref.watch(progressStatsProvider).value; + if (stats == null) return const SizedBox.shrink(); + final scheme = Theme.of(context).colorScheme; + final kg = stats.kgLost; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Expanded( + child: _StatTile( + icon: Icons.local_fire_department, + color: stats.streakDays > 0 + ? const Color(0xFFE8930C) + : scheme.onSurfaceVariant, + value: '${stats.streakDays} day${stats.streakDays == 1 ? '' : 's'}', + label: stats.streakDays > 0 + ? 'without cheat!' + : 'fresh start — stay under goal', + ), + ), + SizedBox( + height: 36, + child: VerticalDivider(color: scheme.outlineVariant), + ), + Expanded( + child: _StatTile( + icon: Icons.monitor_weight_outlined, + color: kg > 0 ? scheme.primary : scheme.onSurfaceVariant, + value: '−${kg.toStringAsFixed(kg >= 10 ? 0 : 2)} kg', + label: 'est. lost this streak', + ), + ), + ], + ), + ), + ); + } +} + +class _StatTile extends StatelessWidget { + const _StatTile({ + required this.icon, + required this.color, + required this.value, + required this.label, + }); + + final IconData icon; + final Color color; + final String value; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: color, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } +} + /// Perishable items that are not finished yet — eat these first. /// Hidden when there is nothing perishable in the kitchen. class _EatSoonCard extends ConsumerWidget { @@ -365,7 +592,10 @@ class _KcalHeader extends StatelessWidget { final scheme = Theme.of(context).colorScheme; final remaining = goal - consumed; final over = remaining < 0; + final progress = goal == 0 ? 0.0 : consumed / goal; + return Card( + color: over ? scheme.errorContainer : null, child: Padding( padding: const EdgeInsets.all(24), child: Row( @@ -373,12 +603,21 @@ class _KcalHeader extends StatelessWidget { SizedBox( width: 120, height: 120, - child: CustomPaint( - painter: _RingPainter( - progress: goal == 0 ? 0 : consumed / goal, - background: scheme.surfaceContainerHighest, - foreground: over ? scheme.error : scheme.primary, - foregroundEnd: over ? scheme.error : scheme.tertiary, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: progress), + duration: const Duration(milliseconds: 900), + curve: Curves.easeOutCubic, + builder: (context, animated, child) => CustomPaint( + painter: _RingPainter( + progress: animated, + background: over + ? scheme.onErrorContainer.withValues(alpha: 0.15) + : scheme.surfaceContainerHighest, + foreground: over ? scheme.error : scheme.primary, + foregroundEnd: over ? scheme.error : scheme.tertiary, + overflowColor: scheme.error, + ), + child: child, ), child: Center( child: Column( @@ -387,11 +626,16 @@ class _KcalHeader extends StatelessWidget { Text( consumed.round().toString(), style: Theme.of(context).textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), + ?.copyWith( + fontWeight: FontWeight.w800, + color: over ? scheme.onErrorContainer : null, + ), ), Text( 'kcal', - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: over ? scheme.onErrorContainer : null, + ), ), ], ), @@ -405,17 +649,23 @@ class _KcalHeader extends StatelessWidget { children: [ Text( over - ? '${remaining.abs().round()} kcal over' + ? '${remaining.abs().round()} kcal over 😬' : '${remaining.round()} kcal left', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, + color: over ? scheme.error : null, ), ), const SizedBox(height: 4), Text( - 'Goal: $goal kcal', + over + ? 'Goal blown — the streak resets. Log everything ' + 'anyway: honest data beats a pretty ring.' + : 'Goal: $goal kcal', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, + color: over + ? scheme.onErrorContainer + : scheme.onSurfaceVariant, ), ), ], @@ -434,12 +684,15 @@ class _RingPainter extends CustomPainter { required this.background, required this.foreground, required this.foregroundEnd, + required this.overflowColor, }); + /// May exceed 1.0 — the overflow is drawn as a second, darker lap. final double progress; final Color background; final Color foreground; final Color foregroundEnd; + final Color overflowColor; @override void paint(Canvas canvas, Size size) { @@ -470,6 +723,22 @@ class _RingPainter extends CustomPainter { false, foregroundPaint, ); + + // Past 100%: a thinner, darker second lap makes the excess unmissable. + if (progress > 1.0) { + final overflowPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke * 0.55 + ..color = Color.lerp(overflowColor, const Color(0xFF000000), 0.25)! + ..strokeCap = StrokeCap.round; + canvas.drawArc( + arcRect, + -math.pi / 2, + 2 * math.pi * (progress - 1.0).clamp(0.0, 1.0), + false, + overflowPaint, + ); + } } @override @@ -477,6 +746,7 @@ class _RingPainter extends CustomPainter { oldDelegate.progress != progress || oldDelegate.foreground != foreground || oldDelegate.foregroundEnd != foregroundEnd || + oldDelegate.overflowColor != overflowColor || oldDelegate.background != background; } @@ -502,6 +772,7 @@ class _QuickAddSheetState extends State<_QuickAddSheet> { @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final scheme = Theme.of(context).colorScheme; return Padding( padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), child: Column( @@ -509,7 +780,34 @@ class _QuickAddSheetState extends State<_QuickAddSheet> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Quick add', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 20), + const SizedBox(height: 12), + Text( + 'Restaurant lunch? Tap to prefill a rough estimate:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: restaurantPresets.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final (name, kcal) = restaurantPresets[index]; + return ActionChip( + avatar: const Icon(Icons.storefront, size: 16), + label: Text('$name · ${kcal.round()}'), + onPressed: () => setState(() { + _nameController.text = name; + _kcalController.text = kcal.round().toString(); + }), + ); + }, + ), + ), + const SizedBox(height: 12), TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, @@ -549,3 +847,90 @@ class _QuickAddSheetState extends State<_QuickAddSheet> { ); } } + +/// Edit a logged entry: name, kcal and the meal period it belongs to. +class _EditEntrySheet extends StatefulWidget { + const _EditEntrySheet({required this.entry}); + + final ConsumptionEntry entry; + + @override + State<_EditEntrySheet> createState() => _EditEntrySheetState(); +} + +class _EditEntrySheetState extends State<_EditEntrySheet> { + late final TextEditingController _nameController = TextEditingController( + text: widget.entry.name, + ); + late final TextEditingController _kcalController = TextEditingController( + text: widget.entry.kcal.round().toString(), + ); + late MealType _mealType = MealType.fromValue(widget.entry.mealType); + + @override + void dispose() { + _nameController.dispose(); + _kcalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 24 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Edit entry', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 20), + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: _kcalController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'kcal'), + ), + const SizedBox(height: 20), + SegmentedButton( + segments: [ + for (final type in MealType.values) + ButtonSegment(value: type, icon: Icon(type.icon, size: 18)), + ], + selected: {_mealType}, + onSelectionChanged: (selection) => + setState(() => _mealType = selection.first), + ), + const SizedBox(height: 8), + Center( + child: Text( + _mealType.label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _mealType.color, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.check), + label: const Text('Save'), + onPressed: () { + final name = _nameController.text.trim(); + final kcal = double.tryParse( + _kcalController.text.replaceAll(',', '.'), + ); + if (name.isEmpty || kcal == null) return; + Navigator.of(context).pop((name, kcal, _mealType)); + }, + ), + ], + ), + ); + } +} diff --git a/lib/providers.dart b/lib/providers.dart index 7bfb39b..4d5d772 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'data/db/database.dart'; +import 'data/progress.dart'; import 'data/quantity.dart'; import 'data/services/ai_service.dart'; import 'data/services/article_extractor.dart'; @@ -54,6 +55,11 @@ class SettingsNotifier extends AsyncNotifier { await ref.read(settingsServiceProvider).setDailyKcalGoal(goal); state = AsyncData(state.requireValue.copyWith(dailyKcalGoal: goal)); } + + Future setDailyBurnKcal(int burn) async { + await ref.read(settingsServiceProvider).setDailyBurnKcal(burn); + state = AsyncData(state.requireValue.copyWith(dailyBurnKcal: burn)); + } } final settingsProvider = AsyncNotifierProvider( @@ -82,6 +88,73 @@ final todayEntriesProvider = StreamProvider>((ref) { return ref.watch(databaseProvider).watchEntriesBetween(start, end); }); +/// The day the Track page is looking at (midnight-keyed; today by default). +class SelectedDayNotifier extends Notifier { + @override + DateTime build() { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day); + } +} + +final selectedDayProvider = NotifierProvider( + SelectedDayNotifier.new, +); + +/* +final selectedDayProvider = StateProvider((ref) { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day); +}); +*/ + +/// Consumption entries of the selected Track day. +final selectedDayEntriesProvider = StreamProvider>(( + ref, +) { + final day = ref.watch(selectedDayProvider); + return ref + .watch(databaseProvider) + .watchEntriesBetween(day, day.add(const Duration(days: 1))); +}); + +/// Streak ("days without cheat") and cumulative kcal deficit → kg lost. +final progressStatsProvider = StreamProvider((ref) { + final settings = ref.watch(settingsProvider).value; + final goal = settings?.dailyKcalGoal ?? SettingsService.defaultKcalGoal; + final burn = settings?.dailyBurnKcal ?? SettingsService.defaultKcalBurn; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + return ref + .watch(databaseProvider) + .watchEntriesBetween( + today.subtract(const Duration(days: 366)), + today.add(const Duration(days: 1)), + ) + .map((entries) { + final byDay = {}; + for (final entry in entries) { + final day = DateTime( + entry.loggedAt.year, + entry.loggedAt.month, + entry.loggedAt.day, + ); + byDay[day] = (byDay[day] ?? 0) + entry.kcal; + } + return computeProgress( + kcalByDay: byDay, + goalKcal: goal, + burnKcal: burn, + today: now, + ); + }); +}); + +/// Meals the user actually cooked, newest first. +final cookedMealsProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchCookedMeals(), +); + final shoppingProvider = StreamProvider>( (ref) => ref.watch(databaseProvider).watchShopping(), ); diff --git a/lib/widgets/log_portion_sheet.dart b/lib/widgets/log_portion_sheet.dart index d6bbc7d..be065ec 100644 --- a/lib/widgets/log_portion_sheet.dart +++ b/lib/widgets/log_portion_sheet.dart @@ -278,12 +278,13 @@ Future showEatPantryItemSheet( /// The full "I ate some of this" flow shared by Kitchen and Track: portion /// sheet → log the kcal → keep the pantry stock in sync. Returns true when -/// something was logged. +/// something was logged. [loggedAt] backdates the log (viewing a past day). Future eatPantryItemFlow( BuildContext context, WidgetRef ref, - PantryItem item, -) async { + PantryItem item, { + DateTime? loggedAt, +}) async { final messenger = ScaffoldMessenger.of(context); final result = await showEatPantryItemSheet(context, item); if (result == null) return false; @@ -295,6 +296,7 @@ Future eatPantryItemFlow( mealType: result.mealType.value, pantryItemId: Value(item.id), grams: Value(result.grams), + loggedAt: loggedAt == null ? const Value.absent() : Value(loggedAt), ), ); await db.updatePantryItem( diff --git a/test/app_test.dart b/test/app_test.dart index d4d9322..5669187 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -9,6 +9,7 @@ class _FakeSettingsService extends SettingsService { String? apiKey; String language = 'english'; int goal = 2000; + int burn = 2200; @override Future getApiKey() async => apiKey; @@ -21,6 +22,7 @@ class _FakeSettingsService extends SettingsService { hasApiKey: apiKey != null, language: language, dailyKcalGoal: goal, + dailyBurnKcal: burn, ); @override @@ -28,6 +30,9 @@ class _FakeSettingsService extends SettingsService { @override Future setDailyKcalGoal(int value) async => goal = value; + + @override + Future setDailyBurnKcal(int value) async => burn = value; } void main() { diff --git a/test/data/database_test.dart b/test/data/database_test.dart index fd43ddc..cdf289a 100644 --- a/test/data/database_test.dart +++ b/test/data/database_test.dart @@ -102,6 +102,32 @@ void main() { expect(items.map((i) => i.name), ['Eggs']); }); + test('cooked meals: newest first, capped by limit', () async { + for (var i = 0; i < 3; i++) { + await db.addCookedMeal( + CookedMealsCompanion.insert( + title: 'Meal $i', + kcal: 400, + cookedAt: Value(DateTime(2026, 7, 1 + i)), + ), + ); + } + final cooked = await db.watchCookedMeals(limit: 2).first; + expect(cooked.map((m) => m.title), ['Meal 2', 'Meal 1']); + }); + + test('pantry: category defaults to cupboard and round-trips', () async { + await db.addPantryItem(PantryItemsCompanion.insert(name: 'Pasta')); + await db.addPantryItem( + PantryItemsCompanion.insert( + name: 'Yogurt', + category: const Value('fridge'), + ), + ); + final pantry = await db.watchPantry().first; + expect(pantry.map((i) => i.category), ['fridge', 'cupboard']); + }); + test('saved meals: replaced wholesale, done flag persists', () async { SavedMealsCompanion meal(String title) => SavedMealsCompanion.insert( title: title, diff --git a/test/data/progress_test.dart b/test/data/progress_test.dart new file mode 100644 index 0000000..b61295e --- /dev/null +++ b/test/data/progress_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readly/data/progress.dart'; + +void main() { + final today = DateTime(2026, 7, 10, 14, 30); + DateTime day(int daysAgo) => + DateTime(2026, 7, 10).subtract(Duration(days: daysAgo)); + + test('counts consecutive under-goal days and banks past deficits', () { + final stats = computeProgress( + kcalByDay: { + day(0): 800, // today, in progress — streak yes, deficit not banked + day(1): 1700, // banks 500 + day(2): 1900, // banks 300 + day(3): 1500, // banks 700 + }, + goalKcal: 1900, + burnKcal: 2200, + today: today, + ); + expect(stats.streakDays, 4); + expect(stats.kcalDeficit, 1500); + expect(stats.kgLost, closeTo(1500 / 7700, 1e-9)); + }); + + test('an over-goal day in the past ends the streak there', () { + final stats = computeProgress( + kcalByDay: {day(0): 500, day(1): 1800, day(2): 2500, day(3): 1600}, + goalKcal: 1900, + burnKcal: 2200, + today: today, + ); + expect(stats.streakDays, 2); + expect(stats.kcalDeficit, 400); // only day(1) + }); + + test('blowing today resets everything — that is the punishment', () { + final stats = computeProgress( + kcalByDay: {day(0): 2400, day(1): 1500, day(2): 1500}, + goalKcal: 1900, + burnKcal: 2200, + today: today, + ); + expect(stats.streakDays, 0); + expect(stats.kcalDeficit, 0); + }); + + test('a day with no logs breaks the streak (not logging = cheating)', () { + final stats = computeProgress( + kcalByDay: {day(0): 900, day(2): 1500}, + goalKcal: 1900, + burnKcal: 2200, + today: today, + ); + expect(stats.streakDays, 1); // only today + expect(stats.kcalDeficit, 0); + }); + + test('empty today does not break the streak of previous days', () { + final stats = computeProgress( + kcalByDay: {day(1): 1600, day(2): 1800}, + goalKcal: 1900, + burnKcal: 2200, + today: today, + ); + expect(stats.streakDays, 2); + expect(stats.kcalDeficit, 600 + 400); + }); + + test('eating over maintenance but under goal banks nothing negative', () { + final stats = computeProgress( + kcalByDay: {day(1): 2100}, // goal 2200, burn 2000 → no negative deficit + goalKcal: 2200, + burnKcal: 2000, + today: today, + ); + expect(stats.streakDays, 1); + expect(stats.kcalDeficit, 0); + }); +} From e9ff85384481579a8319440cba6c09016e3e2bcb Mon Sep 17 00:00:00 2001 From: breval Date: Wed, 8 Jul 2026 23:25:42 +0400 Subject: [PATCH 8/8] refactor: remove commented-out StateProvider for selected day --- lib/providers.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/providers.dart b/lib/providers.dart index 4d5d772..5d15a3d 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -101,13 +101,6 @@ final selectedDayProvider = NotifierProvider( SelectedDayNotifier.new, ); -/* -final selectedDayProvider = StateProvider((ref) { - final now = DateTime.now(); - return DateTime(now.year, now.month, now.day); -}); -*/ - /// Consumption entries of the selected Track day. final selectedDayEntriesProvider = StreamProvider>(( ref,