diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index b0c9ea2..20685a6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,7 +1,10 @@ -name: build -on: [ pull_request ] +name: ci +on: + pull_request: + push: + branches: [ master ] jobs: - build-android: + check-and-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -11,17 +14,21 @@ 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 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/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/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/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..83b2070 --- /dev/null +++ b/lib/app/theme.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// 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(0xFF96604A), + brightness: brightness, + dynamicSchemeVariant: DynamicSchemeVariant.vibrant, + ); + 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..dab43e0 --- /dev/null +++ b/lib/data/db/database.dart @@ -0,0 +1,266 @@ +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()(); + + /// 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))(); + + /// 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)(); + 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'))(); + + /// 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') +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()(); + TextColumn get title => text()(); + TextColumn get summary => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} + +@DriftDatabase( + tables: [ + PantryItems, + ConsumptionEntries, + ShoppingItems, + SavedMeals, + CookedMeals, + Articles, + ], +) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(driftDatabase(name: 'readly')); + + AppDatabase.forTesting(super.e); + + @override + int get schemaVersion => 4; + + @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); + } + 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'))); + } + }, + ); + + // ---- Pantry ---- + + /// Newest additions first, so what you just bought sits on top. + Stream> watchPantry() => + (select(pantryItems)..orderBy([ + (t) => OrderingTerm.desc(t.addedAt), + (t) => OrderingTerm.desc(t.id), + ])) + .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 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(); + + // ---- 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(); + + // ---- 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)), + ); + + // ---- 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( + 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..e0da760 --- /dev/null +++ b/lib/data/db/database.g.dart @@ -0,0 +1,4827 @@ +// 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 _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 _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', + ); + @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, + unitCount, + perishable, + category, + 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('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('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } + 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'], + ), + unitCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}unit_count'], + ), + perishable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}perishable'], + )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, + 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; + + /// 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; + + /// 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; + 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, + this.unitCount, + required this.perishable, + required this.category, + 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); + } + if (!nullToAbsent || unitCount != null) { + 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); + 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), + unitCount: unitCount == null && nullToAbsent + ? const Value.absent() + : Value(unitCount), + perishable: Value(perishable), + category: Value(category), + 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']), + 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']), + ); + } + @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), + 'unitCount': serializer.toJson(unitCount), + 'perishable': serializer.toJson(perishable), + 'category': serializer.toJson(category), + '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(), + Value unitCount = const Value.absent(), + bool? perishable, + String? category, + 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, + 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, + ); + 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, + unitCount: data.unitCount.present ? data.unitCount.value : this.unitCount, + 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, + 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('unitCount: $unitCount, ') + ..write('perishable: $perishable, ') + ..write('category: $category, ') + ..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, + unitCount, + perishable, + category, + 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.unitCount == this.unitCount && + other.perishable == this.perishable && + other.category == this.category && + 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 unitCount; + final Value perishable; + final Value category; + 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.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(), + }); + 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.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(), + }) : 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? unitCount, + Expression? perishable, + Expression? category, + 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 (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, + }); + } + + 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? unitCount, + Value? perishable, + Value? category, + 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, + unitCount: unitCount ?? this.unitCount, + perishable: perishable ?? this.perishable, + category: category ?? this.category, + 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 (unitCount.present) { + map['unit_count'] = Variable(unitCount.value); + } + 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); + } + 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('unitCount: $unitCount, ') + ..write('perishable: $perishable, ') + ..write('category: $category, ') + ..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 _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', + ); + @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, + quantity, + estimatedPrice, + 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('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, + 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'], + )!, + 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'], + )!, + ); + } + + @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; + + /// 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, + required this.name, + this.note, + required this.done, + required this.source, + this.quantity, + this.estimatedPrice, + 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); + if (!nullToAbsent || quantity != null) { + map['quantity'] = Variable(quantity); + } + if (!nullToAbsent || estimatedPrice != null) { + map['estimated_price'] = Variable(estimatedPrice); + } + 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), + quantity: quantity == null && nullToAbsent + ? const Value.absent() + : Value(quantity), + estimatedPrice: estimatedPrice == null && nullToAbsent + ? const Value.absent() + : Value(estimatedPrice), + 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']), + quantity: serializer.fromJson(json['quantity']), + estimatedPrice: serializer.fromJson(json['estimatedPrice']), + 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), + 'quantity': serializer.toJson(quantity), + 'estimatedPrice': serializer.toJson(estimatedPrice), + 'addedAt': serializer.toJson(addedAt), + }; + } + + ShoppingItem copyWith({ + int? id, + String? name, + 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, + name: name ?? this.name, + 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) { + 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, + 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, + ); + } + + @override + String toString() { + return (StringBuffer('ShoppingItem(') + ..write('id: $id, ') + ..write('name: $name, ') + ..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, + quantity, + estimatedPrice, + 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.quantity == this.quantity && + other.estimatedPrice == this.estimatedPrice && + 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 quantity; + final Value estimatedPrice; + 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.quantity = const Value.absent(), + this.estimatedPrice = 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.quantity = const Value.absent(), + this.estimatedPrice = 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? quantity, + Expression? estimatedPrice, + 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 (quantity != null) 'quantity': quantity, + if (estimatedPrice != null) 'estimated_price': estimatedPrice, + if (addedAt != null) 'added_at': addedAt, + }); + } + + ShoppingItemsCompanion copyWith({ + Value? id, + Value? name, + Value? note, + Value? done, + Value? source, + Value? quantity, + Value? estimatedPrice, + Value? addedAt, + }) { + return ShoppingItemsCompanion( + id: id ?? this.id, + name: name ?? this.name, + note: note ?? this.note, + done: done ?? this.done, + source: source ?? this.source, + quantity: quantity ?? this.quantity, + estimatedPrice: estimatedPrice ?? this.estimatedPrice, + 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 (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); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShoppingItemsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('note: $note, ') + ..write('done: $done, ') + ..write('source: $source, ') + ..write('quantity: $quantity, ') + ..write('estimatedPrice: $estimatedPrice, ') + ..write('addedAt: $addedAt') + ..write(')')) + .toString(); + } +} + +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 $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; + 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 $SavedMealsTable savedMeals = $SavedMealsTable(this); + late final $CookedMealsTable cookedMeals = $CookedMealsTable(this); + late final $ArticlesTable articles = $ArticlesTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + pantryItems, + consumptionEntries, + shoppingItems, + savedMeals, + cookedMeals, + 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 unitCount, + Value perishable, + Value category, + 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 unitCount, + Value perishable, + Value category, + 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 unitCount => $composableBuilder( + column: $table.unitCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get perishable => $composableBuilder( + column: $table.perishable, + 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), + ); + + 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 unitCount => $composableBuilder( + column: $table.unitCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get perishable => $composableBuilder( + column: $table.perishable, + 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), + ); + + 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 unitCount => + $composableBuilder(column: $table.unitCount, builder: (column) => column); + + GeneratedColumn get perishable => $composableBuilder( + column: $table.perishable, + builder: (column) => column, + ); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, 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 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(), + }) => PantryItemsCompanion( + id: id, + barcode: barcode, + name: name, + brand: brand, + imageUrl: imageUrl, + kcalPer100g: kcalPer100g, + proteinsPer100g: proteinsPer100g, + carbsPer100g: carbsPer100g, + sugarsPer100g: sugarsPer100g, + fatsPer100g: fatsPer100g, + packageQuantity: packageQuantity, + unitCount: unitCount, + perishable: perishable, + category: category, + 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 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(), + }) => PantryItemsCompanion.insert( + id: id, + barcode: barcode, + name: name, + brand: brand, + imageUrl: imageUrl, + kcalPer100g: kcalPer100g, + proteinsPer100g: proteinsPer100g, + carbsPer100g: carbsPer100g, + sugarsPer100g: sugarsPer100g, + fatsPer100g: fatsPer100g, + packageQuantity: packageQuantity, + unitCount: unitCount, + perishable: perishable, + category: category, + 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 quantity, + Value estimatedPrice, + Value addedAt, + }); +typedef $$ShoppingItemsTableUpdateCompanionBuilder = + ShoppingItemsCompanion Function({ + Value id, + Value name, + Value note, + Value done, + Value source, + Value quantity, + Value estimatedPrice, + 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 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), + ); +} + +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 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), + ); +} + +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 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); +} + +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 quantity = const Value.absent(), + Value estimatedPrice = const Value.absent(), + Value addedAt = const Value.absent(), + }) => ShoppingItemsCompanion( + id: id, + name: name, + note: note, + done: done, + source: source, + quantity: quantity, + estimatedPrice: estimatedPrice, + 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 quantity = const Value.absent(), + Value estimatedPrice = const Value.absent(), + Value addedAt = const Value.absent(), + }) => ShoppingItemsCompanion.insert( + id: id, + name: name, + note: note, + done: done, + source: source, + quantity: quantity, + estimatedPrice: estimatedPrice, + 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 $$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 $$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, + 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); + $$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/meal_type.dart b/lib/data/meal_type.dart new file mode 100644 index 0000000..bb1a2c8 --- /dev/null +++ b/lib/data/meal_type.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +enum MealType { + 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, 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, + ); + + /// 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/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/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/ai_service.dart b/lib/data/services/ai_service.dart new file mode 100644 index 0000000..c2bea08 --- /dev/null +++ b/lib/data/services/ai_service.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_openai/dart_openai.dart'; + +/// Thrown when the OpenAI API returns an error. +class AiException implements Exception { + AiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + bool get isAuthError => statusCode == 401; + + @override + String toString() => 'AiException($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, + 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`. +/// The user supplies their own API key (BYOK). +class AiService { + AiService({required String apiKey}) { + OpenAI.apiKey = apiKey; + OpenAI.requestsTimeOut = const Duration(minutes: 5); + } + + /// 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, + ) { + return OpenAIChatCompletionChoiceMessageModel( + role: role, + content: [OpenAIChatCompletionChoiceMessageContentItemModel.text(text)], + ); + } + + /// Streams a summary of [text] as it is generated. + Stream streamArticleSummary({ + required String? title, + required String text, + required String language, + }) { + 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', + ), + ], + ); + + 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, 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 { + final result = await _structuredRequest( + system: + '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: + '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', + '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: + '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: + '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', + 'properties': { + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'reason': {'type': 'string'}, + 'quantity': {'type': 'string'}, + 'price_eur': {'type': 'number'}, + }, + 'required': ['name', 'reason', 'quantity', 'price_eur'], + '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 String schemaName, + required Map schema, + }) async { + 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); + } + + if (response.choices.isEmpty) { + throw AiException('The model returned an empty response.'); + } + final content = response.choices.first.message.content; + final text = content?.map((item) => item.text ?? '').join() ?? ''; + return extractJson(text); + } + + /// 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 { + return jsonDecode(candidate) as Map; + } on FormatException { + 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 new file mode 100644 index 0000000..4e52bf6 --- /dev/null +++ b/lib/data/services/article_extractor.dart @@ -0,0 +1,135 @@ +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; + +/// 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; +} + +/// 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), + 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..19cf51f --- /dev/null +++ b/lib/data/services/settings_service.dart @@ -0,0 +1,87 @@ +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, + 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, + ); + } +} + +class SettingsService { + SettingsService({ + FlutterSecureStorage? secureStorage, + SharedPreferencesAsync? prefs, + }) : _secure = secureStorage ?? const FlutterSecureStorage(), + _prefsOverride = prefs; + + 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; + 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); + final burn = await _prefs.getInt(_kcalBurnKey); + return AppSettings( + hasApiKey: apiKey != null && apiKey.isNotEmpty, + language: language ?? defaultLanguage, + dailyKcalGoal: goal ?? defaultKcalGoal, + dailyBurnKcal: burn ?? defaultKcalBurn, + ); + } + + Future setLanguage(String language) => + _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 new file mode 100644 index 0000000..55d7f8a --- /dev/null +++ b/lib/features/groceries/groceries_page.dart @@ -0,0 +1,323 @@ +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/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}); + + @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 ai = await ref.read(aiServiceProvider.future); + if (!mounted) return; + if (ai == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Add your OpenAI 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 ai.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'), + quantity: Value(suggestion.quantity), + estimatedPrice: Value(suggestion.priceEur), + ), + ); + added++; + } + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Added $added AI suggestions.'))); + } + } on AiException 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); + } + } + + /// "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); + 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), + quantity: Value(item.quantity), + estimatedPrice: Value(item.estimatedPrice), + 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 ?? []; + 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( + 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(), + ), + ), + 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( + 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: (_) => _deleteWithUndo(item), + child: CheckboxListTile( + value: item.done, + onChanged: (value) => _setDone(item, value ?? false), + title: Text( + item.name, + style: item.done + ? const TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: _subtitleFor(item), + 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..ede042d --- /dev/null +++ b/lib/features/kitchen/kitchen_page.dart @@ -0,0 +1,772 @@ +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/kitchen_category.dart'; +import '../../data/quantity.dart'; +import '../../data/services/off_service.dart'; +import '../../providers.dart'; +import '../../widgets/common.dart'; +import '../../widgets/log_portion_sheet.dart'; + +/// 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 + 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( + 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.', + ) + : 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.', + ) + : _DrawerList(items: visible), + ), + ], + ), + ); + } + + 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); + } +} + +/// 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}); + + final PantryItem item; + + @override + ConsumerState<_PantryCard> createState() => _PantryCardState(); +} + +class _PantryCardState extends ConsumerState<_PantryCard> { + bool _expanded = false; + double? _pendingAmount; + + @override + Widget build(BuildContext context) { + final item = widget.item; + final scheme = Theme.of(context).colorScheme; + final amount = _pendingAmount ?? item.amountLeft; + final color = amountLeftColor(amount, scheme); + + return Card( + 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')), + ], + ), + ], + ), + 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)), + ); + }, + ), + ), + SizedBox( + width: 56, + child: Text( + _amountLabel(item, amount), + textAlign: TextAlign.end, + style: TextStyle( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + const SizedBox(width: 8), + ], + ), + ] 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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + 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() => eatPantryItemFlow(context, ref, widget.item); + + 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 'refill': + await db.updatePantryItem( + widget.item.id, + const PantryItemsCompanion(amountLeft: Value(1.0)), + ); + case 'delete': + 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), + category: Value(item.category), + amountLeft: Value(item.amountLeft), + addedAt: Value(item.addedAt), + ), + ), + ), + ), + ); + } + } +} + +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.secondaryContainer, + child: Icon(Icons.fastfood, color: scheme.onSecondaryContainer), + ) + : Image.network( + imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: scheme.secondaryContainer, + child: Icon( + Icons.fastfood, + color: scheme.onSecondaryContainer, + ), + ), + ), + ), + ); + } +} + +/// 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, + isScrollControlled: true, + builder: (context) => _PantryEditSheet( + product: product, + existing: existing, + barcode: barcode, + initialName: initialName, + ), + ); +} + +class _PantryEditSheet extends ConsumerStatefulWidget { + 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(); +} + +class _PantryEditSheetState extends ConsumerState<_PantryEditSheet> { + late final TextEditingController _name; + late final TextEditingController _brand; + late final TextEditingController _kcal; + 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 ?? '', + ); + _brand = TextEditingController(text: e?.brand ?? p?.brand ?? ''); + _kcal = TextEditingController( + text: (e?.kcalPer100g ?? p?.kcalPer100g)?.round().toString() ?? '', + ); + _package = TextEditingController( + text: e?.packageQuantity ?? p?.quantity ?? '', + ); + _units = TextEditingController(text: e?.unitCount?.toString() ?? ''); + _perishable = e?.perishable ?? false; + } + + @override + void dispose() { + _name.dispose(); + _brand.dispose(); + _kcal.dispose(); + _package.dispose(); + _units.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 units = int.tryParse(_units.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), + unitCount: Value((units ?? 0) > 0 ? units : null), + perishable: Value(_perishable), + category: Value(_category.value), + ), + ); + } 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), + unitCount: Value((units ?? 0) > 0 ? units : null), + perishable: Value(_perishable), + category: Value(_category.value), + ), + ); + } + 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: 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 %', + ), + ), + 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; + // 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( + 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..52420e6 --- /dev/null +++ b/lib/features/meals/meals_page.dart @@ -0,0 +1,634 @@ +import 'dart:convert'; + +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/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 generation = ref.watch(mealSuggestionsProvider); + final meals = ref.watch(savedMealsProvider).value ?? []; + 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 (generation) { + AsyncLoading() => const _Loading(), + AsyncError(:final error) => EmptyState( + icon: Icons.error_outline, + title: 'Could not get suggestions', + message: error is AiException ? error.message : '$error', + action: FilledButton( + onPressed: () => + ref.read(mealSuggestionsProvider.notifier).generate(), + child: const Text('Try again'), + ), + ), + // Idle or done: the saved batch (persisted across restarts) rules. + _ => _MealsBody(meals: meals, hasApiKey: hasApiKey), + }, + ); + } +} + +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 OpenAI 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…'), + ], + ), + ); + } +} + +/// 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: [ + 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), + ), + ), + ], + ), + ), + ], + ], + ); + } + + 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 { + const _MealCard({required this.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), + 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: Icon( + Icons.schedule, + size: 18, + color: scheme.onSecondaryContainer, + ), + backgroundColor: scheme.secondaryContainer, + labelStyle: TextStyle(color: scheme.onSecondaryContainer), + label: Text('${meal.timeMinutes} min'), + ), + Chip( + 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'), + ), + ], + ), + if (usedIngredients.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'From your kitchen', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + usedIngredients.join(' · '), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ], + if (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 missingIngredients) + ActionChip( + avatar: const Icon(Icons.add_shopping_cart, size: 18), + label: Text(ingredient), + onPressed: () => + _addToGroceries(context, ref, ingredient), + ), + ], + ), + ], + if (steps.isNotEmpty) ...[ + const SizedBox(height: 8), + ExpansionTile( + tilePadding: EdgeInsets.zero, + shape: const Border(), + title: Text( + '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, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 13, + child: Text('${index + 1}'), + ), + title: Text(step), + ), + ], + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + 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), + ), + ), + ], + ), + ), + ); + } + + 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, + List usedIngredients, + ) 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.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; + 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/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..414bdc8 --- /dev/null +++ b/lib/features/reader/summary_page.dart @@ -0,0 +1,221 @@ +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/ai_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 ai = await ref.read(aiServiceProvider.future); + if (!mounted) return; + if (ai == null) { + setState(() { + _status = _Status.error; + _needsApiKey = true; + _error = 'Add your OpenAI 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 = ai + .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 AiException ? 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 AiException ? 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..d263109 --- /dev/null +++ b/lib/features/settings/settings_page.dart @@ -0,0 +1,203 @@ +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(); + final _burnController = TextEditingController(); + bool _obscureKey = true; + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider).value; + if (settings != null) { + _goalController.text = settings.dailyKcalGoal.toString(); + _burnController.text = settings.dailyBurnKcal.toString(); + } + } + + @override + void dispose() { + _apiKeyController.dispose(); + _goalController.dispose(); + _burnController.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(); + } + + 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; + 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 (OpenAI)'), + 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: 'OpenAI API key', + hintText: 'sk-…', + 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 platform.openai.com'), + onPressed: () => launchUrl( + Uri.parse('https://platform.openai.com/api-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: 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(), + ), + ], + ), + ), + ), + 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..d89f233 --- /dev/null +++ b/lib/features/track/track_page.dart @@ -0,0 +1,936 @@ +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 '../../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 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('Track'), + 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, day), + ), + body: ListView( + 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) + Padding( + padding: const EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.restaurant, + 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 + 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: type.color, + fontWeight: FontWeight.w700, + ), + ), + ), + 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: (_) 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, + backgroundColor: type.tint, + child: Icon(type.icon, size: 20, color: type.color), + ), + 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), + ), + onTap: () => _editEntry(context, ref, entry), + ), + ), + ], + ), + ), + ]; + } + + 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( + 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, day); + }, + ), + 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, day); + }, + ), + ListTile( + leading: const Icon(Icons.bolt), + title: const Text('Quick add'), + subtitle: const Text('A name and kcal — restaurant presets too'), + onTap: () { + Navigator.pop(sheetContext); + _quickAdd(context, ref, day); + }, + ), + ], + ), + ), + ); + } + + Future _scanAndLog( + BuildContext context, + WidgetRef ref, + DateTime day, + ) 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, day); + return; + } + final result = await showLogPortionSheet( + context, + foodName: product.name, + kcalPer100g: product.kcalPer100g, + packageGrams: + parsePackageGrams(product.quantity) ?? product.servingGrams, + packageLabel: product.quantity, + ); + if (result == null) return; + final loggedAt = _logTimestamp(day); + await ref + .read(databaseProvider) + .logConsumption( + ConsumptionEntriesCompanion.insert( + name: product.name, + kcal: result.kcal, + mealType: result.mealType.value, + grams: Value(result.grams), + loggedAt: loggedAt == null + ? const Value.absent() + : Value(loggedAt), + ), + ); + } catch (e) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar(content: Text('Lookup failed: $e'))); + } + } + + Future _logFromPantry( + BuildContext context, + WidgetRef ref, + DateTime day, + ) 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) + 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; + await eatPantryItemFlow(context, ref, item, loggedAt: _logTimestamp(day)); + } + + 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( + ConsumptionEntriesCompanion.insert( + 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 { + 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}); + + 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; + final progress = goal == 0 ? 0.0 : consumed / goal; + + return Card( + color: over ? scheme.errorContainer : null, + child: Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + SizedBox( + width: 120, + height: 120, + 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( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + consumed.round().toString(), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.w800, + color: over ? scheme.onErrorContainer : null, + ), + ), + Text( + 'kcal', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: over ? scheme.onErrorContainer : null, + ), + ), + ], + ), + ), + ), + ), + 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, + color: over ? scheme.error : null, + ), + ), + const SizedBox(height: 4), + Text( + 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: over + ? scheme.onErrorContainer + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({ + required this.progress, + 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) { + 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 + ..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); + canvas.drawArc( + arcRect, + -math.pi / 2, + 2 * math.pi * progress.clamp(0.0, 1.0), + 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 + bool shouldRepaint(_RingPainter oldDelegate) => + oldDelegate.progress != progress || + oldDelegate.foreground != foreground || + oldDelegate.foregroundEnd != foregroundEnd || + oldDelegate.overflowColor != overflowColor || + 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; + final scheme = Theme.of(context).colorScheme; + 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: 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, + 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)); + }, + ), + ], + ), + ); + } +} + +/// 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/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..5d15a3d --- /dev/null +++ b/lib/providers.dart @@ -0,0 +1,243 @@ +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'; +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); + // 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(), + ); + } + + 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)); + } + + Future setDailyBurnKcal(int burn) async { + await ref.read(settingsServiceProvider).setDailyBurnKcal(burn); + state = AsyncData(state.requireValue.copyWith(dailyBurnKcal: burn)); + } +} + +final settingsProvider = AsyncNotifierProvider( + SettingsNotifier.new, +); + +/// 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 AiService(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); +}); + +/// 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, +); + +/// 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(), +); + +final articlesProvider = StreamProvider>( + (ref) => ref.watch(databaseProvider).watchArticles(), +); + +/// 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 ---- + +/// 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; + + Future generate() async { + final ai = await ref.read(aiServiceProvider.future); + if (ai == null) { + state = AsyncValue.error( + AiException('Add your OpenAI 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(() async { + final meals = await 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': entry.name, + 'kcal': entry.kcal.round(), + 'meal': entry.mealType, + }, + ], + consumedKcal: eaten, + 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.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..be065ec --- /dev/null +++ b/lib/widgets/log_portion_sheet.dart @@ -0,0 +1,314 @@ +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({ + 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 "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? packageGrams, + int? unitCount, + double amountLeft = 1.0, + String? packageLabel, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _LogPortionSheet( + foodName: foodName, + kcalPer100g: kcalPer100g, + packageGrams: packageGrams, + unitCount: (unitCount ?? 0) > 0 ? unitCount : null, + amountLeft: amountLeft.clamp(0.0, 1.0), + packageLabel: packageLabel, + ), + ); +} + +class _LogPortionSheet extends StatefulWidget { + const _LogPortionSheet({ + required this.foodName, + required this.kcalPer100g, + required this.packageGrams, + required this.unitCount, + required this.amountLeft, + required this.packageLabel, + }); + + final String foodName; + final double? kcalPer100g; + 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 _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(); + // 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() { + _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( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.foodName, style: Theme.of(context).textTheme.titleLarge), + 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: 16), + Row( + children: [ + Text( + 'How much did you eat?', + style: Theme.of(context).textTheme.labelLarge, + ), + 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: [ + 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('Log it'), + onPressed: () { + final kcal = double.tryParse( + _kcalController.text.replaceAll(',', '.'), + ); + if (kcal == null || _fraction <= 0) return; + Navigator.of(context).pop( + LogPortionResult( + kcal: kcal, + mealType: _mealType, + fraction: _fraction, + grams: _grams(), + ), + ); + }, + ), + ], + ), + ); + } +} + +/// 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, + ); +} + +/// 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. [loggedAt] backdates the log (viewing a past day). +Future eatPantryItemFlow( + BuildContext context, + WidgetRef ref, + PantryItem item, { + DateTime? loggedAt, +}) 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), + loggedAt: loggedAt == null ? const Value.absent() : Value(loggedAt), + ), + ); + 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 new file mode 100644 index 0000000..5f52574 --- /dev/null +++ b/lib/widgets/scanner_page.dart @@ -0,0 +1,113 @@ +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]. +/// A keyboard fallback at the bottom covers damaged/unreadable barcodes. +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], + ); + final _manualController = TextEditingController(); + 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); + } + + 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(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + resizeToAvoidBottomInset: true, + 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), + ), + ), + ), + 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(), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 351590b..192f212 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,106 +1,56 @@ 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 + # AI (OpenAI models via dart_openai) + dart_openai: ^6.1.1 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec + # Article synthesis (legacy feature) + share_handler: ^0.0.25 + html: ^0.15.6 + # 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..5669187 --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,93 @@ +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/services/settings_service.dart'; +import 'package:readly/providers.dart'; + +class _FakeSettingsService extends SettingsService { + String? apiKey; + String language = 'english'; + int goal = 2000; + int burn = 2200; + + @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, + dailyBurnKcal: burn, + ); + + @override + Future setLanguage(String value) async => language = value; + + @override + Future setDailyKcalGoal(int value) async => goal = value; + + @override + Future setDailyBurnKcal(int value) async => burn = value; +} + +void main() { + testWidgets('app boots and all five tabs navigate', (tester) async { + // 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 + // the test environment. + Future pump() async { + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + 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 pump(); + expect(find.text('Your kitchen is empty'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.restaurant_menu_outlined)); + await pump(); + expect(find.text('AI is not set up yet'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.shopping_basket_outlined)); + await pump(); + expect(find.text('Nothing to buy'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.auto_stories_outlined)); + await pump(); + 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..cdf289a --- /dev/null +++ b/test/data/database_test.dart @@ -0,0 +1,170 @@ +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('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); + 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('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, + 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( + 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/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); + }); +} 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/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); + }); +} 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/article_extractor_test.dart b/test/services/article_extractor_test.dart new file mode 100644 index 0000000..2467a91 --- /dev/null +++ b/test/services/article_extractor_test.dart @@ -0,0 +1,79 @@ +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 = ''' + + 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); + }); + }); +}