diff --git a/.gitignore b/.gitignore index 6694098b..17fed300 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,6 @@ android/vendor/ # macOS generated files **/macos/Flutter/ephemeral/ -trakli_demo/ \ No newline at end of file +trakli_demo/ +# VSCode workspace file +trakli-mobile.code-workspace diff --git a/assets/translations/de.json b/assets/translations/de.json index 2098563c..d7f01b2c 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -133,6 +133,7 @@ "noError": "Kein Fehler", "internetConnectionDesc": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "notFoundDesc": "Die angeforderte Ressource wurde nicht gefunden.", + "fileTooLargeDesc": "Die Datei ist zu groß zum Hochladen. Bitte wählen Sie eine kleinere Datei.", "serverErrorDesc": "Ein Serverfehler ist aufgetreten. Bitte versuchen Sie es erneut.", "cacheErrorDesc": "Ein Cachefehler ist aufgetreten. Bitte versuchen Sie es erneut.", "syncErrorDesc": "Ein Synchronisierungsfehler ist aufgetreten. Bitte versuchen Sie es erneut.", @@ -740,5 +741,28 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Guten Morgen", + "aiGreetingAfternoon": "Guten Tag", + "aiGreetingEvening": "Guten Abend", + "aiStatNetFlow": "Netto-Cashflow", + "aiStatSaved": "Gespart", + "aiLandingNoData": "Füge ein paar Transaktionen hinzu, dann erkenne ich Trends für dich.", + "aiSuggestSpendMonth": "Wie viel habe ich diesen Monat ausgegeben?", + "aiSuggestTopCategory": "Was war letzte Woche meine größte Ausgabenkategorie?", + "aiSuggestIncomeVsExpense": "Zeige meine Einnahmen und Ausgaben für das Jahr.", + "aiSuggestBalance": "Wie war mein Kontostand am 15.?", + "aiContinueSession": "Vorherigen Chat fortsetzen", + "aiStartNewSession": "Neuen Chat starten", + "aiStatTransactions": "Transaktionen", + "aiStatAvgExpense": "Ø Ausgabe", + "aiServiceUnavailable": "Der Datendienst ist vorübergehend nicht verfügbar. Bitte versuche es in Kürze erneut.", + "none": "Keine", + "dismissed": "Verworfen", + "aiReviewImport": "Import prüfen", + "aiOpenCanvas": "Im Canvas öffnen", + "aiImportStatusAnalyzing": "Wird analysiert…", + "aiImportStatusReady": "Bereit zur Prüfung", + "aiImportStatusConfirmed": "Importiert", + "aiImportStatusFailed": "Fehlgeschlagen" } diff --git a/assets/translations/en.json b/assets/translations/en.json index ff00389e..37699de4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -105,7 +105,7 @@ "ai": "AI", "aiChatTitle": "Ask Trakli", "aiChatEmptyHint": "Ask anything about your finances", - "aiChatComposerPlaceholder": "Ask a question…", + "aiChatComposerPlaceholder": "Ask me anything…", "aiChatNewChat": "New chat", "aiChatThinking": "Thinking…", "aiChatError": "Something went wrong. Try again.", @@ -144,6 +144,7 @@ "noError": "No error", "internetConnectionDesc": "Please check your internet connection and try again.", "notFoundDesc": "The requested resource was not found.", + "fileTooLargeDesc": "The file is too large to upload. Please choose a smaller file.", "serverErrorDesc": "A server error occurred. Please try again later.", "cacheErrorDesc": "A cache error occurred. Please try again later.", "syncErrorDesc": "A sync error occurred. Please try again later.", @@ -740,5 +741,34 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Good morning", + "aiGreetingAfternoon": "Good afternoon", + "aiGreetingEvening": "Good evening", + "aiStatNetFlow": "Net flow", + "aiStatSaved": "Saved", + "aiLandingNoData": "Add a few transactions and I'll start spotting trends for you.", + "aiSuggestSpendMonth": "How much did I spend this month?", + "aiSuggestTopCategory": "What was my biggest expense category last week?", + "aiSuggestIncomeVsExpense": "Show my income vs expenses for the year.", + "aiSuggestBalance": "What was my balance on the 15th?", + "aiContinueSession": "Continue previous chat", + "aiStartNewSession": "Start a new chat", + "aiStatTransactions": "Transactions", + "aiStatAvgExpense": "Avg expense", + "aiSpotlightSaved": "You've saved {rate}% of your income this month.", + "aiSpotlightSpent": "You've spent {amount} so far this month.", + "aiSpotlightNetPositive": "You're net positive {amount} this month.", + "aiSpotlightNetNegative": "You're spending {amount} more than you earn this month.", + "aiSpotlightTransactions": "You've logged {count} transactions this month.", + "aiSpotlightAvgExpense": "Your average expense is {amount} this month.", + "aiServiceUnavailable": "The data service is temporarily unavailable. Please try again shortly.", + "none": "None", + "dismissed": "Dismissed", + "aiReviewImport": "Review import", + "aiOpenCanvas": "Open in canvas", + "aiImportStatusAnalyzing": "Analyzing…", + "aiImportStatusReady": "Ready to review", + "aiImportStatusConfirmed": "Imported", + "aiImportStatusFailed": "Failed" } diff --git a/assets/translations/es.json b/assets/translations/es.json index d280e9c8..dc263b0e 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -135,6 +135,7 @@ "noError": "No hay error", "internetConnectionDesc": "Por favor, verifica tu conexión a internet y vuelve a intentarlo.", "notFoundDesc": "La solicitud no encontró el recurso solicitado.", + "fileTooLargeDesc": "El archivo es demasiado grande para subirlo. Elija un archivo más pequeño.", "serverErrorDesc": "Ocurrió un error del servidor. Por favor, vuelve a intentarlo más tarde.", "cacheErrorDesc": "Ocurrió un error de caché. Por favor, vuelve a intentarlo más tarde.", "syncErrorDesc": "Ocurrió un error de sincronización. Por favor, vuelve a intentarlo más tarde.", @@ -740,5 +741,28 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Buenos días", + "aiGreetingAfternoon": "Buenas tardes", + "aiGreetingEvening": "Buenas noches", + "aiStatNetFlow": "Flujo neto", + "aiStatSaved": "Ahorrado", + "aiLandingNoData": "Agrega algunas transacciones y empezaré a detectar tendencias para ti.", + "aiSuggestSpendMonth": "¿Cuánto gasté este mes?", + "aiSuggestTopCategory": "¿Cuál fue mi mayor categoría de gastos la semana pasada?", + "aiSuggestIncomeVsExpense": "Muestra mis ingresos frente a gastos del año.", + "aiSuggestBalance": "¿Cuál era mi saldo el día 15?", + "aiContinueSession": "Continuar el chat anterior", + "aiStartNewSession": "Iniciar un nuevo chat", + "aiStatTransactions": "Transacciones", + "aiStatAvgExpense": "Gasto medio", + "aiServiceUnavailable": "El servicio de datos no está disponible temporalmente. Inténtalo de nuevo en breve.", + "none": "Ninguno", + "dismissed": "Descartado", + "aiReviewImport": "Revisar importación", + "aiOpenCanvas": "Abrir en el lienzo", + "aiImportStatusAnalyzing": "Analizando…", + "aiImportStatusReady": "Listo para revisar", + "aiImportStatusConfirmed": "Importado", + "aiImportStatusFailed": "Error" } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 457b76e6..ad712ce9 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -134,6 +134,7 @@ "noError": "Aucun erreur", "internetConnectionDesc": "Veuillez vérifier votre connexion internet et réessayer.", "notFoundDesc": "La ressource demandée n'a pas été trouvée.", + "fileTooLargeDesc": "Le fichier est trop volumineux pour être téléversé. Veuillez choisir un fichier plus petit.", "serverErrorDesc": "Une erreur de serveur est survenue. Veuillez réessayer plus tard.", "cacheErrorDesc": "Une erreur de cache est survenue. Veuillez réessayer plus tard.", "syncErrorDesc": "Une erreur de synchronisation est survenue. Veuillez réessayer plus tard.", @@ -740,5 +741,28 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Bonjour", + "aiGreetingAfternoon": "Bon après-midi", + "aiGreetingEvening": "Bonsoir", + "aiStatNetFlow": "Flux net", + "aiStatSaved": "Épargné", + "aiLandingNoData": "Ajoutez quelques transactions et je commencerai à repérer des tendances pour vous.", + "aiSuggestSpendMonth": "Combien ai-je dépensé ce mois-ci ?", + "aiSuggestTopCategory": "Quelle a été ma plus grosse catégorie de dépenses la semaine dernière ?", + "aiSuggestIncomeVsExpense": "Affiche mes revenus et dépenses pour l'année.", + "aiSuggestBalance": "Quel était mon solde le 15 ?", + "aiContinueSession": "Continuer le chat précédent", + "aiStartNewSession": "Démarrer un nouveau chat", + "aiStatTransactions": "Transactions", + "aiStatAvgExpense": "Dépense moy.", + "aiServiceUnavailable": "Le service de données est temporairement indisponible. Veuillez réessayer sous peu.", + "none": "Aucun", + "dismissed": "Ignoré", + "aiReviewImport": "Examiner l'import", + "aiOpenCanvas": "Ouvrir dans le canevas", + "aiImportStatusAnalyzing": "Analyse en cours…", + "aiImportStatusReady": "Prêt à examiner", + "aiImportStatusConfirmed": "Importé", + "aiImportStatusFailed": "Échec" } diff --git a/assets/translations/it.json b/assets/translations/it.json index 4a6c207f..c7e0041a 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -134,6 +134,7 @@ "noError": "Nessun errore", "internetConnectionDesc": "Per favore, controlla la tua connessione internet e riprova.", "notFoundDesc": "La risorsa richiesta non è stata trovata.", + "fileTooLargeDesc": "Il file è troppo grande per essere caricato. Scegli un file più piccolo.", "serverErrorDesc": "Si è verificato un errore del server. Per favore, riprova più tardi.", "cacheErrorDesc": "Si è verificato un errore di cache. Per favore, riprova più tardi.", "syncErrorDesc": "Si è verificato un errore di sincronizzazione. Per favore, riprova più tardi.", @@ -740,5 +741,28 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Buongiorno", + "aiGreetingAfternoon": "Buon pomeriggio", + "aiGreetingEvening": "Buonasera", + "aiStatNetFlow": "Flusso netto", + "aiStatSaved": "Risparmiato", + "aiLandingNoData": "Aggiungi qualche transazione e inizierò a individuare le tendenze per te.", + "aiSuggestSpendMonth": "Quanto ho speso questo mese?", + "aiSuggestTopCategory": "Qual è stata la mia categoria di spesa maggiore la settimana scorsa?", + "aiSuggestIncomeVsExpense": "Mostra le mie entrate e uscite dell'anno.", + "aiSuggestBalance": "Qual era il mio saldo il 15?", + "aiContinueSession": "Continua la chat precedente", + "aiStartNewSession": "Inizia una nuova chat", + "aiStatTransactions": "Transazioni", + "aiStatAvgExpense": "Spesa media", + "aiServiceUnavailable": "Il servizio dati è temporaneamente non disponibile. Riprova a breve.", + "none": "Nessuno", + "dismissed": "Ignorato", + "aiReviewImport": "Rivedi importazione", + "aiOpenCanvas": "Apri nel canvas", + "aiImportStatusAnalyzing": "Analisi in corso…", + "aiImportStatusReady": "Pronto per la revisione", + "aiImportStatusConfirmed": "Importato", + "aiImportStatusFailed": "Non riuscito" } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 3bef411b..1d19d06e 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -144,6 +144,7 @@ "noError": "Нет ошибки", "internetConnectionDesc": "Пожалуйста, проверьте подключение к Интернету и повторите попытку.", "notFoundDesc": "Запрошенный ресурс не найден.", + "fileTooLargeDesc": "Файл слишком большой для загрузки. Пожалуйста, выберите файл поменьше.", "serverErrorDesc": "Произошла ошибка сервера. Пожалуйста, повторите попытку позже.", "cacheErrorDesc": "Произошла ошибка кэша. Пожалуйста, повторите попытку позже.", "syncErrorDesc": "Произошла ошибка синхронизации. Пожалуйста, повторите попытку позже.", @@ -740,5 +741,28 @@ "supportEmailSubject": "Trakli Support Request", "supportEmailCopied": "{0} (copied to clipboard)", "defaultUserName": "Trakli", - "appTitle": "Trakli" + "appTitle": "Trakli", + "aiGreetingMorning": "Доброе утро", + "aiGreetingAfternoon": "Добрый день", + "aiGreetingEvening": "Добрый вечер", + "aiStatNetFlow": "Чистый поток", + "aiStatSaved": "Сэкономлено", + "aiLandingNoData": "Добавьте несколько операций, и я начну находить закономерности.", + "aiSuggestSpendMonth": "Сколько я потратил в этом месяце?", + "aiSuggestTopCategory": "Какая категория расходов была наибольшей на прошлой неделе?", + "aiSuggestIncomeVsExpense": "Покажи мои доходы и расходы за год.", + "aiSuggestBalance": "Какой у меня был баланс 15-го числа?", + "aiContinueSession": "Продолжить предыдущий чат", + "aiStartNewSession": "Начать новый чат", + "aiStatTransactions": "Операции", + "aiStatAvgExpense": "Средний расход", + "aiServiceUnavailable": "Сервис данных временно недоступен. Повторите попытку позже.", + "none": "Нет", + "dismissed": "Отклонено", + "aiReviewImport": "Просмотреть импорт", + "aiOpenCanvas": "Открыть в холсте", + "aiImportStatusAnalyzing": "Анализ…", + "aiImportStatusReady": "Готово к проверке", + "aiImportStatusConfirmed": "Импортировано", + "aiImportStatusFailed": "Ошибка" } diff --git a/doc/bloc_lint_plan.md b/doc/bloc_lint_plan.md new file mode 100644 index 00000000..37dd6ed0 --- /dev/null +++ b/doc/bloc_lint_plan.md @@ -0,0 +1,303 @@ +# Bloc Lint + Framework-Aware Static Analysis Plan + +> Status: **Planned — not yet implemented.** This is the implementation +> blueprint. Nothing here has been added to the codebase yet. + +## 1. Goal & scope + +Layer **Bloc-aware static analysis** on top of the existing `flutter_lints` +baseline, so the analyzer catches Cubit/Bloc anti-patterns that generic Dart +lints miss (business logic leaking Flutter imports, public mutable fields on +state, inconsistent file naming, etc.). + +This mirrors what tmail-flutter does with `riverpod_lint` — framework-specific +lints for their state-management library. Our library is **Bloc/Cubit** +(`flutter_bloc ^9.1.1`, `bloc ^9.0.0`), so the direct analog is the official +[`bloc_lint`](https://pub.dev/packages/bloc_lint) package from the Bloc author. + +### Scope + +- **Primary:** add `bloc_lint` (official, Bloc-specific) and wire it into editor, + pre-commit, and CI. +- **Optional (Phase 4):** add `custom_lint` for *project-specific* rules we write + ourselves (the framework `riverpod_lint` is built on). +- **Optional (appendix):** tighten the base `flutter_lints` ruleset, which is + currently the bare default with zero customizations. + +### Non-goals + +- Migrating state management (we keep Cubit). +- Adopting `very_good_analysis` wholesale (considered in the appendix, not the + main plan). + +## 2. Current state (what this builds on) + +- [analysis_options.yaml](../analysis_options.yaml): includes + `package:flutter_lints/flutter.yaml`, ignores `invalid_annotation_target`, + excludes `android/vendor/**` + `ios/vendor/**`. **No custom lint rules, no + generated-file exclusion.** +- [pubspec.yaml](../pubspec.yaml): `flutter_lints: ^4.0.0`; SDK + `>=3.4.3 <4.0.0`; `flutter_bloc ^9.1.1`, `bloc ^9.0.0`, `bloc_test ^10.0.0`. +- **Pre-commit** (husky + `lint_staged`) already runs, per pubspec: + `dart fix --apply && dart format && dart analyze --fatal-infos --fatal-warnings` + on staged `lib/**.dart` (excluding `*.g.dart` / `*.freezed.dart`). This is + already stricter than tmail's setup. +- **CI** [build.yml](../.github/workflows/build.yml): `analyze` job runs + `build_runner` then `flutter analyze`; `test` job runs `flutter test`. +- Cubits live per-feature under `lib/presentation/**/cubit/` (e.g. + `transactions/cubit/transaction_cubit.dart`, `auth/cubits/...`). + +## 3. Important: `bloc_lint` is a *separate* linter + +This is the single most important thing to understand before implementing. + +`bloc_lint` is **not** a `flutter_lints` rule set and **not** a Dart analyzer +plugin in the `dart analyze` pipeline. It is a standalone linter executed by the +**`bloc` CLI** (shipped via the `bloc_tools` global package), reading its own +`bloc:` section from `analysis_options.yaml`. + +Consequences: + +- `flutter analyze` will **not** report `bloc_lint` violations. We must add a + dedicated `bloc lint .` step to pre-commit and CI, or they won't be enforced. +- The existing `include: package:flutter_lints/flutter.yaml` and the new `bloc:` + section coexist — they're consumed by two different tools. +- Editor diagnostics require the Bloc VS Code/IntelliJ extension (LSP), separate + from the Dart analyzer. (Confirm current editor-integration story during + Phase 0; CLI enforcement works regardless.) + +> This is different from tmail's `riverpod_lint`, which *is* a `custom_lint` +> plugin and therefore shows up in `dart analyze`. For Bloc, the official tool is +> CLI-based. If we want everything inside `dart analyze` instead, that's the +> `custom_lint` route in §7. + +## 4. Compatibility / blocker to resolve first + +`bloc_lint` requires **Dart ≥ 3.7.0**. Our `pubspec.yaml` floor is `>=3.4.3`. + +- Flutter `3.38.9` (pinned in `.fvmrc` and CI) ships a Dart that is ≥ 3.7, so it + runs fine in practice. +- But we must **bump the pubspec SDK floor** to `>=3.7.0` (or whatever the + bundled Dart is) so resolution succeeds and we don't claim to support a Dart + that can't run our dev tooling. +- Action in Phase 0: run `dart --version` under FVM, then set + `environment: sdk: ">=3.7.0 <4.0.0"` (adjust to actual). Verify the app still + resolves and builds — this is the only change with blast radius beyond tooling. + +## 5. Dependencies + +```bash +# global CLI (developer machines + CI) +dart pub global activate bloc_tools + +# dev dependency +dart pub add --dev bloc_lint +``` + +Resulting `pubspec.yaml` `dev_dependencies` addition: + +```yaml +dev_dependencies: + # ...existing... + bloc_lint: ^0.x.x # pin the version `dart pub add` resolves for our SDK +``` + +## 6. Configuration + +Because we already use `include: package:flutter_lints/flutter.yaml`, we **do +not** use `bloc_lint`'s `include: package:bloc_lint/recommended.yaml` (only one +`include:` is allowed). Instead, add an explicit top-level `bloc:` section. + +Proposed [analysis_options.yaml](../analysis_options.yaml): + +```yaml +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - "android/vendor/**" + - "ios/vendor/**" + # consider also excluding generated code from analysis noise: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "lib/di/injection.config.dart" + - "lib/gen/**" + +# Consumed by the `bloc` CLI (bloc_lint), NOT by `flutter analyze`. +bloc: + rules: + - avoid_flutter_imports + - avoid_public_fields + - prefer_file_naming_conventions + # add incrementally (see §6.2): + # - prefer_void_public_cubit_methods + # - prefer_cubit + # - avoid_public_bloc_methods +``` + +> Verify `flutter analyze` does not warn about the unknown `bloc:` top-level key. +> If it does, that's a Phase 0 finding to resolve (e.g. keep bloc config in a +> separate options file the `bloc` CLI points at). + +### 6.1 The rules (recommended set = 5, complete = 9) + +| Rule | What it catches | Fit for Trakli | +|---|---|---| +| `avoid_flutter_imports` | Bloc/Cubit business logic importing `package:flutter/material.dart` etc. | **High** — keeps cubits pure / testable; aligns with our clean-architecture layering | +| `avoid_public_fields` | Public mutable fields on Bloc/Cubit/state | **High** — protects immutability (we use `equatable`/`freezed` states) | +| `prefer_file_naming_conventions` | Enforces `*_cubit.dart` / `*_bloc.dart` / `*_state.dart` naming | **High** — we already follow this; the rule prevents drift | +| `avoid_public_bloc_methods` | Public methods on a `Bloc` (should use events) | **Low/medium** — we use Cubits, not event-based Blocs; mostly inert | +| `prefer_void_public_cubit_methods` | Public Cubit methods returning values instead of `void`/emitting state | **Medium** — many of our cubit methods are `Future`; verify it doesn't flag those before enabling | +| `avoid_build_context_extensions` / `prefer_build_context_extensions` | Opinionated, mutually exclusive — pick at most one | **Optional** | +| `prefer_cubit` | Flags `Bloc` usage, nudging toward `Cubit` | **Medium** — matches our Cubit-only convention; enables consistency | +| `prefer_bloc` | Opposite of `prefer_cubit` | **Do not enable** — conflicts with our Cubit convention | + +### 6.2 Recommended adoption order + +1. **Start with 3 low-friction, high-value rules:** `avoid_flutter_imports`, + `avoid_public_fields`, `prefer_file_naming_conventions`. These are almost + certainly near-clean already. +2. **Trial `prefer_cubit`** to lock in the Cubit-only convention. +3. **Trial `prefer_void_public_cubit_methods`** — run it, count violations on our + `Future` cubit methods; enable only if the churn is acceptable. +4. Leave `prefer_bloc` and the redundant build_context pair off. + +## 7. Optional — `custom_lint` for project-specific rules (Phase 4) + +`bloc_lint` covers Bloc idioms. For **Trakli-specific** conventions, `custom_lint` +(the framework `riverpod_lint` is built on) lets us write our own analyzer rules +that *do* surface in `dart analyze` and the IDE. Candidate house rules: + +- No hardcoded UI strings — require `LocaleKeys.*.tr()` (we use + `easy_localization`). +- Repository/usecase naming and layering (no `presentation` importing `data` + directly, etc.). +- Enforce `Key` presence on key interactive widgets (ties into the + [Patrol plan](patrol_e2e_testing_plan.md)). + +Cost: `custom_lint` version must track the analyzer/Dart SDK closely, and we'd +maintain rule code. Treat as a later, separate effort — only if the team wants +bespoke enforcement. + +## 8. Enforcement wiring + +### 8.1 Pre-commit (husky + lint_staged) + +`bloc lint` is repo-wide, not per-file, so add it to the husky **pre-commit** +hook *after* `lint_staged` rather than inside the `lint_staged` globs: + +```sh +# .husky/pre-commit (append) +dart run lint_staged +bloc lint . # NEW — fails the commit on bloc_lint violations +flutter analyze +``` + +(Confirm the `bloc` CLI is on PATH for contributors; document +`dart pub global activate bloc_tools` in [CONTRIBUTING.md](../CONTRIBUTING.md).) + +### 8.2 CI (build.yml analyze job) + +Add a step to the existing `analyze` job in +[build.yml](../.github/workflows/build.yml): + +```yaml + analyze: + name: Analyze code (SAST) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: ${{ env.FLUTTER_CHANNEL }} + cache: true + - run: flutter pub run build_runner build --delete-conflicting-outputs + - run: flutter analyze + - run: dart pub global activate bloc_tools # NEW + - run: bloc lint . # NEW +``` + +## 9. Rollout strategy (avoid a red wall on day one) + +1. **Measure:** install, configure the 3 starter rules, run `bloc lint .`, count + violations. Do **not** enable in CI yet. +2. **Fix or baseline:** clean up violations in a dedicated PR. For anything + intentional, use `// ignore: ` / `// ignore_for_file: ` with a + reason comment. +3. **Enforce:** once green, add the CI + pre-commit steps so regressions are + blocked. +4. **Expand:** trial additional rules one at a time, repeating measure→fix→enforce. + +## 10. Decisions needed (resolve in Phase 0) + +1. **SDK floor bump** to `>=3.7.0` — confirm the actual bundled Dart and that the + app still resolves/builds (§4). +2. Does `flutter analyze` tolerate the `bloc:` key, or do we need a split config + file (§6)? +3. Which rules ship in the first enforced set — the 3 starters only, or also + `prefer_cubit` (§6.2)? +4. Adopt `custom_lint` for house rules now or defer (§7)? +5. Do we also exclude generated files from `flutter analyze` while here (§6)? + +## 11. Implementation roadmap (checklist) + +**Phase 0 — Spike & decisions (≈0.5 day)** +- [ ] `dart --version` under FVM; bump pubspec SDK floor; confirm build. +- [ ] `dart pub global activate bloc_tools`; `dart pub add --dev bloc_lint`. +- [ ] Add `bloc:` section with the 3 starter rules; confirm `flutter analyze` + stays clean (no unknown-key error). +- [ ] Run `bloc lint .`; record violation count. + +**Phase 1 — Clean up & enforce starters (≈0.5–1 day)** +- [ ] Fix/baseline starter-rule violations in one PR. +- [ ] Add `bloc lint .` to husky pre-commit + CI `analyze` job. +- [ ] Document `bloc_tools` setup in [CONTRIBUTING.md](../CONTRIBUTING.md). + +**Phase 2 — Expand ruleset (≈0.5 day, iterative)** +- [ ] Trial `prefer_cubit`, then `prefer_void_public_cubit_methods`; enable per + §9. + +**Phase 3 (optional) — Tighten base lints (appendix)** +- [ ] Enable a curated set of additional core Dart lints (see appendix). + +**Phase 4 (optional) — custom_lint house rules (§7)** +- [ ] Spike one project-specific rule; decide whether to invest. + +## Appendix — optional: tighten the base `flutter_lints` ruleset + +Independent of Bloc, our `analysis_options.yaml` enables zero rules beyond the +`flutter_lints` default. High-value, low-friction additions to consider under the +existing `linter: rules:` block: + +```yaml +linter: + rules: + - prefer_single_quotes + - require_trailing_commas + - avoid_redundant_argument_values + - unnecessary_parenthesis + - directives_ordering + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - use_super_parameters + - avoid_unnecessary_containers + - sized_box_for_whitespace +``` + +Heavier alternative: replace the `flutter_lints` include with +[`very_good_analysis`](https://pub.dev/packages/very_good_analysis) for a much +stricter baseline (closer to a "max strictness" posture). This is a bigger +cleanup and a separate decision — flagged, not recommended by default. + +## 12. References + +- [bloc_lint — pub.dev](https://pub.dev/packages/bloc_lint) +- [Bloc Library — Linter Configuration](https://bloclibrary.dev/lint/configuration/) +- [Bloc Library — Customizing Lint Rules](https://bloclibrary.dev/lint/customizing-rules/) +- [Dart — Customizing static analysis](https://dart.dev/tools/analysis) +- Reference (tmail-flutter uses `riverpod_lint`): https://github.com/linagora/tmail-flutter +- Internal: [patrol_e2e_testing_plan.md](patrol_e2e_testing_plan.md), + [bloc_cubit_patterns.md](bloc_cubit_patterns.md) diff --git a/doc/patrol_e2e_testing_plan.md b/doc/patrol_e2e_testing_plan.md new file mode 100644 index 00000000..4a1cbdaf --- /dev/null +++ b/doc/patrol_e2e_testing_plan.md @@ -0,0 +1,390 @@ +# Patrol E2E Testing Plan + +> Status: **Planned — not yet implemented.** This document is the implementation +> blueprint. Nothing in here has been added to the codebase yet. + +## 1. Goal & scope + +Add native end-to-end (E2E) / integration testing with +[Patrol](https://patrol.leancode.co/) to cover the critical user journeys that +unit and widget tests cannot reach — flows that cross native boundaries +(permission dialogs, system file/photo pickers, OAuth webviews, secure storage) +and span multiple screens, cubits, the Drift database, and the network layer. + +We currently have **18 unit/widget/drift tests** and **zero integration tests**. +The CI `test` job in [build.yml](../.github/workflows/build.yml) runs only +`flutter test`. This plan closes that gap. + +### Why Patrol (vs. plain `integration_test`) + +| Need in Trakli | Plain `integration_test` | Patrol | +|---|---|---| +| Tap native permission dialogs (camera, photos) | ❌ | ✅ `$.native.grantPermissionWhenInUse()` | +| Drive system file picker / `image_picker` / `pdfx` import | ❌ | ✅ native automation | +| OAuth webview (Google / Apple sign-in) | ❌ flaky | ✅ native interaction | +| Hot-restart between tests, better finders | partial | ✅ `patrol_finders` | +| Run on real devices / CI | ✅ | ✅ via `patrol_cli` | + +Trakli leans on `image_picker`, `image_cropper`, `file_picker`, `pdfx`, +`flutter_secure_storage`, `google_sign_in`, and `sign_in_with_apple` — all of +which touch native UI. That is exactly Patrol's sweet spot. + +### Non-goals + +- Replacing unit/widget/drift tests (they stay; E2E is the top of the pyramid). +- Visual / golden testing. +- Load/performance testing. + +## 2. Key facts this plan is built on + +- **Flutter** pinned to `3.38.9` via `.fvmrc`; Dart SDK `>=3.4.3 <4.0.0`. +- **Flavors:** `development`, `staging`, `production` with entrypoints + `lib/main_development.dart`, `lib/main_staging.dart`, `lib/main_production.dart`, + all delegating to [bootstrap.dart](../lib/bootstrap.dart). +- **App ID:** `com.whilesmart.trakli` (`.dev`, `.stg` suffixes per flavor). +- **iOS:** deployment target `13.0`. +- **DI:** `configureDependencies(String env)` in [injection.dart](../lib/di/injection.dart); + base URL is flavor-driven in [http_module.dart](../lib/core/module/http_module.dart) + (dev → `https://api.dev.trakli.app/api/v1/`). +- **Auth/session:** access + refresh tokens live in **SharedPreferences** + (`auth_token`, `auth_refresh_token`) via + [token_manager.dart](../lib/data/datasources/auth/token_manager.dart); + onboarding state in `onboarding_completed` + ([preference_manager.dart](../lib/data/datasources/auth/preference_manager.dart)). +- **Root widget:** `AppWidget` ([app_widget.dart](../lib/presentation/app_widget.dart)), + a `MultiBlocProvider` that routes authenticated → `MainNavigationScreen`, + unauthenticated → `LoginScreen`/`OnboardingScreen`. +- **Startup side effects (important for tests):** `bootstrap()` calls + `Firebase.initializeApp`, crash reporting, `FeatureRemoteConfig`, + `RemoteUpdateCheck`, and attaches a 5-minute sync trigger. These must be + neutralized or stubbed under test (see §5). +- **No widget `Key`s** exist today → finders rely on text/icon. We add keys as a + prerequisite (see §4). + +## 3. Dependencies & versions + +Add to `pubspec.yaml` `dev_dependencies`: + +```yaml +dev_dependencies: + patrol: ^4.0.0 # verify the exact version compatible with Flutter 3.38.9 + integration_test: + sdk: flutter +``` + +CLI (developer machines + CI): + +```bash +dart pub global activate patrol_cli +patrol doctor # verifies Android/iOS native setup +``` + +> Pin the precise `patrol` version with `flutter pub add dev:patrol` so it +> resolves against the pinned SDK rather than guessing. tmail-flutter runs +> `patrol 4.5.0`, which is a good reference point for this ecosystem. + +Add Patrol config at repo root `patrol.yaml`: + +```yaml +android: + package_name: com.whilesmart.trakli.dev +ios: + bundle_id: com.whilesmart.trakli.dev # confirm dev bundle id in Xcode +``` + +## 4. Prerequisite: make the app testable (do this first) + +These small refactors make every later test stable and cheap. They are the +highest-leverage part of the plan. + +### 4a. Extract `initializeApp()` from `bootstrap()` + +Today `bootstrap()` does async init **and** calls `runApp`. Tests need the init +without `runApp`. Split it: + +```dart +// lib/bootstrap.dart +Future initializeApp(String env) async { + WidgetsFlutterBinding.ensureInitialized(); + await _initFirebase(); // skippable under test + configureDependencies(env); + await _initServices(); // version, remote config, etc. + await EasyLocalization.ensureInitialized(); +} + +Future bootstrap(String env) async { + await initializeApp(env); + _attachSyncTriggers(); // NOT called in tests + runApp(_wrapApp(const AppWidget())); +} +``` + +Tests then call `initializeApp('development')` and pump `_wrapApp(AppWidget())` +themselves. This keeps prod behavior identical while giving tests a clean hook. + +### 4b. Add `Key`s to critical interactive widgets + +Text-based finders break when copy/translations change and are ambiguous when a +label repeats. Add stable keys to the widgets the E2E tests drive. Proposed +convention: `Key('_')`. + +Minimum set for Phase 1–2: + +| Screen | Widget | Proposed key | +|---|---|---| +| `login_screen.dart` | "Login with email" button | `login_email_btn` | +| `login_with_email_screen.dart` | email field | `login_email_field` | +| `login_with_email_screen.dart` | password field | `login_password_field` | +| `login_with_email_screen.dart` | submit button | `login_submit_btn` | +| `main_navigation_screen.dart` | add-transaction FAB | already `heroTag: 'addFab'` → add `Key('home_add_fab')` | +| `add_transaction_form_compact_layout.dart` | amount field | `txn_amount_field` | +| `add_transaction_form_compact_layout.dart` | submit button | `txn_submit_btn` | +| `wallet_screen.dart` | add-wallet button | `wallet_add_btn` | + +> Keys are additive and harmless to production. Bundle them with each test PR so +> they land next to the test that needs them. + +### 4c. Decide the backend strategy (decision needed — see §9) + +Three options, pick per environment: + +1. **Live `development` backend** — simplest; tests hit `api.dev.trakli.app`. + Pro: realistic. Con: flaky/slow, needs a stable seeded test account, network + dependency in CI. +2. **Mock server / Dio interceptor** — register a fake `Dio` (or + `http_mock_adapter`) in `getIt` after `configureDependencies`, returning + canned JSON. Pro: hermetic, fast, deterministic. Con: must maintain fixtures. +3. **Hybrid** — smoke/auth against live dev; data flows against mocked HTTP. + +Recommended default: **option 2 (mocked HTTP)** for CI determinism, with a small +nightly **option 1** suite against live dev for contract drift. + +## 5. Test harness design + +Create `integration_test/` with a shared harness so individual tests stay short. + +``` +integration_test/ +├── e2e_test.dart # entry that aggregates suites (for `patrol test`) +├── helpers/ +│ ├── patrol_harness.dart # initializeApp + pump AppWidget + reset DI/db +│ ├── auth_seed.dart # seed/clear tokens + onboarding flags +│ ├── http_mock.dart # fake Dio / fixtures (if option 2) +│ └── finders.dart # KeyedFinders for the keys added in §4b +└── flows/ + ├── smoke_test.dart + ├── auth_login_test.dart + ├── add_transaction_test.dart + ├── wallet_create_test.dart + └── navigation_test.dart +``` + +### Harness responsibilities (`patrol_harness.dart`) + +```dart +Future pumpTrakliApp( + PatrolIntegrationTester $, { + String env = 'development', + bool authenticated = false, +}) async { + await getIt.reset(); // clean container per test + await initializeApp(env); // from §4a (Firebase stubbed in test mode) + if (option2_mockHttp) registerMockHttp(); + await resetDatabase(); // AppDatabase().deleteAllData() or fresh db + authenticated ? await seedAuthToken() : await clearAuthToken(); + await $.pumpWidgetAndSettle(wrapApp(const AppWidget())); +} +``` + +### Auth seeding (`auth_seed.dart`) + +```dart +Future seedAuthToken() async { + final tm = getIt(); + await tm.persistToken('test-access-token'); + await tm.persistRefreshToken('test-refresh-token'); + // mark onboarding complete so we land on MainNavigationScreen + await getIt().setOnboardingCompleted(true); +} + +Future clearAuthToken() async => getIt().clearToken(); +``` + +This lets data-flow tests (add transaction, wallet) start already logged in, +instead of going through OAuth every run. + +### Firebase under test + +`Firebase.initializeApp` must not call the network in CI. Options: guard +`_initFirebase()` behind a `const bool.fromEnvironment('PATROL_TEST')` flag set +via `--dart-define`, or use `firebase_auth_mocks` / no-op. Crashlytics, remote +config, and the 5-minute sync trigger should be **disabled** in test mode. + +## 6. Test scenarios (prioritized) + +Ordered by value-to-effort. Each is one file under `integration_test/flows/`. + +### Phase 1 — Smoke & auth (prove the harness works) + +1. **App boots** — `pumpTrakliApp(authenticated: false)` settles and shows + onboarding/login without crashing. (Validates §4a + §5 end-to-end.) +2. **Login with email (happy path)** — from `LoginScreen` → tap + `login_email_btn` → fill `login_email_field` / `login_password_field` → tap + `login_submit_btn` → assert `MainNavigationScreen` (find `home_add_fab`). +3. **Login validation error** — bad credentials → assert error snackbar/text. + +### Phase 2 — Core money flows (the product's reason to exist) + +4. **Add expense transaction** — start authenticated → tap `home_add_fab` → + fill amount, pick wallet/category/date → tap `txn_submit_btn` → assert it + appears in the home/transactions list and persists in Drift. +5. **Add income transaction** — variant toggling income/expense. +6. **Create wallet** — Wallet tab → `wallet_add_btn` → fill form → assert it + shows in the wallet list and is selectable in the add-transaction dropdown. + +### Phase 3 — Native-boundary flows (Patrol's unique value) + +7. **Attach receipt via camera** — add-transaction → attachment → Patrol grants + the camera permission dialog (`$.native.grantPermissionWhenInUse()`) → + capture → assert attachment chip appears. +8. **Import document** (`file_picker` + `pdfx`) — drive the native picker, select + a bundled fixture PDF, assert parsed/preview state. +9. **OAuth sign-in (Google)** — drive the native webview far enough to assert the + flow launches and returns (may stay in nightly-only suite due to flakiness). + +### Phase 4 — Cross-cutting + +10. **Bottom-nav navigation** — visit Home / Statistics / Wallet / Budgets, + assert each renders. +11. **Pull-to-refresh sync** — trigger refresh on home, assert sync runs and + list reconciles (against mocked HTTP). +12. **Logout** — drawer → logout → assert tokens cleared and back at login. + +## 7. Example test (reference implementation) + +```dart +// integration_test/flows/add_transaction_test.dart +import 'package:patrol/patrol.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../helpers/patrol_harness.dart'; +import '../helpers/finders.dart'; + +void main() { + patrolTest('logged-in user can add an expense', ($) async { + await pumpTrakliApp($, authenticated: true); + + // Land on home, open the add-transaction sheet + await $(K.homeAddFab).tap(); + + await $(K.txnAmountField).enterText('2500'); + // category / wallet dropdowns — keyed in §4b + await $(K.txnCategoryDropdown).tap(); + await $(#groceriesOption).tap(); + + await $(K.txnSubmitBtn).tap(); + await $.pumpAndSettle(); + + // Assert it surfaced in the list and persisted + expect($('2500'), findsOneWidget); + final count = await getIt().transactionCount(); + expect(count, greaterThan(0)); + }); +} +``` + +## 8. CI integration + +Add a dedicated workflow (or a gated job in +[build.yml](../.github/workflows/build.yml)). Keep it **separate from the fast +`flutter test` job** so unit tests stay quick and E2E can run on a heavier +runner / schedule. + +```yaml +# .github/workflows/e2e.yml +name: E2E (Patrol) +on: + pull_request: + paths: ['lib/**', 'integration_test/**', 'pubspec.yaml'] + schedule: + - cron: '0 2 * * *' # nightly live-dev suite +jobs: + android-e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: { flutter-version: 3.38.9, cache: true } + - run: dart pub global activate patrol_cli + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + arch: x86_64 + script: patrol test --flavor development --target integration_test/e2e_test.dart +``` + +Notes: +- Android emulator on `ubuntu-latest` is cheapest; add an iOS job on + `macos-latest` once Android is green. +- Use `--flavor development` so it builds the dev entrypoint/app id. +- Pass secrets (test account creds, `--dart-define PATROL_TEST=true`) via repo + secrets; reference [ENV_AND_SECRETS.md](../.github/ENV_AND_SECRETS.md). +- Gate PRs on the **Phase 1–2 (mocked)** suite only; keep native/OAuth and + live-dev flows in the nightly `schedule` run to avoid PR flakiness. + +## 9. Open decisions (resolve before Phase 1) + +1. **Backend strategy** (§4c): mocked HTTP vs live dev vs hybrid. → recommend + mocked for PR CI, live for nightly. +2. **Firebase in test**: dart-define guard vs mock package. +3. **Test account**: do we provision a seeded `development` user + reset + mechanism, or stay fully hermetic with mocks? +4. **iOS scope**: Android-only at first, or both platforms from day one? +5. **Where keys live**: confirm the `_` naming and whether to + centralize them in a `keys.dart` constants file. + +## 10. Implementation roadmap (checklist) + +**Phase 0 — Setup (≈0.5 day)** +- [ ] Add `patrol` + `integration_test` to `pubspec.yaml`; `patrol doctor` green. +- [ ] Native setup: Android instrumentation runner + `androidTest`, iOS UITest + target (`patrol` package setup steps). +- [ ] Add `patrol.yaml`. + +**Phase 1 — Testability + smoke/auth (≈1.5 days)** +- [ ] Refactor `bootstrap.dart` → `initializeApp()` (§4a). +- [ ] Add Phase 1–2 widget `Key`s (§4b). +- [ ] Build harness + auth seeding + (chosen) HTTP strategy (§5). +- [ ] Write smoke + login tests; get green locally. + +**Phase 2 — Core money flows (≈2 days)** +- [ ] Add transaction (expense/income), create wallet tests. +- [ ] Wire the Phase 1–2 suite into PR CI (mocked) (§8). + +**Phase 3 — Native-boundary flows (≈2 days)** +- [ ] Camera/photo permission + attachment, file/PDF import, OAuth (nightly). + +**Phase 4 — Cross-cutting + hardening (≈1 day)** +- [ ] Navigation, pull-to-refresh sync, logout. +- [ ] Nightly live-dev suite; flake triage; document run instructions in + [CONTRIBUTING.md](../CONTRIBUTING.md). + +## 11. Local run cheatsheet (for when implemented) + +```bash +# one flow, on a connected device/emulator +patrol test --flavor development --target integration_test/flows/auth_login_test.dart + +# full suite +patrol test --flavor development --target integration_test/e2e_test.dart + +# verify native setup +patrol doctor +``` + +## 12. References + +- Patrol docs: https://patrol.leancode.co/ +- Reference usage (tmail-flutter, `patrol 4.5.0`): + https://github.com/linagora/tmail-flutter +- Internal: [bloc_cubit_patterns.md](bloc_cubit_patterns.md), + [drift_sync_crash_reporting.md](drift_sync_crash_reporting.md) diff --git a/lib/core/error/error_handler.dart b/lib/core/error/error_handler.dart index 72aadb16..a29894c5 100644 --- a/lib/core/error/error_handler.dart +++ b/lib/core/error/error_handler.dart @@ -53,6 +53,9 @@ class ErrorHandler { return ForbiddenException(message, statusCode: statusCode, data: data); case 404: return NotFoundException(message, statusCode: statusCode, data: data); + case 413: + return FileTooLargeException(message, + statusCode: statusCode, data: data); case 422: final errors = _extractValidationErrors(data); return ValidationException(message, errors: errors); diff --git a/lib/core/error/exceptions.dart b/lib/core/error/exceptions.dart index 6e4de46a..a8cb6b57 100644 --- a/lib/core/error/exceptions.dart +++ b/lib/core/error/exceptions.dart @@ -62,6 +62,11 @@ class ValidationException extends ApiException { : super(statusCode: 422); } +class FileTooLargeException extends ApiException { + FileTooLargeException(super.message, {int? statusCode, super.data}) + : super(statusCode: statusCode ?? 413); +} + class DuplicateException extends ApiException { DuplicateException(super.message, {int? statusCode, super.data}) : super(statusCode: statusCode ?? 409); diff --git a/lib/core/error/failures/failures.dart b/lib/core/error/failures/failures.dart index 719bfac4..98bbf5d8 100644 --- a/lib/core/error/failures/failures.dart +++ b/lib/core/error/failures/failures.dart @@ -13,6 +13,7 @@ class Failure with _$Failure { const factory Failure.syncError(String message) = SyncFailure; const factory Failure.validationError(String message, {required List errors}) = ValidationFailure; + const factory Failure.fileTooLarge() = FileTooLargeFailure; const factory Failure.unauthorizedError() = UnauthorizedFailure; const factory Failure.unknownError() = UnknownFailure; const factory Failure.badRequest({ @@ -47,6 +48,8 @@ class Failure with _$Failure { }, none: (NoneFailure _) => LocaleKeys.noError.tr(), notFound: (NotFoundFailure _) => LocaleKeys.notFoundDesc.tr(), + fileTooLarge: (FileTooLargeFailure _) => + LocaleKeys.fileTooLargeDesc.tr(), networkError: (NetworkFailure _) => LocaleKeys.internetConnectionDesc.tr(), serverError: (ServerFailure failure) => LocaleKeys.serverErrorDesc.tr(), diff --git a/lib/core/error/failures/failures.freezed.dart b/lib/core/error/failures/failures.freezed.dart index a77743e5..67f75d2f 100644 --- a/lib/core/error/failures/failures.freezed.dart +++ b/lib/core/error/failures/failures.freezed.dart @@ -24,6 +24,7 @@ mixin _$Failure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -41,6 +42,7 @@ mixin _$Failure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -57,6 +59,7 @@ mixin _$Failure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -74,6 +77,7 @@ mixin _$Failure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -90,6 +94,7 @@ mixin _$Failure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -106,6 +111,7 @@ mixin _$Failure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -212,6 +218,7 @@ class _$ServerFailureImpl extends ServerFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -232,6 +239,7 @@ class _$ServerFailureImpl extends ServerFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -251,6 +259,7 @@ class _$ServerFailureImpl extends ServerFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -274,6 +283,7 @@ class _$ServerFailureImpl extends ServerFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -293,6 +303,7 @@ class _$ServerFailureImpl extends ServerFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -312,6 +323,7 @@ class _$ServerFailureImpl extends ServerFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -388,6 +400,7 @@ class _$NetworkFailureImpl extends NetworkFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -408,6 +421,7 @@ class _$NetworkFailureImpl extends NetworkFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -427,6 +441,7 @@ class _$NetworkFailureImpl extends NetworkFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -450,6 +465,7 @@ class _$NetworkFailureImpl extends NetworkFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -469,6 +485,7 @@ class _$NetworkFailureImpl extends NetworkFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -488,6 +505,7 @@ class _$NetworkFailureImpl extends NetworkFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -583,6 +601,7 @@ class _$CacheFailureImpl extends CacheFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -603,6 +622,7 @@ class _$CacheFailureImpl extends CacheFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -622,6 +642,7 @@ class _$CacheFailureImpl extends CacheFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -645,6 +666,7 @@ class _$CacheFailureImpl extends CacheFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -664,6 +686,7 @@ class _$CacheFailureImpl extends CacheFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -683,6 +706,7 @@ class _$CacheFailureImpl extends CacheFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -786,6 +810,7 @@ class _$SyncFailureImpl extends SyncFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -806,6 +831,7 @@ class _$SyncFailureImpl extends SyncFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -825,6 +851,7 @@ class _$SyncFailureImpl extends SyncFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -848,6 +875,7 @@ class _$SyncFailureImpl extends SyncFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -867,6 +895,7 @@ class _$SyncFailureImpl extends SyncFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -886,6 +915,7 @@ class _$SyncFailureImpl extends SyncFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1007,6 +1037,7 @@ class _$ValidationFailureImpl extends ValidationFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1027,6 +1058,7 @@ class _$ValidationFailureImpl extends ValidationFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1046,6 +1078,7 @@ class _$ValidationFailureImpl extends ValidationFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1069,6 +1102,7 @@ class _$ValidationFailureImpl extends ValidationFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1088,6 +1122,7 @@ class _$ValidationFailureImpl extends ValidationFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -1107,6 +1142,7 @@ class _$ValidationFailureImpl extends ValidationFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1138,6 +1174,181 @@ abstract class ValidationFailure extends Failure { throw _privateConstructorUsedError; } +/// @nodoc +abstract class _$$FileTooLargeFailureImplCopyWith<$Res> { + factory _$$FileTooLargeFailureImplCopyWith(_$FileTooLargeFailureImpl value, + $Res Function(_$FileTooLargeFailureImpl) then) = + __$$FileTooLargeFailureImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$FileTooLargeFailureImplCopyWithImpl<$Res> + extends _$FailureCopyWithImpl<$Res, _$FileTooLargeFailureImpl> + implements _$$FileTooLargeFailureImplCopyWith<$Res> { + __$$FileTooLargeFailureImplCopyWithImpl(_$FileTooLargeFailureImpl _value, + $Res Function(_$FileTooLargeFailureImpl) _then) + : super(_value, _then); + + /// Create a copy of Failure + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$FileTooLargeFailureImpl extends FileTooLargeFailure { + const _$FileTooLargeFailureImpl() : super._(); + + @override + String toString() { + return 'Failure.fileTooLarge()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$FileTooLargeFailureImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String message) serverError, + required TResult Function() networkError, + required TResult Function(String message) cacheError, + required TResult Function(String message) syncError, + required TResult Function(String message, List errors) + validationError, + required TResult Function() fileTooLarge, + required TResult Function() unauthorizedError, + required TResult Function() unknownError, + required TResult Function(List? errors, String? error) + badRequest, + required TResult Function() none, + required TResult Function() notFound, + required TResult Function(String message) duplicate, + required TResult Function() cancel, + }) { + return fileTooLarge(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String message)? serverError, + TResult? Function()? networkError, + TResult? Function(String message)? cacheError, + TResult? Function(String message)? syncError, + TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, + TResult? Function()? unauthorizedError, + TResult? Function()? unknownError, + TResult? Function(List? errors, String? error)? badRequest, + TResult? Function()? none, + TResult? Function()? notFound, + TResult? Function(String message)? duplicate, + TResult? Function()? cancel, + }) { + return fileTooLarge?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String message)? serverError, + TResult Function()? networkError, + TResult Function(String message)? cacheError, + TResult Function(String message)? syncError, + TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, + TResult Function()? unauthorizedError, + TResult Function()? unknownError, + TResult Function(List? errors, String? error)? badRequest, + TResult Function()? none, + TResult Function()? notFound, + TResult Function(String message)? duplicate, + TResult Function()? cancel, + required TResult orElse(), + }) { + if (fileTooLarge != null) { + return fileTooLarge(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(ServerFailure value) serverError, + required TResult Function(NetworkFailure value) networkError, + required TResult Function(CacheFailure value) cacheError, + required TResult Function(SyncFailure value) syncError, + required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, + required TResult Function(UnauthorizedFailure value) unauthorizedError, + required TResult Function(UnknownFailure value) unknownError, + required TResult Function(_BadRequest value) badRequest, + required TResult Function(NoneFailure value) none, + required TResult Function(NotFoundFailure value) notFound, + required TResult Function(DuplicateFailure value) duplicate, + required TResult Function(CancelFailure value) cancel, + }) { + return fileTooLarge(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(ServerFailure value)? serverError, + TResult? Function(NetworkFailure value)? networkError, + TResult? Function(CacheFailure value)? cacheError, + TResult? Function(SyncFailure value)? syncError, + TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, + TResult? Function(UnauthorizedFailure value)? unauthorizedError, + TResult? Function(UnknownFailure value)? unknownError, + TResult? Function(_BadRequest value)? badRequest, + TResult? Function(NoneFailure value)? none, + TResult? Function(NotFoundFailure value)? notFound, + TResult? Function(DuplicateFailure value)? duplicate, + TResult? Function(CancelFailure value)? cancel, + }) { + return fileTooLarge?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(ServerFailure value)? serverError, + TResult Function(NetworkFailure value)? networkError, + TResult Function(CacheFailure value)? cacheError, + TResult Function(SyncFailure value)? syncError, + TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, + TResult Function(UnauthorizedFailure value)? unauthorizedError, + TResult Function(UnknownFailure value)? unknownError, + TResult Function(_BadRequest value)? badRequest, + TResult Function(NoneFailure value)? none, + TResult Function(NotFoundFailure value)? notFound, + TResult Function(DuplicateFailure value)? duplicate, + TResult Function(CancelFailure value)? cancel, + required TResult orElse(), + }) { + if (fileTooLarge != null) { + return fileTooLarge(this); + } + return orElse(); + } +} + +abstract class FileTooLargeFailure extends Failure { + const factory FileTooLargeFailure() = _$FileTooLargeFailureImpl; + const FileTooLargeFailure._() : super._(); +} + /// @nodoc abstract class _$$UnauthorizedFailureImplCopyWith<$Res> { factory _$$UnauthorizedFailureImplCopyWith(_$UnauthorizedFailureImpl value, @@ -1186,6 +1397,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1206,6 +1418,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1225,6 +1438,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1248,6 +1462,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1267,6 +1482,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -1286,6 +1502,7 @@ class _$UnauthorizedFailureImpl extends UnauthorizedFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1354,6 +1571,7 @@ class _$UnknownFailureImpl extends UnknownFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1374,6 +1592,7 @@ class _$UnknownFailureImpl extends UnknownFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1393,6 +1612,7 @@ class _$UnknownFailureImpl extends UnknownFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1416,6 +1636,7 @@ class _$UnknownFailureImpl extends UnknownFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1435,6 +1656,7 @@ class _$UnknownFailureImpl extends UnknownFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -1454,6 +1676,7 @@ class _$UnknownFailureImpl extends UnknownFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1568,6 +1791,7 @@ class _$BadRequestImpl extends _BadRequest { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1588,6 +1812,7 @@ class _$BadRequestImpl extends _BadRequest { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1607,6 +1832,7 @@ class _$BadRequestImpl extends _BadRequest { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1630,6 +1856,7 @@ class _$BadRequestImpl extends _BadRequest { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1649,6 +1876,7 @@ class _$BadRequestImpl extends _BadRequest { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -1668,6 +1896,7 @@ class _$BadRequestImpl extends _BadRequest { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1746,6 +1975,7 @@ class _$NoneFailureImpl extends NoneFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1766,6 +1996,7 @@ class _$NoneFailureImpl extends NoneFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1785,6 +2016,7 @@ class _$NoneFailureImpl extends NoneFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1808,6 +2040,7 @@ class _$NoneFailureImpl extends NoneFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1827,6 +2060,7 @@ class _$NoneFailureImpl extends NoneFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -1846,6 +2080,7 @@ class _$NoneFailureImpl extends NoneFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -1914,6 +2149,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -1934,6 +2170,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -1953,6 +2190,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -1976,6 +2214,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -1995,6 +2234,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -2014,6 +2254,7 @@ class _$NotFoundFailureImpl extends NotFoundFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -2110,6 +2351,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -2130,6 +2372,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -2149,6 +2392,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -2172,6 +2416,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -2191,6 +2436,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -2210,6 +2456,7 @@ class _$DuplicateFailureImpl extends DuplicateFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, @@ -2286,6 +2533,7 @@ class _$CancelFailureImpl extends CancelFailure { required TResult Function(String message) syncError, required TResult Function(String message, List errors) validationError, + required TResult Function() fileTooLarge, required TResult Function() unauthorizedError, required TResult Function() unknownError, required TResult Function(List? errors, String? error) @@ -2306,6 +2554,7 @@ class _$CancelFailureImpl extends CancelFailure { TResult? Function(String message)? cacheError, TResult? Function(String message)? syncError, TResult? Function(String message, List errors)? validationError, + TResult? Function()? fileTooLarge, TResult? Function()? unauthorizedError, TResult? Function()? unknownError, TResult? Function(List? errors, String? error)? badRequest, @@ -2325,6 +2574,7 @@ class _$CancelFailureImpl extends CancelFailure { TResult Function(String message)? cacheError, TResult Function(String message)? syncError, TResult Function(String message, List errors)? validationError, + TResult Function()? fileTooLarge, TResult Function()? unauthorizedError, TResult Function()? unknownError, TResult Function(List? errors, String? error)? badRequest, @@ -2348,6 +2598,7 @@ class _$CancelFailureImpl extends CancelFailure { required TResult Function(CacheFailure value) cacheError, required TResult Function(SyncFailure value) syncError, required TResult Function(ValidationFailure value) validationError, + required TResult Function(FileTooLargeFailure value) fileTooLarge, required TResult Function(UnauthorizedFailure value) unauthorizedError, required TResult Function(UnknownFailure value) unknownError, required TResult Function(_BadRequest value) badRequest, @@ -2367,6 +2618,7 @@ class _$CancelFailureImpl extends CancelFailure { TResult? Function(CacheFailure value)? cacheError, TResult? Function(SyncFailure value)? syncError, TResult? Function(ValidationFailure value)? validationError, + TResult? Function(FileTooLargeFailure value)? fileTooLarge, TResult? Function(UnauthorizedFailure value)? unauthorizedError, TResult? Function(UnknownFailure value)? unknownError, TResult? Function(_BadRequest value)? badRequest, @@ -2386,6 +2638,7 @@ class _$CancelFailureImpl extends CancelFailure { TResult Function(CacheFailure value)? cacheError, TResult Function(SyncFailure value)? syncError, TResult Function(ValidationFailure value)? validationError, + TResult Function(FileTooLargeFailure value)? fileTooLarge, TResult Function(UnauthorizedFailure value)? unauthorizedError, TResult Function(UnknownFailure value)? unknownError, TResult Function(_BadRequest value)? badRequest, diff --git a/lib/core/error/repository_error_handler.dart b/lib/core/error/repository_error_handler.dart index 9f3732ce..1f7a5ace 100644 --- a/lib/core/error/repository_error_handler.dart +++ b/lib/core/error/repository_error_handler.dart @@ -18,6 +18,8 @@ class RepositoryErrorHandler { return left(const UnauthorizedFailure()); } on ValidationException catch (e) { return left(ValidationFailure(e.message, errors: e.errors)); + } on FileTooLargeException { + return left(const FileTooLargeFailure()); } on BadRequestException catch (e) { return left(ServerFailure(e.message)); } on ServerException catch (e) { @@ -44,6 +46,7 @@ class RepositoryErrorHandler { syncError: (SyncFailure f) => ServerException(f.message), validationError: (ValidationFailure f) => ValidationException(f.message, errors: f.errors), + fileTooLarge: (_) => FileTooLargeException('File too large'), unauthorizedError: (_) => UnauthorizedException('Unauthorized'), unknownError: (_) => UnknownException('Unknown error occurred'), badRequest: (f) { diff --git a/lib/core/module/http_module.dart b/lib/core/module/http_module.dart index b74e924c..3787ed8f 100644 --- a/lib/core/module/http_module.dart +++ b/lib/core/module/http_module.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; +import 'package:trakli/core/network/interceptors/locale_interceptor.dart'; import 'package:trakli/core/network/interceptors/logger_interceptor.dart'; import 'package:trakli/core/network/interceptors/remove_null_exceptions.dart'; import 'package:trakli/core/network/interceptors/token_interceptor.dart'; @@ -29,6 +30,7 @@ abstract class InjectHttpClientModule { dio.interceptors.addAll([ RemoveNullValuesInterceptor(), TokenInterceptor(getIt(), getIt()), + LocaleInterceptor(), LoggerInterceptor() ]); diff --git a/lib/core/network/interceptors/locale_interceptor.dart b/lib/core/network/interceptors/locale_interceptor.dart new file mode 100644 index 00000000..87b1f02e --- /dev/null +++ b/lib/core/network/interceptors/locale_interceptor.dart @@ -0,0 +1,17 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:trakli/presentation/utils/globals.dart'; + +class LocaleInterceptor extends Interceptor { + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + final context = navigatorKey.currentContext; + if (context != null) { + options.headers['Accept-Language'] = context.locale.languageCode; + } + return handler.next(options); + } +} diff --git a/lib/data/datasources/ai/ai_remote_datasource.dart b/lib/data/datasources/ai/ai_remote_datasource.dart index 72d772f6..36f99e6a 100644 --- a/lib/data/datasources/ai/ai_remote_datasource.dart +++ b/lib/data/datasources/ai/ai_remote_datasource.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/error_handler.dart'; import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; import 'package:trakli/data/datasources/ai/dto/message_pair_dto.dart'; import 'package:trakli/data/datasources/core/api_response.dart'; @@ -11,13 +14,30 @@ abstract class AiRemoteDataSource { required String message, String? formatHint, String? title, + bool deferProcessing, }); Future addMessage({ required int sessionId, required String message, String? formatHint, + bool deferProcessing, + }); + Future uploadFiles({ + required int sessionId, + required int messageId, + required List files, + String? documentType, }); Future deleteSession(int id); + Future confirmAction({ + required int sessionId, + required int actionId, + Map? overrides, + }); + Future rejectAction({ + required int sessionId, + required int actionId, + }); Future checkHealth(); } @@ -53,11 +73,13 @@ class AiRemoteDataSourceImpl implements AiRemoteDataSource { required String message, String? formatHint, String? title, + bool deferProcessing = false, }) async { final body = { 'message': message, if (formatHint != null) 'format_hint': formatHint, if (title != null) 'title': title, + if (deferProcessing) 'defer_processing': true, }; final response = await dio.post('ai/chats', data: body); final apiResponse = ApiResponse.fromJson(response.data); @@ -69,21 +91,71 @@ class AiRemoteDataSourceImpl implements AiRemoteDataSource { required int sessionId, required String message, String? formatHint, + bool deferProcessing = false, }) async { final body = { 'message': message, if (formatHint != null) 'format_hint': formatHint, + if (deferProcessing) 'defer_processing': true, }; final response = await dio.post('ai/chats/$sessionId/messages', data: body); final apiResponse = ApiResponse.fromJson(response.data); return MessagePairDto.fromJson(apiResponse.data as Map); } + @override + Future uploadFiles({ + required int sessionId, + required int messageId, + required List files, + String? documentType, + }) { + return ErrorHandler.handleApiCall(() async { + final form = FormData(); + for (final file in files) { + final name = file.uri.pathSegments.isNotEmpty + ? file.uri.pathSegments.last + : 'file'; + form.files.add( + MapEntry('files[]', + await MultipartFile.fromFile(file.path, filename: name)), + ); + } + if (documentType != null && documentType.isNotEmpty) { + form.fields.add(MapEntry('document_type', documentType)); + } + await dio.post( + 'ai/chats/$sessionId/messages/$messageId/files', + data: form, + ); + }); + } + @override Future deleteSession(int id) async { await dio.delete('ai/chats/$id'); } + @override + Future confirmAction({ + required int sessionId, + required int actionId, + Map? overrides, + }) async { + final body = (overrides != null && overrides.isNotEmpty) + ? {'overrides': overrides} + : {}; + await dio.post('ai/chats/$sessionId/actions/$actionId/confirm', data: body); + } + + @override + Future rejectAction({ + required int sessionId, + required int actionId, + }) async { + await dio.post('ai/chats/$sessionId/actions/$actionId/reject'); + } + @override Future checkHealth() async { try { @@ -101,4 +173,3 @@ class AiRemoteDataSourceImpl implements AiRemoteDataSource { } } } - diff --git a/lib/data/datasources/ai/dto/chat_blocks_dto.dart b/lib/data/datasources/ai/dto/chat_blocks_dto.dart new file mode 100644 index 00000000..085b4c8b --- /dev/null +++ b/lib/data/datasources/ai/dto/chat_blocks_dto.dart @@ -0,0 +1,503 @@ +// Typed models for the AI agent's result "blocks" and legacy SmartQL result. +// Row-level payloads stay `List>` (query-defined columns). + +// ---- small parse helpers ---------------------------------------------------- + +String _s(dynamic v) => v?.toString() ?? ''; +String? _sn(dynamic v) => v?.toString(); +num? _numOrNull(dynamic v) => v is num ? v : num.tryParse(v?.toString() ?? ''); +int? _intOrNull(dynamic v) => + v is num ? v.toInt() : int.tryParse(v?.toString() ?? ''); + +List _stringList(dynamic v) => + v is List ? v.map((e) => e.toString()).toList() : const []; + +List _intList(dynamic v) => + v is List ? v.map(_intOrNull).whereType().toList() : const []; + +List> _rowList(dynamic v) => v is List + ? v.whereType().map((e) => Map.from(e)).toList() + : const []; + +List _objList(dynamic v, T Function(Map) fromJson) => + v is List + ? v + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList() + : const []; + +// ---- sealed block union ----------------------------------------------------- + +sealed class ChatBlock { + const ChatBlock(); + + /// Dispatches on the block's `type`; unknown types degrade to [UnknownBlock]. + factory ChatBlock.fromJson(Map json) { + final type = json['type']?.toString() ?? ''; + return switch (type) { + 'markdown' => MarkdownBlock.fromJson(json), + 'table' => TableBlock.fromJson(json), + 'kpi' => KpiBlock.fromJson(json), + 'chart' => ChartBlock.fromJson(json), + 'comparison' => ComparisonBlock.fromJson(json), + 'list' => ListBlock.fromJson(json), + 'callout' => CalloutBlock.fromJson(json), + 'timeline' => TimelineBlock.fromJson(json), + 'progress' => ProgressBlock.fromJson(json), + 'question' => QuestionBlock.fromJson(json), + 'quick_actions' => QuickActionsBlock.fromJson(json), + 'import_review' => ImportReviewBlock.fromJson(json), + 'canvas' => CanvasBlock.fromJson(json), + 'proposed_action' => ProposedActionBlock.fromJson(json), + _ => UnknownBlock.fromJson(json, type), + }; + } +} + +final class MarkdownBlock extends ChatBlock { + final String text; + const MarkdownBlock(this.text); + factory MarkdownBlock.fromJson(Map j) => + MarkdownBlock(_s(j['text'])); +} + +final class TableBlock extends ChatBlock { + final String? title; + final List columns; + final List> rows; + const TableBlock({this.title, required this.columns, required this.rows}); + factory TableBlock.fromJson(Map j) => TableBlock( + title: _sn(j['title']), + columns: _stringList(j['columns']), + rows: _rowList(j['rows']), + ); +} + +final class KpiItem { + final String label; + final dynamic value; + final String? currency; + final String? unit; + final num? deltaPercent; + const KpiItem({ + required this.label, + this.value, + this.currency, + this.unit, + this.deltaPercent, + }); + factory KpiItem.fromJson(Map j) => KpiItem( + label: _s(j['label']), + value: j['value'], + currency: _sn(j['currency']), + unit: _sn(j['unit']), + deltaPercent: _numOrNull(j['delta_percent']), + ); +} + +final class KpiBlock extends ChatBlock { + final String? title; + final List items; + const KpiBlock({this.title, required this.items}); + factory KpiBlock.fromJson(Map j) => KpiBlock( + title: _sn(j['title']), + items: _objList(j['items'], KpiItem.fromJson), + ); +} + +final class ChartBlock extends ChatBlock { + final String? title; + final String chartHint; + final List> data; + const ChartBlock({this.title, required this.chartHint, required this.data}); + factory ChartBlock.fromJson(Map j) => ChartBlock( + title: _sn(j['title']), + chartHint: _s(j['chart_hint']), + data: _rowList(j['data']), + ); +} + +final class ComparisonBlock extends ChatBlock { + final String? title; + final List> series; + const ComparisonBlock({this.title, required this.series}); + factory ComparisonBlock.fromJson(Map j) => ComparisonBlock( + title: _sn(j['title']), + series: _rowList(j['series']), + ); +} + +final class ListBlock extends ChatBlock { + final String? title; + final List> items; + const ListBlock({this.title, required this.items}); + factory ListBlock.fromJson(Map j) => ListBlock( + title: _sn(j['title']), + items: _rowList(j['items']), + ); +} + +final class CalloutBlock extends ChatBlock { + final String? title; + final String text; + final String variant; + const CalloutBlock({this.title, required this.text, required this.variant}); + factory CalloutBlock.fromJson(Map j) { + final v = _s(j['variant']); + return CalloutBlock( + title: _sn(j['title']), + text: _s(j['text']), + variant: v.isEmpty ? 'info' : v, + ); + } +} + +final class TimelineItem { + final String? title; + final String? date; + final String? description; + final dynamic amount; + final String? currency; + const TimelineItem({ + this.title, + this.date, + this.description, + this.amount, + this.currency, + }); + factory TimelineItem.fromJson(Map j) => TimelineItem( + title: _sn(j['title']), + date: _sn(j['date']), + description: _sn(j['description']), + amount: j['amount'], + currency: _sn(j['currency']), + ); +} + +final class TimelineBlock extends ChatBlock { + final String? title; + final List items; + const TimelineBlock({this.title, required this.items}); + factory TimelineBlock.fromJson(Map j) => TimelineBlock( + title: _sn(j['title']), + items: _objList(j['items'], TimelineItem.fromJson), + ); +} + +final class ProgressItem { + final String? label; + final num? current; + final num? target; + final String? currency; + const ProgressItem({this.label, this.current, this.target, this.currency}); + factory ProgressItem.fromJson(Map j) => ProgressItem( + label: _sn(j['label']), + current: _numOrNull(j['current']), + target: _numOrNull(j['target']), + currency: _sn(j['currency']), + ); +} + +final class ProgressBlock extends ChatBlock { + final String? title; + final List items; + const ProgressBlock({this.title, required this.items}); + factory ProgressBlock.fromJson(Map j) => ProgressBlock( + title: _sn(j['title']), + items: _objList(j['items'], ProgressItem.fromJson), + ); +} + +final class QuestionOption { + final String label; + final String message; + const QuestionOption({required this.label, required this.message}); + factory QuestionOption.fromJson(Map j) { + final label = _s(j['label']); + final msg = _s(j['message']); + return QuestionOption(label: label, message: msg.isEmpty ? label : msg); + } +} + +final class QuestionBlock extends ChatBlock { + final String? prompt; + final List options; + const QuestionBlock({this.prompt, required this.options}); + factory QuestionBlock.fromJson(Map j) => QuestionBlock( + prompt: _sn(j['prompt']), + options: _objList(j['options'], QuestionOption.fromJson), + ); +} + +final class QuickAction { + final String label; + const QuickAction(this.label); + factory QuickAction.fromJson(Map j) => + QuickAction(_s(j['label'])); +} + +final class QuickActionsBlock extends ChatBlock { + final List actions; + const QuickActionsBlock({required this.actions}); + factory QuickActionsBlock.fromJson(Map j) => + QuickActionsBlock(actions: _objList(j['actions'], QuickAction.fromJson)); +} + +final class ImportReviewBlock extends ChatBlock { + final int? importSessionId; + final String status; + final String? fileName; + const ImportReviewBlock( + {this.importSessionId, required this.status, this.fileName}); + factory ImportReviewBlock.fromJson(Map j) => + ImportReviewBlock( + importSessionId: _intOrNull(j['import_session_id']), + status: _s(j['status']), + fileName: _sn(j['file_name']), + ); +} + +final class CanvasBlock extends ChatBlock { + final String? title; + final List blocks; + const CanvasBlock({this.title, required this.blocks}); + factory CanvasBlock.fromJson(Map j) => CanvasBlock( + title: _sn(j['title']), + blocks: (j['blocks'] is List) + ? (j['blocks'] as List) + .whereType() + .map((e) => ChatBlock.fromJson(Map.from(e))) + .toList() + : const [], + ); +} + +/// A single editable field inside a [ProposedActionBlock], parsed into a typed +/// variant so the renderer can switch over it exhaustively. [value] is the +/// server-proposed value (used as the edit default and read-only display); it +/// stays dynamic because it serializes back to JSON as an override. +sealed class ActionField { + final String key; + final String label; + final dynamic value; + final String? display; + const ActionField({ + required this.key, + required this.label, + required this.value, + this.display, + }); + + /// Maps the server `type` (with key/action heuristics for currency and + /// wallet-type) to a typed variant; anything unknown becomes a [TextActionField]. + factory ActionField.fromJson(Map j, {String actionType = ''}) { + final key = _s(j['key']); + final label = _s(j['label']); + final value = j['value']; + final display = _sn(j['display']); + + switch (_s(j['type'])) { + case 'currency': + return CurrencyActionField( + key: key, label: label, value: value, display: display); + case 'wallet_type': + return WalletTypeActionField( + key: key, label: label, value: value, display: display); + case 'enum': + return EnumActionField( + key: key, + label: label, + value: value, + display: display, + options: _stringList(j['options'])); + case 'wallet': + return WalletRefActionField( + key: key, label: label, value: value, display: display); + case 'party': + return PartyRefActionField( + key: key, label: label, value: value, display: display); + case 'number': + return NumberActionField( + key: key, label: label, value: value, display: display); + case 'datetime': + return DateTimeActionField( + key: key, label: label, value: value, display: display); + case 'categories': + return CategoriesActionField( + key: key, + label: label, + value: value, + display: display, + initialIds: _intList(value)); + } + + final k = key.toLowerCase(); + if (k.contains('currency')) { + return CurrencyActionField( + key: key, label: label, value: value, display: display); + } + if (k == 'type' && actionType.toLowerCase().contains('wallet')) { + return WalletTypeActionField( + key: key, label: label, value: value, display: display); + } + return TextActionField( + key: key, label: label, value: value, display: display); + } +} + +/// Currency code, edited via the currency picker. +final class CurrencyActionField extends ActionField { + const CurrencyActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// Wallet kind (bank / cash / credit_card / mobile), edited via a dropdown. +final class WalletTypeActionField extends ActionField { + const WalletTypeActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// A fixed set of string [options], edited via a dropdown. +final class EnumActionField extends ActionField { + final List options; + const EnumActionField( + {required super.key, + required super.label, + required super.value, + super.display, + this.options = const []}); +} + +/// A reference to one of the user's wallets (by id). +final class WalletRefActionField extends ActionField { + const WalletRefActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// A reference to one of the user's parties (by id, nullable). +final class PartyRefActionField extends ActionField { + const PartyRefActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// A numeric value. +final class NumberActionField extends ActionField { + const NumberActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// A date-time value (ISO string), edited via date/time pickers. +final class DateTimeActionField extends ActionField { + const DateTimeActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// A set of category ids, edited via selectable chips. +final class CategoriesActionField extends ActionField { + final List initialIds; + const CategoriesActionField( + {required super.key, + required super.label, + required super.value, + super.display, + this.initialIds = const []}); +} + +/// Free-text — also the fallback for any unrecognized server type. +final class TextActionField extends ActionField { + const TextActionField( + {required super.key, + required super.label, + required super.value, + super.display}); +} + +/// An action (e.g. `transaction.create`) the user must confirm/edit/dismiss. +final class ProposedActionBlock extends ChatBlock { + final int id; + final String actionType; + final String summary; + + /// low | medium | high + final String risk; + + /// pending | executed | rejected + final String status; + final List fields; + + const ProposedActionBlock({ + required this.id, + required this.actionType, + required this.summary, + required this.risk, + required this.status, + required this.fields, + }); + + bool get isPending => status != 'executed' && status != 'rejected'; + bool get isExecuted => status == 'executed'; + + factory ProposedActionBlock.fromJson(Map j) { + final actionType = _s(j['action_type']); + return ProposedActionBlock( + id: _intOrNull(j['id']) ?? 0, + actionType: actionType, + summary: _s(j['summary']), + risk: _s(j['risk']).isEmpty ? 'low' : _s(j['risk']), + status: _s(j['status']).isEmpty ? 'pending' : _s(j['status']), + fields: (j['fields'] is List) + ? (j['fields'] as List) + .whereType() + .map((e) => ActionField.fromJson(Map.from(e), + actionType: actionType)) + .toList() + : const [], + ); + } +} + +/// Any block type the client doesn't know yet — degrades to its text/summary. +final class UnknownBlock extends ChatBlock { + final String type; + final String text; + const UnknownBlock({required this.type, required this.text}); + factory UnknownBlock.fromJson(Map j, String type) { + final t = _s(j['text']); + return UnknownBlock(type: type, text: t.isEmpty ? _s(j['summary']) : t); + } +} + +// ---- legacy SmartQL result -------------------------------------------------- + +/// A legacy SmartQL result: a `format_type` plus tabular `rows` (used when a +/// response has no agent blocks). +final class LegacyResult { + /// scalar | pair | record | list | pair_list | table | raw + final String formatType; + final List> rows; + const LegacyResult({required this.formatType, required this.rows}); + + factory LegacyResult.fromJson(Map j) => LegacyResult( + formatType: _s(j['format_type']), + rows: _rowList(j['rows']), + ); +} diff --git a/lib/data/datasources/ai/dto/chat_message_dto.dart b/lib/data/datasources/ai/dto/chat_message_dto.dart index 69acb39d..d702c316 100644 --- a/lib/data/datasources/ai/dto/chat_message_dto.dart +++ b/lib/data/datasources/ai/dto/chat_message_dto.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; import 'package:trakli/data/datasources/core/util.dart'; part 'chat_message_dto.freezed.dart'; @@ -35,6 +36,30 @@ extension ChatMessageDtoX on ChatMessageDto { bool get isFailed => status == 'failed'; bool get isCompleted => status == 'completed'; + bool get isServiceUnavailable => result?['source'] == 'unavailable'; + + List get blocks { + final b = result?['blocks']; + if (b is! List) return const []; + return b + .whereType() + .map((e) => ChatBlock.fromJson(Map.from(e))) + .toList(); + } + + LegacyResult? get legacyResult { + final r = result; + if (r == null) return null; + final b = r['blocks']; + if (b is List && b.isNotEmpty) return null; + final rows = r['rows']; + final ft = r['format_type']; + if (rows is List && rows.isNotEmpty && ft is String && ft.isNotEmpty) { + return LegacyResult.fromJson(r); + } + return null; + } + String? get humanResponse { final r = result; if (r == null) return null; diff --git a/lib/data/repositories/ai_repository_impl.dart b/lib/data/repositories/ai_repository_impl.dart index 844fcdfb..f4211da0 100644 --- a/lib/data/repositories/ai_repository_impl.dart +++ b/lib/data/repositories/ai_repository_impl.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:fpdart/fpdart.dart'; import 'package:injectable/injectable.dart'; import 'package:trakli/core/error/failures/failures.dart'; @@ -30,12 +32,14 @@ class AiRepositoryImpl implements AiRepository { required String message, String? formatHint, String? title, + bool deferProcessing = false, }) { return RepositoryErrorHandler.handleApiCall( () => remote.createSession( message: message, formatHint: formatHint, title: title, + deferProcessing: deferProcessing, ), ); } @@ -45,16 +49,36 @@ class AiRepositoryImpl implements AiRepository { required int sessionId, required String message, String? formatHint, + bool deferProcessing = false, }) { return RepositoryErrorHandler.handleApiCall( () => remote.addMessage( sessionId: sessionId, message: message, formatHint: formatHint, + deferProcessing: deferProcessing, ), ); } + @override + Future> uploadFiles({ + required int sessionId, + required int messageId, + required List files, + String? documentType, + }) { + return RepositoryErrorHandler.handleApiCall(() async { + await remote.uploadFiles( + sessionId: sessionId, + messageId: messageId, + files: files, + documentType: documentType, + ); + return unit; + }); + } + @override Future> deleteSession(int id) { return RepositoryErrorHandler.handleApiCall(() async { @@ -63,6 +87,33 @@ class AiRepositoryImpl implements AiRepository { }); } + @override + Future> confirmAction({ + required int sessionId, + required int actionId, + Map? overrides, + }) { + return RepositoryErrorHandler.handleApiCall(() async { + await remote.confirmAction( + sessionId: sessionId, + actionId: actionId, + overrides: overrides, + ); + return unit; + }); + } + + @override + Future> rejectAction({ + required int sessionId, + required int actionId, + }) { + return RepositoryErrorHandler.handleApiCall(() async { + await remote.rejectAction(sessionId: sessionId, actionId: actionId); + return unit; + }); + } + @override Future> checkHealth() { return RepositoryErrorHandler.handleApiCall(() => remote.checkHealth()); diff --git a/lib/di/injection.config.dart b/lib/di/injection.config.dart index 03f8f8ab..2e45a910 100644 --- a/lib/di/injection.config.dart +++ b/lib/di/injection.config.dart @@ -123,11 +123,14 @@ import '../domain/repositories/transaction_repository.dart' as _i118; import '../domain/repositories/transfer_repository.dart' as _i55; import '../domain/repositories/wallet_repository.dart' as _i368; import '../domain/usecases/ai/check_health_usecase.dart' as _i605; +import '../domain/usecases/ai/confirm_action_usecase.dart' as _i82; import '../domain/usecases/ai/create_session_usecase.dart' as _i995; import '../domain/usecases/ai/delete_session_usecase.dart' as _i236; import '../domain/usecases/ai/get_session_usecase.dart' as _i752; import '../domain/usecases/ai/list_sessions_usecase.dart' as _i505; +import '../domain/usecases/ai/reject_action_usecase.dart' as _i391; import '../domain/usecases/ai/send_message_usecase.dart' as _i308; +import '../domain/usecases/ai/upload_files_usecase.dart' as _i633; import '../domain/usecases/app_update/check_app_update_usecase.dart' as _i150; import '../domain/usecases/auth/delete_account_usecase.dart' as _i684; import '../domain/usecases/auth/get_loggedin_user.dart' as _i880; @@ -490,15 +493,15 @@ _i174.GetIt $initGetIt( () => _i444.StreamAuthStatus(gh<_i800.AuthRepository>())); gh.factory<_i828.IsOnboardingCompleted>( () => _i828.IsOnboardingCompleted(gh<_i800.AuthRepository>())); - gh.lazySingleton<_i918.BudgetSyncHandler>(() => _i918.BudgetSyncHandler( - gh<_i704.AppDatabase>(), - gh<_i760.BudgetRemoteDataSource>(), - )); gh.lazySingleton<_i161.BudgetPeriodStateSyncHandler>( () => _i161.BudgetPeriodStateSyncHandler( gh<_i704.AppDatabase>(), gh<_i760.BudgetRemoteDataSource>(), )); + gh.lazySingleton<_i918.BudgetSyncHandler>(() => _i918.BudgetSyncHandler( + gh<_i704.AppDatabase>(), + gh<_i760.BudgetRemoteDataSource>(), + )); gh.factory<_i929.GetImportSessionsUseCase>( () => _i929.GetImportSessionsUseCase(gh<_i32.ImportRepository>())); gh.factory<_i36.ConfirmSessionUseCase>( @@ -517,6 +520,10 @@ _i174.GetIt $initGetIt( () => _i12.GetPartiesUseCase(gh<_i661.PartyRepository>())); gh.factory<_i714.ListenToPartiesUseCase>( () => _i714.ListenToPartiesUseCase(gh<_i661.PartyRepository>())); + gh.factory<_i82.ConfirmActionUseCase>( + () => _i82.ConfirmActionUseCase(gh<_i542.AiRepository>())); + gh.factory<_i633.UploadFilesUseCase>( + () => _i633.UploadFilesUseCase(gh<_i542.AiRepository>())); gh.factory<_i995.CreateSessionUseCase>( () => _i995.CreateSessionUseCase(gh<_i542.AiRepository>())); gh.factory<_i236.DeleteSessionUseCase>( @@ -527,6 +534,8 @@ _i174.GetIt $initGetIt( () => _i308.SendMessageUseCase(gh<_i542.AiRepository>())); gh.factory<_i605.CheckHealthUseCase>( () => _i605.CheckHealthUseCase(gh<_i542.AiRepository>())); + gh.factory<_i391.RejectActionUseCase>( + () => _i391.RejectActionUseCase(gh<_i542.AiRepository>())); gh.factory<_i505.ListSessionsUseCase>( () => _i505.ListSessionsUseCase(gh<_i542.AiRepository>())); gh.factory<_i559.AppUpdateCubit>( @@ -556,13 +565,6 @@ _i174.GetIt $initGetIt( db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); - gh.factory<_i415.AiChatCubit>(() => _i415.AiChatCubit( - listSessionsUseCase: gh<_i505.ListSessionsUseCase>(), - getSessionUseCase: gh<_i752.GetSessionUseCase>(), - createSessionUseCase: gh<_i995.CreateSessionUseCase>(), - sendMessageUseCase: gh<_i308.SendMessageUseCase>(), - deleteSessionUseCase: gh<_i236.DeleteSessionUseCase>(), - )); gh.factory<_i422.GetNotificationsUseCase>( () => _i422.GetNotificationsUseCase(gh<_i965.NotificationRepository>())); gh.factory<_i837.MarkNotificationAsReadUseCase>(() => @@ -611,6 +613,16 @@ _i174.GetIt $initGetIt( _i225.EnsureDefaultWalletExistsUseCase(gh<_i368.WalletRepository>())); gh.factory<_i82.ListenToWalletsUseCase>( () => _i82.ListenToWalletsUseCase(gh<_i368.WalletRepository>())); + gh.lazySingleton<_i415.AiChatCubit>(() => _i415.AiChatCubit( + listSessionsUseCase: gh<_i505.ListSessionsUseCase>(), + getSessionUseCase: gh<_i752.GetSessionUseCase>(), + createSessionUseCase: gh<_i995.CreateSessionUseCase>(), + sendMessageUseCase: gh<_i308.SendMessageUseCase>(), + deleteSessionUseCase: gh<_i236.DeleteSessionUseCase>(), + confirmActionUseCase: gh<_i82.ConfirmActionUseCase>(), + rejectActionUseCase: gh<_i391.RejectActionUseCase>(), + uploadFilesUseCase: gh<_i633.UploadFilesUseCase>(), + )); gh.factory<_i524.LoginWithEmailUseCase>( () => _i524.LoginWithEmailUseCase(gh<_i800.AuthRepository>())); gh.factory<_i705.RegisterUseCase>( @@ -804,6 +816,8 @@ _i174.GetIt $initGetIt( listenToTransactionsUseCase: gh<_i1022.ListenToTransactionsUseCase>(), getWalletsUseCase: gh<_i713.GetWalletsUseCase>(), )); + gh.factory<_i920.ListenToPeriodStatesUseCase>( + () => _i920.ListenToPeriodStatesUseCase(gh<_i340.BudgetRepository>())); gh.factory<_i102.GetBudgetUseCase>( () => _i102.GetBudgetUseCase(gh<_i340.BudgetRepository>())); gh.factory<_i377.ListenToBudgetsUseCase>( @@ -820,8 +834,6 @@ _i174.GetIt $initGetIt( () => _i748.DeleteBudgetUseCase(gh<_i340.BudgetRepository>())); gh.factory<_i884.GetAllBudgetsUseCase>( () => _i884.GetAllBudgetsUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i920.ListenToPeriodStatesUseCase>( - () => _i920.ListenToPeriodStatesUseCase(gh<_i340.BudgetRepository>())); gh.lazySingleton<_i646.SynchAppDatabase>(() => _i646.SynchAppDatabase( appDatabase: gh<_i704.AppDatabase>(), typeHandlers: diff --git a/lib/domain/repositories/ai_repository.dart b/lib/domain/repositories/ai_repository.dart index 0dcbe030..9db20501 100644 --- a/lib/domain/repositories/ai_repository.dart +++ b/lib/domain/repositories/ai_repository.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:fpdart/fpdart.dart'; import 'package:trakli/core/error/failures/failures.dart'; import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; @@ -12,15 +14,35 @@ abstract class AiRepository { required String message, String? formatHint, String? title, + bool deferProcessing, }); Future> sendMessage({ required int sessionId, required String message, String? formatHint, + bool deferProcessing, + }); + + Future> uploadFiles({ + required int sessionId, + required int messageId, + required List files, + String? documentType, }); Future> deleteSession(int id); + Future> confirmAction({ + required int sessionId, + required int actionId, + Map? overrides, + }); + + Future> rejectAction({ + required int sessionId, + required int actionId, + }); + Future> checkHealth(); } diff --git a/lib/domain/usecases/ai/confirm_action_usecase.dart b/lib/domain/usecases/ai/confirm_action_usecase.dart new file mode 100644 index 00000000..73b48899 --- /dev/null +++ b/lib/domain/usecases/ai/confirm_action_usecase.dart @@ -0,0 +1,33 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/domain/repositories/ai_repository.dart'; + +class ConfirmActionParams { + final int sessionId; + final int actionId; + final Map? overrides; + + const ConfirmActionParams({ + required this.sessionId, + required this.actionId, + this.overrides, + }); +} + +@injectable +class ConfirmActionUseCase implements UseCase { + final AiRepository _repository; + + ConfirmActionUseCase(this._repository); + + @override + Future> call(ConfirmActionParams params) async { + return await _repository.confirmAction( + sessionId: params.sessionId, + actionId: params.actionId, + overrides: params.overrides, + ); + } +} diff --git a/lib/domain/usecases/ai/create_session_usecase.dart b/lib/domain/usecases/ai/create_session_usecase.dart index 3544c76f..cf7a36cf 100644 --- a/lib/domain/usecases/ai/create_session_usecase.dart +++ b/lib/domain/usecases/ai/create_session_usecase.dart @@ -9,11 +9,13 @@ class CreateSessionParams { final String message; final String? formatHint; final String? title; + final bool deferProcessing; const CreateSessionParams({ required this.message, this.formatHint, this.title, + this.deferProcessing = false, }); } @@ -31,6 +33,7 @@ class CreateSessionUseCase message: params.message, formatHint: params.formatHint, title: params.title, + deferProcessing: params.deferProcessing, ); } } diff --git a/lib/domain/usecases/ai/reject_action_usecase.dart b/lib/domain/usecases/ai/reject_action_usecase.dart new file mode 100644 index 00000000..3febbb3d --- /dev/null +++ b/lib/domain/usecases/ai/reject_action_usecase.dart @@ -0,0 +1,30 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/domain/repositories/ai_repository.dart'; + +class RejectActionParams { + final int sessionId; + final int actionId; + + const RejectActionParams({ + required this.sessionId, + required this.actionId, + }); +} + +@injectable +class RejectActionUseCase implements UseCase { + final AiRepository _repository; + + RejectActionUseCase(this._repository); + + @override + Future> call(RejectActionParams params) async { + return await _repository.rejectAction( + sessionId: params.sessionId, + actionId: params.actionId, + ); + } +} diff --git a/lib/domain/usecases/ai/send_message_usecase.dart b/lib/domain/usecases/ai/send_message_usecase.dart index 1419c65a..01b423fd 100644 --- a/lib/domain/usecases/ai/send_message_usecase.dart +++ b/lib/domain/usecases/ai/send_message_usecase.dart @@ -9,11 +9,13 @@ class SendMessageParams { final int sessionId; final String message; final String? formatHint; + final bool deferProcessing; const SendMessageParams({ required this.sessionId, required this.message, this.formatHint, + this.deferProcessing = false, }); } @@ -29,6 +31,7 @@ class SendMessageUseCase implements UseCase { sessionId: params.sessionId, message: params.message, formatHint: params.formatHint, + deferProcessing: params.deferProcessing, ); } } diff --git a/lib/domain/usecases/ai/upload_files_usecase.dart b/lib/domain/usecases/ai/upload_files_usecase.dart new file mode 100644 index 00000000..60f6a40f --- /dev/null +++ b/lib/domain/usecases/ai/upload_files_usecase.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:fpdart/fpdart.dart'; +import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/domain/repositories/ai_repository.dart'; + +class UploadFilesParams { + final int sessionId; + final int messageId; + final List files; + final String? documentType; + + const UploadFilesParams({ + required this.sessionId, + required this.messageId, + required this.files, + this.documentType, + }); +} + +@injectable +class UploadFilesUseCase implements UseCase { + final AiRepository _repository; + + UploadFilesUseCase(this._repository); + + @override + Future> call(UploadFilesParams params) async { + return await _repository.uploadFiles( + sessionId: params.sessionId, + messageId: params.messageId, + files: params.files, + documentType: params.documentType, + ); + } +} diff --git a/lib/gen/translations/codegen_loader.g.dart b/lib/gen/translations/codegen_loader.g.dart index 35e0bdd2..fa3a922f 100644 --- a/lib/gen/translations/codegen_loader.g.dart +++ b/lib/gen/translations/codegen_loader.g.dart @@ -138,6 +138,7 @@ abstract class LocaleKeys { static const noError = 'noError'; static const internetConnectionDesc = 'internetConnectionDesc'; static const notFoundDesc = 'notFoundDesc'; + static const fileTooLargeDesc = 'fileTooLargeDesc'; static const serverErrorDesc = 'serverErrorDesc'; static const cacheErrorDesc = 'cacheErrorDesc'; static const syncErrorDesc = 'syncErrorDesc'; @@ -736,5 +737,34 @@ abstract class LocaleKeys { static const supportEmailCopied = 'supportEmailCopied'; static const defaultUserName = 'defaultUserName'; static const appTitle = 'appTitle'; + static const aiGreetingMorning = 'aiGreetingMorning'; + static const aiGreetingAfternoon = 'aiGreetingAfternoon'; + static const aiGreetingEvening = 'aiGreetingEvening'; + static const aiStatNetFlow = 'aiStatNetFlow'; + static const aiStatSaved = 'aiStatSaved'; + static const aiLandingNoData = 'aiLandingNoData'; + static const aiSuggestSpendMonth = 'aiSuggestSpendMonth'; + static const aiSuggestTopCategory = 'aiSuggestTopCategory'; + static const aiSuggestIncomeVsExpense = 'aiSuggestIncomeVsExpense'; + static const aiSuggestBalance = 'aiSuggestBalance'; + static const aiContinueSession = 'aiContinueSession'; + static const aiStartNewSession = 'aiStartNewSession'; + static const aiStatTransactions = 'aiStatTransactions'; + static const aiStatAvgExpense = 'aiStatAvgExpense'; + static const aiSpotlightSaved = 'aiSpotlightSaved'; + static const aiSpotlightSpent = 'aiSpotlightSpent'; + static const aiSpotlightNetPositive = 'aiSpotlightNetPositive'; + static const aiSpotlightNetNegative = 'aiSpotlightNetNegative'; + static const aiSpotlightTransactions = 'aiSpotlightTransactions'; + static const aiSpotlightAvgExpense = 'aiSpotlightAvgExpense'; + static const aiServiceUnavailable = 'aiServiceUnavailable'; + static const none = 'none'; + static const dismissed = 'dismissed'; + static const aiReviewImport = 'aiReviewImport'; + static const aiOpenCanvas = 'aiOpenCanvas'; + static const aiImportStatusAnalyzing = 'aiImportStatusAnalyzing'; + static const aiImportStatusReady = 'aiImportStatusReady'; + static const aiImportStatusConfirmed = 'aiImportStatusConfirmed'; + static const aiImportStatusFailed = 'aiImportStatusFailed'; } diff --git a/lib/presentation/ai_chat/ai_chat_screen.dart b/lib/presentation/ai_chat/ai_chat_screen.dart index b97d6417..69e8a0f4 100644 --- a/lib/presentation/ai_chat/ai_chat_screen.dart +++ b/lib/presentation/ai_chat/ai_chat_screen.dart @@ -1,28 +1,47 @@ +import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:heroicons/heroicons.dart'; -import 'package:trakli/data/datasources/ai/dto/chat_message_dto.dart'; -import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; import 'package:trakli/di/injection.dart'; -import 'package:trakli/domain/repositories/ai_repository.dart'; import 'package:trakli/gen/assets.gen.dart'; import 'package:trakli/gen/translations/codegen_loader.g.dart'; import 'package:trakli/presentation/ai_chat/cubit/ai_chat_cubit.dart'; -import 'package:trakli/presentation/auth/cubits/auth/auth_cubit.dart'; +import 'package:trakli/presentation/ai_chat/widgets/chat_composer.dart'; +import 'package:trakli/presentation/ai_chat/widgets/chat_history_sheet.dart'; +import 'package:trakli/presentation/ai_chat/widgets/chat_landing.dart'; +import 'package:trakli/presentation/ai_chat/widgets/message_bubble.dart'; import 'package:trakli/presentation/utils/design_tokens.dart'; import 'package:trakli/presentation/utils/icon_background_decor.dart'; import 'package:trakli/presentation/utils/page_app_bar.dart'; -class AiChatScreen extends StatelessWidget { +class AiChatScreen extends StatefulWidget { const AiChatScreen({super.key}); + @override + State createState() => _AiChatScreenState(); +} + +class _AiChatScreenState extends State { + final AiChatCubit _cubit = getIt(); + + @override + void initState() { + super.initState(); + _cubit.openInitial(); + } + + @override + void dispose() { + _cubit.pausePolling(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => getIt()..loadMostRecent(), + return BlocProvider.value( + value: _cubit, child: const _AiChatView(), ); } @@ -54,7 +73,7 @@ class _AiChatViewState extends State<_AiChatView> { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => _HistorySheet(cubit: cubit), + builder: (_) => HistorySheet(cubit: cubit), ); } @@ -67,6 +86,19 @@ class _AiChatViewState extends State<_AiChatView> { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); } + void _sendMessage(String text, List files, String? documentType) { + final trimmed = text.trim(); + if (trimmed.isEmpty && files.isEmpty) return; + context.read().sendMessage( + trimmed, + files: files.isEmpty ? null : files, + documentType: documentType, + ); + _composer.clear(); + _focus.unfocus(); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + } + void _scrollToBottom() { if (!_scroll.hasClients) return; _scroll.animateTo( @@ -118,30 +150,48 @@ class _AiChatViewState extends State<_AiChatView> { .addPostFrameCallback((_) => _scrollToBottom()); }, builder: (context, state) { - if (state.isInitializing) { + final hasContent = + state.messages.isNotEmpty || state.recentSession != null; + if (state.isInitializing && !hasContent) { return const Center(child: CircularProgressIndicator()); } return Column( children: [ + if (state.isInitializing) + LinearProgressIndicator( + minHeight: 2.h, + backgroundColor: Colors.transparent, + color: tones.brand.deep, + ), if (state.failure != null) _ErrorBanner(state: state), Expanded( child: state.isEmpty - ? _EmptyState(onPick: _send) + ? ChatLanding( + onPick: _send, + recentSession: state.recentSession, + onContinue: () { + final id = state.recentSession?.id; + if (id != null) { + context.read().openSession(id); + } + }, + ) : ListView.builder( controller: _scroll, padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h), itemCount: state.messages.length, itemBuilder: (context, i) { - return _MessageRow(message: state.messages[i]); + final message = state.messages[i]; + return MessageRow(message: message); }, ), ), - _Composer( + Composer( controller: _composer, focus: _focus, isSending: state.isBusy, - onSend: _send, + onSend: _sendMessage, ), ], ); @@ -188,831 +238,3 @@ class _ErrorBanner extends StatelessWidget { ); } } - -class _MessageRow extends StatelessWidget { - final ChatMessageDto message; - const _MessageRow({required this.message}); - - @override - Widget build(BuildContext context) { - final isUser = message.isUser; - return Padding( - padding: EdgeInsets.only(bottom: 12.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [ - if (!isUser) ...[ - const _AssistantAvatar(), - SizedBox(width: 8.w), - ], - Flexible(child: _Bubble(message: message)), - if (isUser) ...[ - SizedBox(width: 8.w), - const _UserAvatar(), - ], - ], - ), - ); - } -} - -class _Bubble extends StatelessWidget { - final ChatMessageDto message; - const _Bubble({required this.message}); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final isUser = message.isUser; - final inFlight = message.isInFlight; - final failed = message.isFailed; - - final radius = BorderRadius.only( - topLeft: Radius.circular(isUser ? 18.r : 6.r), - topRight: Radius.circular(isUser ? 6.r : 18.r), - bottomLeft: Radius.circular(18.r), - bottomRight: Radius.circular(18.r), - ); - - if (isUser) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [tones.brand.accent, tones.brand.deep], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: radius, - ), - child: SelectableText( - message.displayText, - style: TextStyle( - color: Colors.white, - fontSize: 14.sp, - height: 1.4, - ), - ), - ); - } - - final bg = failed ? tones.expense.background : tones.bgCard; - final fg = failed ? tones.expense.deep : tones.textPrimary; - - final body = inFlight - ? Padding( - padding: EdgeInsets.symmetric(vertical: 4.h), - child: _ThinkingDots(color: tones.brand.deep.withAlpha(180)), - ) - : SelectableText( - failed - ? (message.error ?? LocaleKeys.aiChatError.tr()) - : message.displayText, - style: TextStyle( - color: fg, - fontSize: 14.sp, - height: 1.45, - ), - ); - - return Container( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), - decoration: BoxDecoration( - color: bg, - borderRadius: radius, - border: Border.all( - color: failed - ? tones.expense.accent.withAlpha(60) - : tones.borderLight, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(8), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: body, - ); - } -} - -class _ThinkingDots extends StatefulWidget { - final Color color; - const _ThinkingDots({required this.color}); - - @override - State<_ThinkingDots> createState() => _ThinkingDotsState(); -} - -class _ThinkingDotsState extends State<_ThinkingDots> - with SingleTickerProviderStateMixin { - late final AnimationController _ctl; - - @override - void initState() { - super.initState(); - _ctl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - )..repeat(); - } - - @override - void dispose() { - _ctl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _ctl, - builder: (context, _) { - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(3, (i) { - final t = (_ctl.value + i * 0.2) % 1.0; - final scale = 0.6 + 0.4 * (1 - (t - 0.5).abs() * 2).clamp(0.0, 1.0); - return Padding( - padding: EdgeInsets.symmetric(horizontal: 2.w), - child: Transform.scale( - scale: scale, - child: Container( - width: 7.r, - height: 7.r, - decoration: BoxDecoration( - color: widget.color, - shape: BoxShape.circle, - ), - ), - ), - ); - }), - ); - }, - ); - } -} - -class _AssistantAvatar extends StatelessWidget { - const _AssistantAvatar(); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - return Container( - width: 32.r, - height: 32.r, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [tones.brandSoft.background, tones.brand.background], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - border: Border.all(color: tones.brand.deep.withAlpha(30), width: 1), - ), - alignment: Alignment.center, - child: HeroIcon( - HeroIcons.sparkles, - style: HeroIconStyle.outline, - color: tones.brand.deep, - size: 16.sp, - ), - ); - } -} - -class _UserAvatar extends StatelessWidget { - const _UserAvatar(); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final user = context.read().state.user; - final initials = _initialsFor( - first: user?.firstName, - last: user?.lastName, - email: user?.email, - ); - - return Container( - width: 32.r, - height: 32.r, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [tones.brand.accent, tones.brand.deep], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - initials, - style: TextStyle( - color: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - ), - ), - ); - } -} - -String _initialsFor({String? first, String? last, String? email}) { - String pick(String? s) => - (s != null && s.trim().isNotEmpty) ? s.trim()[0].toUpperCase() : ''; - final a = pick(first); - final b = pick(last); - if (a.isNotEmpty || b.isNotEmpty) return '$a$b'; - final e = pick(email); - return e.isNotEmpty ? e : '·'; -} - -class _EmptyState extends StatelessWidget { - final void Function(String) onPick; - const _EmptyState({required this.onPick}); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final suggestions = [ - 'How much did I spend this month?', - 'What was my biggest expense category last week?', - 'Show my income vs expenses for the year.', - 'What was my balance on the 15th?', - ]; - - return ListView( - padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 32.h), - children: [ - SizedBox(height: 24.h), - Center( - child: Container( - width: 76.r, - height: 76.r, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [tones.brand.accent, tones.brand.deep], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: tones.brand.deep.withAlpha(60), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - padding: EdgeInsets.all(20.r), - child: SvgPicture.asset( - Assets.images.sparkles, - colorFilter: - const ColorFilter.mode(Colors.white, BlendMode.srcIn), - ), - ), - ), - SizedBox(height: 20.h), - Text( - LocaleKeys.aiChatTitle.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: tones.textPrimary, - fontSize: 20.sp, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 8.h), - Text( - LocaleKeys.aiChatEmptyHint.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: tones.textSecondary, - fontSize: 14.sp, - height: 1.5, - ), - ), - SizedBox(height: 28.h), - ...suggestions.map((q) { - return Padding( - padding: EdgeInsets.only(bottom: 10.h), - child: InkWell( - onTap: () => onPick(q), - borderRadius: BorderRadius.circular(14.r), - child: Container( - padding: - EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), - decoration: BoxDecoration( - color: tones.bgCard, - borderRadius: BorderRadius.circular(14.r), - border: Border.all(color: tones.borderLight), - ), - child: Row( - children: [ - Icon( - Icons.bolt_rounded, - size: 16.sp, - color: tones.brand.deep, - ), - SizedBox(width: 10.w), - Expanded( - child: Text( - q, - style: TextStyle( - color: tones.textPrimary, - fontSize: 13.sp, - fontWeight: FontWeight.w500, - height: 1.4, - ), - ), - ), - Icon( - Icons.arrow_outward_rounded, - size: 14.sp, - color: tones.textMuted, - ), - ], - ), - ), - ), - ); - }), - ], - ); - } -} - -class _Composer extends StatefulWidget { - final TextEditingController controller; - final FocusNode focus; - final bool isSending; - final void Function([String? overrideText]) onSend; - - const _Composer({ - required this.controller, - required this.focus, - required this.isSending, - required this.onSend, - }); - - @override - State<_Composer> createState() => _ComposerState(); -} - -class _ComposerState extends State<_Composer> { - bool _hasText = false; - - @override - void initState() { - super.initState(); - widget.controller.addListener(_onTextChanged); - } - - @override - void dispose() { - widget.controller.removeListener(_onTextChanged); - super.dispose(); - } - - void _onTextChanged() { - final hasText = widget.controller.text.trim().isNotEmpty; - if (hasText != _hasText) setState(() => _hasText = hasText); - } - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final canSend = _hasText && !widget.isSending; - final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; - final fabReserve = keyboardOpen ? 0.0 : 30.r; - - return Padding( - padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 10.h + fabReserve), - child: SafeArea( - top: false, - child: Container( - decoration: BoxDecoration( - color: tones.bgCard, - borderRadius: BorderRadius.circular(16.r), - border: Border.all(color: tones.borderLight), - ), - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 4.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: 120.h), - child: TextField( - controller: widget.controller, - focusNode: widget.focus, - maxLines: null, - minLines: 1, - textInputAction: TextInputAction.newline, - keyboardType: TextInputType.multiline, - style: TextStyle( - color: tones.textPrimary, - fontSize: 14.sp, - height: 1.4, - ), - decoration: InputDecoration( - border: InputBorder.none, - isCollapsed: true, - contentPadding: EdgeInsets.symmetric(vertical: 12.h), - hintText: LocaleKeys.aiChatComposerPlaceholder.tr(), - hintStyle: TextStyle( - color: tones.textMuted, - fontSize: 14.sp, - ), - ), - ), - ), - ), - SizedBox(width: 8.w), - Padding( - padding: EdgeInsets.only(bottom: 4.h), - child: _SendButton( - canSend: canSend, - isSending: widget.isSending, - onTap: () => widget.onSend(), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SendButton extends StatelessWidget { - final bool canSend; - final bool isSending; - final VoidCallback onTap; - const _SendButton({ - required this.canSend, - required this.isSending, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final enabled = canSend && !isSending; - return AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 36.r, - height: 36.r, - decoration: BoxDecoration( - color: enabled ? tones.brand.deep : tones.borderLight, - shape: BoxShape.circle, - ), - child: Material( - color: Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: enabled ? onTap : null, - child: Center( - child: isSending - ? SizedBox( - width: 14.r, - height: 14.r, - child: const CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : Icon( - Icons.arrow_upward_rounded, - color: enabled ? Colors.white : tones.textMuted, - size: 18.sp, - ), - ), - ), - ), - ); - } -} - -class _HistorySheet extends StatefulWidget { - final AiChatCubit cubit; - const _HistorySheet({required this.cubit}); - - @override - State<_HistorySheet> createState() => _HistorySheetState(); -} - -class _HistorySheetState extends State<_HistorySheet> { - late Future> _future; - - @override - void initState() { - super.initState(); - _future = _fetch(); - } - - Future> _fetch() async { - final result = await getIt().listSessions(); - return result.fold((_) => [], (sessions) => sessions); - } - - void _reload() => setState(() => _future = _fetch()); - - Future _confirmDelete(ChatSessionDto session) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) { - final tones = ctx.tones; - return AlertDialog( - backgroundColor: tones.bgCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - content: Text( - LocaleKeys.aiChatDeleteConfirm.tr(), - style: TextStyle( - color: tones.textPrimary, - fontSize: 14.sp, - height: 1.4, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: Text( - LocaleKeys.cancel.tr(), - style: TextStyle(color: tones.textSecondary), - ), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: Text( - LocaleKeys.delete.tr(), - style: TextStyle( - color: tones.expense.deep, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ); - }, - ); - if (confirmed != true || !mounted) return; - await widget.cubit.deleteSession(session.id); - _reload(); - } - - @override - Widget build(BuildContext context) { - final tones = context.tones; - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.4, - maxChildSize: 0.92, - expand: false, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: tones.bgPage, - borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), - ), - child: Column( - children: [ - SizedBox(height: 8.h), - Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: tones.borderMedium, - borderRadius: BorderRadius.circular(2.r), - ), - ), - Padding( - padding: EdgeInsets.fromLTRB(16.w, 12.h, 8.w, 12.h), - child: Row( - children: [ - Expanded( - child: Text( - LocaleKeys.aiChatHistory.tr(), - style: TextStyle( - color: tones.textPrimary, - fontSize: 16.sp, - fontWeight: FontWeight.w700, - ), - ), - ), - IconButton( - icon: Icon(Icons.close_rounded, - color: tones.textSecondary, size: 22.sp), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - Divider(height: 1, color: tones.borderLight), - Expanded( - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator()); - } - final sessions = snapshot.data ?? const []; - if (sessions.isEmpty) { - return _HistoryEmpty(); - } - return BlocBuilder( - bloc: widget.cubit, - buildWhen: (a, b) => a.session?.id != b.session?.id, - builder: (context, state) { - return ListView.separated( - controller: scrollController, - padding: EdgeInsets.symmetric(vertical: 8.h), - itemCount: sessions.length, - separatorBuilder: (_, __) => - Divider(height: 1, color: tones.borderLight), - itemBuilder: (context, i) { - final s = sessions[i]; - final isCurrent = state.session?.id == s.id; - return _SessionRow( - session: s, - isCurrent: isCurrent, - onTap: () { - widget.cubit.openSession(s.id); - Navigator.of(context).pop(); - }, - onDelete: () => _confirmDelete(s), - ); - }, - ); - }, - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} - -class _HistoryEmpty extends StatelessWidget { - @override - Widget build(BuildContext context) { - final tones = context.tones; - return Padding( - padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 40.h), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 56.r, - height: 56.r, - decoration: BoxDecoration( - color: tones.brand.background, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: HeroIcon( - HeroIcons.sparkles, - style: HeroIconStyle.outline, - color: tones.brand.deep, - size: 24, - ), - ), - SizedBox(height: 14.h), - Text( - LocaleKeys.aiChatNoSessions.tr(), - style: TextStyle( - color: tones.textPrimary, - fontSize: 15.sp, - fontWeight: FontWeight.w600, - ), - ), - SizedBox(height: 4.h), - Text( - LocaleKeys.aiChatNoSessionsHint.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: tones.textSecondary, - fontSize: 13.sp, - ), - ), - ], - ), - ); - } -} - -class _SessionRow extends StatelessWidget { - final ChatSessionDto session; - final bool isCurrent; - final VoidCallback onTap; - final VoidCallback onDelete; - - const _SessionRow({ - required this.session, - required this.isCurrent, - required this.onTap, - required this.onDelete, - }); - - @override - Widget build(BuildContext context) { - final tones = context.tones; - final title = (session.title?.trim().isNotEmpty ?? false) - ? session.title!.trim() - : LocaleKeys.aiChatUntitled.tr(); - - return InkWell( - onTap: onTap, - child: Container( - color: isCurrent ? tones.brand.background : Colors.transparent, - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 36.r, - height: 36.r, - decoration: BoxDecoration( - color: isCurrent ? tones.brand.deep : tones.brand.background, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: HeroIcon( - HeroIcons.sparkles, - style: HeroIconStyle.outline, - color: isCurrent ? Colors.white : tones.brand.deep, - size: 18, - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: tones.textPrimary, - fontSize: 14.sp, - fontWeight: - isCurrent ? FontWeight.w700 : FontWeight.w600, - ), - ), - SizedBox(height: 2.h), - Text( - _relativeTime(session.updatedAt), - style: TextStyle( - color: tones.textMuted, - fontSize: 12.sp, - ), - ), - ], - ), - ), - IconButton( - icon: Icon( - Icons.delete_outline_rounded, - color: tones.textMuted, - size: 20.sp, - ), - onPressed: onDelete, - splashRadius: 20.r, - ), - ], - ), - ), - ); - } -} - -String _relativeTime(DateTime when) { - final now = DateTime.now(); - final diff = now.difference(when); - if (diff.inSeconds < 60) return 'now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - if (diff.inDays < 7) return '${diff.inDays}d ago'; - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]; - return '${months[when.month - 1]} ${when.day}'; -} diff --git a/lib/presentation/ai_chat/cubit/ai_chat_cubit.dart b/lib/presentation/ai_chat/cubit/ai_chat_cubit.dart index df9b7af5..9c5cf6f9 100644 --- a/lib/presentation/ai_chat/cubit/ai_chat_cubit.dart +++ b/lib/presentation/ai_chat/cubit/ai_chat_cubit.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -8,28 +9,54 @@ import 'package:trakli/core/usecases/usecase.dart'; import 'package:trakli/core/utils/services/logger.dart'; import 'package:trakli/data/datasources/ai/dto/chat_message_dto.dart'; import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; +import 'package:trakli/domain/usecases/ai/confirm_action_usecase.dart'; import 'package:trakli/domain/usecases/ai/create_session_usecase.dart'; import 'package:trakli/domain/usecases/ai/delete_session_usecase.dart'; import 'package:trakli/domain/usecases/ai/get_session_usecase.dart'; import 'package:trakli/domain/usecases/ai/list_sessions_usecase.dart'; +import 'package:trakli/domain/usecases/ai/reject_action_usecase.dart'; import 'package:trakli/domain/usecases/ai/send_message_usecase.dart'; +import 'package:trakli/domain/usecases/ai/upload_files_usecase.dart'; part 'ai_chat_cubit.freezed.dart'; part 'ai_chat_state.dart'; -@injectable +/// A failed file upload, retained so the retry can re-upload it. +typedef _RetryableUpload = ({ + int sessionId, + int messageId, + List files, + String? docType, + int assistantId, +}); + +@lazySingleton class AiChatCubit extends Cubit { final ListSessionsUseCase _listSessionsUseCase; final GetSessionUseCase _getSessionUseCase; final CreateSessionUseCase _createSessionUseCase; final SendMessageUseCase _sendMessageUseCase; final DeleteSessionUseCase _deleteSessionUseCase; + final ConfirmActionUseCase _confirmActionUseCase; + final RejectActionUseCase _rejectActionUseCase; + final UploadFilesUseCase _uploadFilesUseCase; - static const Duration _pollInterval = Duration(seconds: 3); - static const Duration _stuckThreshold = Duration(seconds: 90); + static const List _pollBackoff = [ + Duration(seconds: 2), + Duration(seconds: 3), + Duration(seconds: 5), + Duration(seconds: 8), + ]; + static const Duration _pollMaxWindow = Duration(minutes: 3); Timer? _pollTimer; DateTime? _pollStartedAt; + int _pollAttempt = 0; + + _RetryableUpload? _retryableUpload; + + /// Static so the landing shows once per app launch: survives cubit recreation, resets on cold start. + static bool _landingShown = false; AiChatCubit({ required ListSessionsUseCase listSessionsUseCase, @@ -37,17 +64,34 @@ class AiChatCubit extends Cubit { required CreateSessionUseCase createSessionUseCase, required SendMessageUseCase sendMessageUseCase, required DeleteSessionUseCase deleteSessionUseCase, + required ConfirmActionUseCase confirmActionUseCase, + required RejectActionUseCase rejectActionUseCase, + required UploadFilesUseCase uploadFilesUseCase, }) : _listSessionsUseCase = listSessionsUseCase, _getSessionUseCase = getSessionUseCase, _createSessionUseCase = createSessionUseCase, _sendMessageUseCase = sendMessageUseCase, _deleteSessionUseCase = deleteSessionUseCase, + _confirmActionUseCase = confirmActionUseCase, + _rejectActionUseCase = rejectActionUseCase, + _uploadFilesUseCase = uploadFilesUseCase, super(AiChatState.initial()); - Future loadMostRecent() async { + /// Shows the landing on the first open per launch; later opens resume the most recent chat. + Future openInitial() async { if (state.isInitializing) return; emit(state.copyWith(isInitializing: true, failure: null)); + if (state.hasSession) { + await _loadSession(state.session!.id); + emit(state.copyWith(isInitializing: false)); + _maybeStartPolling(); + return; + } + + final alreadyLanded = _landingShown; + _landingShown = true; + final result = await _listSessionsUseCase(NoParams()); await result.fold( (failure) async { @@ -58,8 +102,16 @@ class AiChatCubit extends Cubit { emit(state.copyWith(isInitializing: false)); return; } - await _loadSession(sessions.first.id); - emit(state.copyWith(isInitializing: false)); + if (alreadyLanded) { + await _loadSession(sessions.first.id); + emit(state.copyWith(isInitializing: false)); + _maybeStartPolling(); + } else { + emit(state.copyWith( + isInitializing: false, + recentSession: sessions.first, + )); + } }, ); } @@ -102,57 +154,263 @@ class AiChatCubit extends Cubit { emit(AiChatState.initial()); } - Future sendMessage(String text) async { + /// Re-sends the user message that preceded a failed assistant reply. + Future retry(int assistantMessageId) async { + final pending = _retryableUpload; + if (pending != null && pending.assistantId == assistantMessageId) { + await _retryUpload(pending); + return; + } + final msgs = state.messages; + final idx = msgs.indexWhere((m) => m.id == assistantMessageId); + final start = idx == -1 ? msgs.length - 1 : idx - 1; + String? text; + for (var i = start; i >= 0; i--) { + final m = msgs[i]; + if (m.isUser && (m.content?.trim().isNotEmpty ?? false)) { + text = m.content; + break; + } + } + if (text == null) return; + await sendMessage(text); + } + + /// Confirms a proposed action (optionally with [overrides]) and refreshes the session. + Future confirmAction({ + required int actionId, + Map? overrides, + }) async { + final session = state.session; + if (session == null) return false; + final result = await _confirmActionUseCase( + ConfirmActionParams( + sessionId: session.id, + actionId: actionId, + overrides: overrides, + ), + ); + return result.fold( + (failure) async { + logger.e('AI confirmAction failed (action=$actionId): $failure'); + emit(state.copyWith(failure: failure)); + return false; + }, + (_) async { + await _loadSession(session.id); + _maybeStartPolling(); + return true; + }, + ); + } + + Future rejectAction({required int actionId}) async { + final session = state.session; + if (session == null) return false; + final result = await _rejectActionUseCase( + RejectActionParams(sessionId: session.id, actionId: actionId), + ); + return result.fold( + (failure) async { + logger.e('AI rejectAction failed (action=$actionId): $failure'); + emit(state.copyWith(failure: failure)); + return false; + }, + (_) async { + await _loadSession(session.id); + return true; + }, + ); + } + + Future sendMessage( + String text, { + List? files, + String? documentType, + }) async { + final hasFiles = files != null && files.isNotEmpty; final trimmed = text.trim(); - if (trimmed.isEmpty || state.isSending) return; + if ((trimmed.isEmpty && !hasFiles) || state.isSending) return; + + final messageText = trimmed.isNotEmpty + ? trimmed + : 'Attached ${files!.map(_fileName).join(', ')}'; emit(state.copyWith(isSending: true, failure: null)); + final int sessionId; + int? userMessageId; + if (!state.hasSession) { final result = await _createSessionUseCase( - CreateSessionParams(message: trimmed), + CreateSessionParams(message: messageText, deferProcessing: hasFiles), ); - result.fold( - (failure) { - logger.e('AI createSession failed: $failure'); - emit(state.copyWith(isSending: false, failure: failure)); - }, - (session) { - emit(state.copyWith( - isSending: false, - session: session, - messages: session.messages, - )); - _maybeStartPolling(); - }, + final created = result.fold((failure) { + logger.e('AI createSession failed: $failure'); + emit(state.copyWith(isSending: false, failure: failure)); + return null; + }, (session) => session); + if (created == null) return; + emit(state.copyWith(session: created, messages: created.messages)); + sessionId = created.id; + for (final m in created.messages) { + if (m.isUser) { + userMessageId = m.id; + break; + } + } + } else { + sessionId = state.session!.id; + final result = await _sendMessageUseCase( + SendMessageParams( + sessionId: sessionId, + message: messageText, + deferProcessing: hasFiles, + ), ); - return; - } - - final sessionId = state.session!.id; - final result = await _sendMessageUseCase( - SendMessageParams(sessionId: sessionId, message: trimmed), - ); - result.fold( - (failure) { + final pair = result.fold((failure) { logger.e('AI sendMessage failed (session=$sessionId): $failure'); emit(state.copyWith(isSending: false, failure: failure)); - }, - (pair) { - final updated = [...state.messages, pair.user, pair.assistant]; - emit(state.copyWith(isSending: false, messages: updated)); - _maybeStartPolling(); - }, + return null; + }, (pair) => pair); + if (pair == null) return; + emit(state.copyWith( + messages: [...state.messages, pair.user, pair.assistant], + )); + userMessageId = pair.user.id; + } + + if (hasFiles) { + final messageId = userMessageId; + final assistantId = _inFlightAssistantId(); + final upload = messageId == null + ? null + : await _uploadFilesUseCase( + UploadFilesParams( + sessionId: sessionId, + messageId: messageId, + files: files, + documentType: documentType, + ), + ); + + final failure = upload?.fold((f) => f, (_) => null); + if (upload == null || failure != null) { + if (failure != null) { + logger.e('AI uploadFiles failed (session=$sessionId): $failure'); + } + _failUpload( + assistantId: assistantId, + sessionId: sessionId, + messageId: messageId, + files: files, + docType: documentType, + failure: failure, + ); + return; + } + _retryableUpload = null; + await _loadSession(sessionId); + } + + emit(state.copyWith(isSending: false)); + _maybeStartPolling(); + } + + String _fileName(File f) { + final last = + f.uri.pathSegments.isNotEmpty ? f.uri.pathSegments.last : 'file'; + return last.replaceFirst(RegExp(r'^\d+_'), ''); + } + + int? _inFlightAssistantId() { + for (var i = state.messages.length - 1; i >= 0; i--) { + final m = state.messages[i]; + if (m.isAssistant && m.isInFlight) return m.id; + } + return null; + } + + void _setMessageStatus(int messageId, String status) { + emit(state.copyWith(messages: [ + for (final m in state.messages) + m.id == messageId ? m.copyWith(status: status) : m, + ])); + } + + /// Marks the held turn failed, stashes the upload for retry, and re-enables the composer. + void _failUpload({ + required int? assistantId, + required int sessionId, + required int? messageId, + required List files, + required String? docType, + Failure? failure, + }) { + _stopPolling(); + final isTooLarge = failure is FileTooLargeFailure; + _retryableUpload = (!isTooLarge && messageId != null && assistantId != null) + ? ( + sessionId: sessionId, + messageId: messageId, + files: files, + docType: docType, + assistantId: assistantId, + ) + : null; + if (assistantId != null) _setMessageStatus(assistantId, 'failed'); + emit( + state.copyWith(isSending: false, failure: isTooLarge ? failure : null)); + } + + /// Re-uploads the stashed file, releasing the pending assistant message. + Future _retryUpload(_RetryableUpload up) async { + if (state.isSending) return; + _setMessageStatus(up.assistantId, 'pending'); + emit(state.copyWith(isSending: true, failure: null)); + final upload = await _uploadFilesUseCase( + UploadFilesParams( + sessionId: up.sessionId, + messageId: up.messageId, + files: up.files, + documentType: up.docType, + ), ); + final ok = upload.fold((failure) { + logger.e('AI retry uploadFiles failed (session=${up.sessionId}): ' + '$failure'); + return false; + }, (_) => true); + if (!ok) { + _setMessageStatus(up.assistantId, 'failed'); + emit(state.copyWith(isSending: false)); + return; + } + _retryableUpload = null; + await _loadSession(up.sessionId); + emit(state.copyWith(isSending: false)); + _maybeStartPolling(); } void _maybeStartPolling() { + if (_pollTimer != null) return; final latest = state.latestAssistantMessage; if (latest == null || !latest.isInFlight) return; - _stopPolling(); _pollStartedAt = DateTime.now(); + _pollAttempt = 0; emit(state.copyWith(isPolling: true)); - _pollTimer = Timer.periodic(_pollInterval, (_) => _tick()); + _scheduleNextPoll(); + } + + /// Pauses polling without dropping the chat (the singleton isn't disposed); resumes via [openInitial]. + void pausePolling() => _stopPolling(); + + void _scheduleNextPoll() { + final i = _pollAttempt < _pollBackoff.length + ? _pollAttempt + : _pollBackoff.length - 1; + _pollTimer = Timer(_pollBackoff[i], _tick); + _pollAttempt++; } Future _tick() async { @@ -164,7 +422,7 @@ class AiChatCubit extends Cubit { final started = _pollStartedAt; if (started != null && - DateTime.now().difference(started) > _stuckThreshold) { + DateTime.now().difference(started) > _pollMaxWindow) { _stopPolling(); return; } @@ -172,20 +430,22 @@ class AiChatCubit extends Cubit { final result = await _getSessionUseCase(GetSessionParams(id: session.id)); result.fold( (_) {}, - (fresh) { - emit(state.copyWith(session: fresh, messages: fresh.messages)); - final latest = state.latestAssistantMessage; - if (latest == null || !latest.isInFlight) { - _stopPolling(); - } - }, + (fresh) => emit(state.copyWith(session: fresh, messages: fresh.messages)), ); + + final latest = state.latestAssistantMessage; + if (latest == null || !latest.isInFlight) { + _stopPolling(); + } else { + _scheduleNextPoll(); + } } void _stopPolling() { _pollTimer?.cancel(); _pollTimer = null; _pollStartedAt = null; + _pollAttempt = 0; if (state.isPolling) { emit(state.copyWith(isPolling: false)); } diff --git a/lib/presentation/ai_chat/cubit/ai_chat_cubit.freezed.dart b/lib/presentation/ai_chat/cubit/ai_chat_cubit.freezed.dart index 03a34dc5..cdf356de 100644 --- a/lib/presentation/ai_chat/cubit/ai_chat_cubit.freezed.dart +++ b/lib/presentation/ai_chat/cubit/ai_chat_cubit.freezed.dart @@ -20,6 +20,7 @@ mixin _$AiChatState { bool get isSending => throw _privateConstructorUsedError; bool get isPolling => throw _privateConstructorUsedError; ChatSessionDto? get session => throw _privateConstructorUsedError; + ChatSessionDto? get recentSession => throw _privateConstructorUsedError; List get messages => throw _privateConstructorUsedError; Failure? get failure => throw _privateConstructorUsedError; @@ -41,10 +42,12 @@ abstract class $AiChatStateCopyWith<$Res> { bool isSending, bool isPolling, ChatSessionDto? session, + ChatSessionDto? recentSession, List messages, Failure? failure}); $ChatSessionDtoCopyWith<$Res>? get session; + $ChatSessionDtoCopyWith<$Res>? get recentSession; $FailureCopyWith<$Res>? get failure; } @@ -67,6 +70,7 @@ class _$AiChatStateCopyWithImpl<$Res, $Val extends AiChatState> Object? isSending = null, Object? isPolling = null, Object? session = freezed, + Object? recentSession = freezed, Object? messages = null, Object? failure = freezed, }) { @@ -87,6 +91,10 @@ class _$AiChatStateCopyWithImpl<$Res, $Val extends AiChatState> ? _value.session : session // ignore: cast_nullable_to_non_nullable as ChatSessionDto?, + recentSession: freezed == recentSession + ? _value.recentSession + : recentSession // ignore: cast_nullable_to_non_nullable + as ChatSessionDto?, messages: null == messages ? _value.messages : messages // ignore: cast_nullable_to_non_nullable @@ -112,6 +120,20 @@ class _$AiChatStateCopyWithImpl<$Res, $Val extends AiChatState> }); } + /// Create a copy of AiChatState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ChatSessionDtoCopyWith<$Res>? get recentSession { + if (_value.recentSession == null) { + return null; + } + + return $ChatSessionDtoCopyWith<$Res>(_value.recentSession!, (value) { + return _then(_value.copyWith(recentSession: value) as $Val); + }); + } + /// Create a copy of AiChatState /// with the given fields replaced by the non-null parameter values. @override @@ -140,12 +162,15 @@ abstract class _$$AiChatStateImplCopyWith<$Res> bool isSending, bool isPolling, ChatSessionDto? session, + ChatSessionDto? recentSession, List messages, Failure? failure}); @override $ChatSessionDtoCopyWith<$Res>? get session; @override + $ChatSessionDtoCopyWith<$Res>? get recentSession; + @override $FailureCopyWith<$Res>? get failure; } @@ -166,6 +191,7 @@ class __$$AiChatStateImplCopyWithImpl<$Res> Object? isSending = null, Object? isPolling = null, Object? session = freezed, + Object? recentSession = freezed, Object? messages = null, Object? failure = freezed, }) { @@ -186,6 +212,10 @@ class __$$AiChatStateImplCopyWithImpl<$Res> ? _value.session : session // ignore: cast_nullable_to_non_nullable as ChatSessionDto?, + recentSession: freezed == recentSession + ? _value.recentSession + : recentSession // ignore: cast_nullable_to_non_nullable + as ChatSessionDto?, messages: null == messages ? _value._messages : messages // ignore: cast_nullable_to_non_nullable @@ -206,6 +236,7 @@ class _$AiChatStateImpl extends _AiChatState { this.isSending = false, this.isPolling = false, this.session, + this.recentSession, final List messages = const [], this.failure}) : _messages = messages, @@ -222,6 +253,8 @@ class _$AiChatStateImpl extends _AiChatState { final bool isPolling; @override final ChatSessionDto? session; + @override + final ChatSessionDto? recentSession; final List _messages; @override @JsonKey() @@ -236,7 +269,7 @@ class _$AiChatStateImpl extends _AiChatState { @override String toString() { - return 'AiChatState(isInitializing: $isInitializing, isSending: $isSending, isPolling: $isPolling, session: $session, messages: $messages, failure: $failure)'; + return 'AiChatState(isInitializing: $isInitializing, isSending: $isSending, isPolling: $isPolling, session: $session, recentSession: $recentSession, messages: $messages, failure: $failure)'; } @override @@ -251,6 +284,8 @@ class _$AiChatStateImpl extends _AiChatState { (identical(other.isPolling, isPolling) || other.isPolling == isPolling) && (identical(other.session, session) || other.session == session) && + (identical(other.recentSession, recentSession) || + other.recentSession == recentSession) && const DeepCollectionEquality().equals(other._messages, _messages) && (identical(other.failure, failure) || other.failure == failure)); } @@ -262,6 +297,7 @@ class _$AiChatStateImpl extends _AiChatState { isSending, isPolling, session, + recentSession, const DeepCollectionEquality().hash(_messages), failure); @@ -280,6 +316,7 @@ abstract class _AiChatState extends AiChatState { final bool isSending, final bool isPolling, final ChatSessionDto? session, + final ChatSessionDto? recentSession, final List messages, final Failure? failure}) = _$AiChatStateImpl; const _AiChatState._() : super._(); @@ -293,6 +330,8 @@ abstract class _AiChatState extends AiChatState { @override ChatSessionDto? get session; @override + ChatSessionDto? get recentSession; + @override List get messages; @override Failure? get failure; diff --git a/lib/presentation/ai_chat/cubit/ai_chat_state.dart b/lib/presentation/ai_chat/cubit/ai_chat_state.dart index a9c92d4a..9ba8a271 100644 --- a/lib/presentation/ai_chat/cubit/ai_chat_state.dart +++ b/lib/presentation/ai_chat/cubit/ai_chat_state.dart @@ -7,6 +7,7 @@ class AiChatState with _$AiChatState { @Default(false) bool isSending, @Default(false) bool isPolling, ChatSessionDto? session, + ChatSessionDto? recentSession, @Default([]) List messages, Failure? failure, }) = _AiChatState; diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_action_pill.dart b/lib/presentation/ai_chat/widgets/blocks/chat_action_pill.dart new file mode 100644 index 00000000..e78df23a --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_action_pill.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/presentation/ai_chat/cubit/ai_chat_cubit.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// A tappable pill that sends [message] to the chat when tapped. Shared by the +/// question and quick-actions blocks. +class ChatActionPill extends StatelessWidget { + final String label; + final String message; + const ChatActionPill({super.key, required this.label, required this.message}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Material( + color: tones.bgCard, + borderRadius: BorderRadius.circular(20.r), + child: InkWell( + borderRadius: BorderRadius.circular(20.r), + onTap: () => context.read().sendMessage(message), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: tones.borderLight), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: tones.brand.deep, + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_block_card.dart b/lib/presentation/ai_chat/widgets/blocks/chat_block_card.dart new file mode 100644 index 00000000..ea7f24d0 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_block_card.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Shared chrome + tiny formatting helpers for the AI chat block renderers. + +/// The standard card wrapper used by most blocks: an optional bold title above +/// the block's [child]. +class ChatBlockCard extends StatelessWidget { + final String? title; + final Widget child; + const ChatBlockCard({super.key, this.title, required this.child}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 8.h), + padding: EdgeInsets.all(14.r), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null && title!.isNotEmpty) ...[ + Text( + title!, + style: TextStyle( + color: tones.textPrimary, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 10.h), + ], + child, + ], + ), + ); + } +} + +/// Formats a numeric-ish value: integers without decimals, others to 2 dp; +/// falls back to the raw string when it isn't a number. +String fmtNum(dynamic v) { + if (v == null) return '-'; + if (v is num) { + return v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2); + } + final n = num.tryParse(v.toString()); + return n != null ? fmtNum(n) : v.toString(); +} + +/// Turns a snake_case key into a Title Case label. +String prettyKey(String k) => k + .replaceAll('_', ' ') + .split(' ') + .map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); + +/// First non-null value among [keys] present in [row]. +dynamic pickValue(Map row, List keys) { + for (final k in keys) { + if (row[k] != null) return row[k]; + } + return null; +} + +/// A muted "key" / bold right-aligned "value" row, used by the table +/// (single-row) and comparison renderers. +Widget kvRow(AppTones tones, String k, String v) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text(k, + style: TextStyle(color: tones.textMuted, fontSize: 12.sp)), + ), + SizedBox(width: 8.w), + Expanded( + flex: 3, + child: Text( + v, + textAlign: TextAlign.right, + style: TextStyle( + color: tones.textPrimary, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_block_widget.dart b/lib/presentation/ai_chat/widgets/blocks/chat_block_widget.dart new file mode 100644 index 00000000..fe8da1e2 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_block_widget.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_callout_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_canvas_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_chart_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_comparison_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_import_review_card.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_kpi_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_list_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_markdown_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_progress_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_question_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_quick_actions_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_table_block.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_timeline_block.dart'; + +/// Dispatches a parsed [ChatBlock] to its dedicated renderer widget (one per +/// type under `blocks/`). Unknown blocks degrade to their text; proposed +/// actions render nothing here (they're shown as a dedicated card at the +/// message level). +class ChatBlockWidget extends StatelessWidget { + final ChatBlock block; + const ChatBlockWidget({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + return switch (block) { + MarkdownBlock(:final text) => ChatMarkdownBlock(text: text), + final TableBlock b => ChatTableBlock(block: b), + final KpiBlock b => ChatKpiBlock(block: b), + final ChartBlock b => ChatChartBlock(block: b), + final ComparisonBlock b => ChatComparisonBlock(block: b), + final ListBlock b => ChatListBlock(block: b), + final CalloutBlock b => ChatCalloutBlock(block: b), + final TimelineBlock b => ChatTimelineBlock(block: b), + final ProgressBlock b => ChatProgressBlock(block: b), + final QuestionBlock b => ChatQuestionBlock(block: b), + final QuickActionsBlock b => ChatQuickActionsBlock(block: b), + final ImportReviewBlock b => ChatImportReviewCard( + key: ValueKey('imp-${b.importSessionId ?? 0}'), block: b), + final CanvasBlock b => ChatCanvasBlock(block: b), + UnknownBlock(:final text) => ChatMarkdownBlock(text: text), + ProposedActionBlock() => const SizedBox.shrink(), + }; + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_callout_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_callout_block.dart new file mode 100644 index 00000000..147d2ee9 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_callout_block.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a callout block: a tinted, icon-led note whose color reflects its +/// variant (success / warning / danger / info). +class ChatCalloutBlock extends StatelessWidget { + final CalloutBlock block; + const ChatCalloutBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final b = block; + final (color, icon) = switch (b.variant) { + 'success' => (tones.income.deep, Icons.check_circle_outline_rounded), + 'warning' => (tones.accentWarm, Icons.warning_amber_rounded), + 'danger' => (tones.expense.deep, Icons.error_outline_rounded), + _ => (tones.brand.deep, Icons.info_outline_rounded), + }; + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 8.h), + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: color.withAlpha(20), + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: color.withAlpha(60)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18.sp, color: color), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if ((b.title ?? '').isNotEmpty) + Text(b.title!, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary)), + if (b.text.isNotEmpty) + Padding( + padding: EdgeInsets.only( + top: (b.title ?? '').isNotEmpty ? 4.h : 0), + child: Text(b.text, + style: TextStyle( + fontSize: 12.sp, + height: 1.4, + color: tones.textSecondary)), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_canvas_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_canvas_block.dart new file mode 100644 index 00000000..5b87f274 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_canvas_block.dart @@ -0,0 +1,91 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_widget.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// A tappable card that opens its nested blocks in a bottom sheet. +class ChatCanvasBlock extends StatelessWidget { + final CanvasBlock block; + const ChatCanvasBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final b = block; + return InkWell( + borderRadius: BorderRadius.circular(14.r), + onTap: () => _openCanvas(context, b.title ?? '', b.blocks), + child: ChatBlockCard( + child: Row( + children: [ + Icon(Icons.dashboard_customize_outlined, + size: 18.sp, color: tones.brand.deep), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (b.title ?? '').isEmpty + ? LocaleKeys.aiOpenCanvas.tr() + : b.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary), + ), + SizedBox(height: 2.h), + Text('${b.blocks.length}', + style: + TextStyle(fontSize: 11.sp, color: tones.textMuted)), + ], + ), + ), + Icon(Icons.open_in_full_rounded, + size: 16.sp, color: tones.textMuted), + ], + ), + ), + ); + } + + void _openCanvas(BuildContext context, String title, List nested) { + final tones = context.tones; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: tones.bgPage, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + ), + builder: (sheetContext) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.8, + maxChildSize: 0.95, + builder: (_, controller) => Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: ListView( + controller: controller, + children: [ + SizedBox(height: 12.h), + if (title.isNotEmpty) + Text(title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary)), + for (final b in nested) ChatBlockWidget(block: b), + SizedBox(height: 24.h), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_chart_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_chart_block.dart new file mode 100644 index 00000000..470f7dfe --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_chart_block.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_table_block.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a chart block. Picks the first non-numeric column as the category +/// (x) axis and the first numeric one as the value (y) axis; falls back to a +/// table when there's nothing to plot. +class ChatChartBlock extends StatelessWidget { + final ChartBlock block; + const ChatChartBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final b = block; + final data = b.data; + if (data.isEmpty) { + return ChatTableBlock( + block: TableBlock(title: b.title, columns: const [], rows: data), + ); + } + final keys = data.first.keys.toList(); + final labelKey = keys.firstWhere( + (k) => data.first[k] is! num, + orElse: () => keys.first, + ); + final valueKey = keys.firstWhere( + (k) => k != labelKey && data.first[k] is num, + orElse: () => keys.length > 1 ? keys[1] : keys.first, + ); + final isCircular = b.chartHint == 'pie' || b.chartHint == 'donut'; + + double y(Map d) { + final v = d[valueKey]; + return v is num ? v.toDouble() : (num.tryParse('$v')?.toDouble() ?? 0); + } + + String x(Map d) => '${d[labelKey] ?? ''}'; + + final chart = isCircular + ? SfCircularChart( + legend: + const Legend(isVisible: true, position: LegendPosition.bottom), + series: [ + DoughnutSeries, String>( + dataSource: data, + xValueMapper: (d, _) => x(d), + yValueMapper: (d, _) => y(d), + ), + ], + ) + : SfCartesianChart( + primaryXAxis: const CategoryAxis(), + series: [ + if (b.chartHint == 'line' || b.chartHint == 'area') + LineSeries, String>( + dataSource: data, + xValueMapper: (d, _) => x(d), + yValueMapper: (d, _) => y(d), + color: tones.brand.deep, + ) + else + ColumnSeries, String>( + dataSource: data, + xValueMapper: (d, _) => x(d), + yValueMapper: (d, _) => y(d), + color: tones.brand.deep, + ), + ], + ); + + return ChatBlockCard( + title: b.title, + child: SizedBox(height: 220.h, child: chart), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_comparison_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_comparison_block.dart new file mode 100644 index 00000000..9833b31e --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_comparison_block.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a comparison block: one tinted sub-card per series, each a heading +/// followed by its key/value pairs. +class ChatComparisonBlock extends StatelessWidget { + final ComparisonBlock block; + const ChatComparisonBlock({super.key, required this.block}); + + static const _headingKeys = ['heading', 'label', 'title', 'name', 'period']; + + @override + Widget build(BuildContext context) { + final tones = context.tones; + if (block.series.isEmpty) return const SizedBox.shrink(); + return ChatBlockCard( + title: block.title, + child: Column( + children: block.series.map((col) { + final heading = '${pickValue(col, _headingKeys) ?? ''}'; + final pairs = col.entries.where((e) => !_headingKeys.contains(e.key)); + return Container( + margin: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.all(10.r), + decoration: BoxDecoration( + color: tones.bgPage, + borderRadius: BorderRadius.circular(10.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (heading.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Text(heading, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary)), + ), + ...pairs + .map((e) => kvRow(tones, prettyKey(e.key), fmtNum(e.value))), + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_import_review_card.dart b/lib/presentation/ai_chat/widgets/blocks/chat_import_review_card.dart new file mode 100644 index 00000000..9bd8b50a --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_import_review_card.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/di/injection.dart'; +import 'package:trakli/domain/entities/import/import_session_status.dart'; +import 'package:trakli/domain/usecases/import/get_import_session_usecase.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/imports/suggestion_review_screen.dart'; +import 'package:trakli/presentation/utils/app_navigator.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// An `import_review` block: shows the file's analysis status and, once ready, +/// a "Review import" action. Polls the import session every few seconds. +class ChatImportReviewCard extends StatefulWidget { + final ImportReviewBlock block; + const ChatImportReviewCard({super.key, required this.block}); + + @override + State createState() => _ChatImportReviewCardState(); +} + +class _ChatImportReviewCardState extends State { + late ImportSessionStatus _status; + Timer? _timer; + int _ticks = 0; + + @override + void initState() { + super.initState(); + _status = ImportSessionStatus.parse(widget.block.status); + if (widget.block.importSessionId != null && _status.isInFlight) { + _timer = Timer.periodic(const Duration(seconds: 3), (_) => _poll()); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _poll() async { + final id = widget.block.importSessionId; + // Give up after ~2 minutes so a stuck import doesn't poll forever. + if (id == null || _ticks++ > 40) { + _timer?.cancel(); + return; + } + final result = await getIt()( + GetImportSessionParams(sessionId: id), + ); + if (!mounted) return; + result.fold((_) {}, (session) { + setState(() => _status = session.status); + if (session.status.isTerminal) _timer?.cancel(); + }); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final analyzing = _status.isInFlight; + final sessionId = widget.block.importSessionId; + final canReview = + sessionId != null && _status != ImportSessionStatus.unknown; + final showReadyIcon = _status == ImportSessionStatus.ready || + _status == ImportSessionStatus.confirmed; + final fileName = widget.block.fileName ?? ''; + final statusLabel = switch (_status) { + ImportSessionStatus.ready => LocaleKeys.aiImportStatusReady.tr(), + ImportSessionStatus.confirmed => LocaleKeys.aiImportStatusConfirmed.tr(), + ImportSessionStatus.failed => LocaleKeys.aiImportStatusFailed.tr(), + ImportSessionStatus.unknown => prettyKey(widget.block.status), + _ => LocaleKeys.aiImportStatusAnalyzing.tr(), + }; + + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 8.h), + padding: EdgeInsets.all(14.r), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.description_outlined, + size: 18.sp, color: tones.brand.deep), + SizedBox(width: 8.w), + Expanded( + child: Text( + fileName.isEmpty ? statusLabel : fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: tones.textPrimary), + ), + ), + ], + ), + SizedBox(height: 8.h), + Row( + children: [ + if (analyzing) ...[ + SizedBox( + width: 12.r, + height: 12.r, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8.w), + ] else if (_status == ImportSessionStatus.failed) ...[ + Icon(Icons.error_outline_rounded, + size: 14.sp, color: tones.expense.deep), + SizedBox(width: 6.w), + ] else if (showReadyIcon) ...[ + Icon(Icons.check_circle_rounded, + size: 14.sp, color: tones.income.deep), + SizedBox(width: 6.w), + ], + Text(statusLabel, + style: TextStyle(fontSize: 12.sp, color: tones.textMuted)), + const Spacer(), + if (canReview) + InkWell( + onTap: () => AppNavigator.push( + context, + SuggestionReviewScreen(sessionId: sessionId), + ), + child: Text( + LocaleKeys.aiReviewImport.tr(), + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: tones.brand.deep), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_kpi_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_kpi_block.dart new file mode 100644 index 00000000..a7f90109 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_kpi_block.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a KPI block: a wrap of metric cards, each with an optional +/// percentage delta indicator. +class ChatKpiBlock extends StatelessWidget { + final KpiBlock block; + const ChatKpiBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + if (block.items.isEmpty) return const SizedBox.shrink(); + return ChatBlockCard( + title: block.title, + child: Wrap( + spacing: 10.w, + runSpacing: 10.h, + children: block.items.map((it) { + final currency = it.currency ?? ''; + final unit = it.unit ?? ''; + final value = fmtNum(it.value); + final delta = it.deltaPercent; + final up = delta != null ? delta >= 0 : null; + return Container( + width: 150.w, + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: tones.bgPage, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: tones.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(it.label, + style: TextStyle(color: tones.textMuted, fontSize: 11.sp)), + SizedBox(height: 4.h), + Text( + [ + if (currency.isNotEmpty) currency, + value, + if (unit.isNotEmpty) unit + ].join(' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tones.textPrimary, + fontSize: 16.sp, + fontWeight: FontWeight.w800, + ), + ), + if (delta != null) ...[ + SizedBox(height: 4.h), + Row( + children: [ + Icon( + up == true + ? Icons.trending_up_rounded + : Icons.trending_down_rounded, + size: 13.sp, + color: + up == true ? tones.income.deep : tones.expense.deep, + ), + SizedBox(width: 4.w), + Text( + '${delta.abs().toStringAsFixed(1)}%', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w600, + color: up == true + ? tones.income.deep + : tones.expense.deep, + ), + ), + ], + ), + ], + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_legacy_result.dart b/lib/presentation/ai_chat/widgets/blocks/chat_legacy_result.dart new file mode 100644 index 00000000..6b4dc6bc --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_legacy_result.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_widget.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a legacy SmartQL result (`format_type` + `rows`) used when a message +/// has no agent blocks. Tabular formats fall through to the table renderer. +class ChatLegacyResult extends StatelessWidget { + final LegacyResult result; + const ChatLegacyResult({super.key, required this.result}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final rows = result.rows; + if (rows.isEmpty) return const SizedBox.shrink(); + final format = result.formatType; + + if (format == 'table' || + !const ['scalar', 'pair', 'record', 'list', 'pair_list'] + .contains(format)) { + return ChatBlockWidget( + block: TableBlock(title: null, columns: const [], rows: rows), + ); + } + + final Widget body; + switch (format) { + case 'scalar': + final first = rows.first.values; + body = Text( + first.isEmpty ? '-' : fmtNum(first.first), + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w800, + color: tones.brand.deep, + ), + ); + break; + case 'pair': + final entries = rows.first.entries.toList(); + final label = + entries.isNotEmpty ? prettyKey(entries.first.key) : ''; + final value = entries.length > 1 + ? entries[1].value + : (entries.isNotEmpty ? entries.first.value : null); + body = _record(tones, label, fmtNum(value)); + break; + case 'list': + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows.map((r) { + final v = r.values.isNotEmpty ? r.values.first : null; + return Padding( + padding: EdgeInsets.symmetric(vertical: 3.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('• ', + style: + TextStyle(fontSize: 13.sp, color: tones.textMuted)), + Expanded( + child: Text(fmtNum(v), + style: TextStyle( + fontSize: 13.sp, color: tones.textPrimary)), + ), + ], + ), + ); + }).toList(), + ); + break; + case 'pair_list': + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows.map((r) { + final vals = r.values.toList(); + final label = vals.isNotEmpty ? '${vals.first}' : ''; + final value = vals.length > 1 ? vals[1] : null; + return _record(tones, label, fmtNum(value)); + }).toList(), + ); + break; + case 'record': + default: + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows.first.entries + .map((e) => _record(tones, prettyKey(e.key), fmtNum(e.value))) + .toList(), + ); + } + + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 8.h), + padding: EdgeInsets.all(14.r), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: body, + ); + } + + /// A bold "Label:" followed by its value. + Widget _record(AppTones tones, String label, String value) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '$label: ', + style: TextStyle( + color: tones.textPrimary, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ), + TextSpan( + text: value, + style: TextStyle( + color: tones.textSecondary, + fontSize: 13.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_list_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_list_block.dart new file mode 100644 index 00000000..53459cae --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_list_block.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a list block: each item shows a primary line, an optional secondary +/// line, and an optional trailing amount. +class ChatListBlock extends StatelessWidget { + final ListBlock block; + const ChatListBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + if (block.items.isEmpty) return const SizedBox.shrink(); + return ChatBlockCard( + title: block.title, + child: Column( + children: block.items.map((row) { + final primary = + '${pickValue(row, ['title', 'label', 'name', 'description']) ?? ''}'; + final secondary = '${pickValue(row, [ + 'date', + 'datetime', + 'subtitle', + 'category', + 'wallet', + 'type' + ]) ?? ''}'; + final trailingVal = + pickValue(row, ['amount', 'value', 'total', 'count']); + final currency = '${row['currency'] ?? ''}'; + final trailing = trailingVal == null + ? '' + : '${currency.isNotEmpty ? '$currency ' : ''}${fmtNum(trailingVal)}'; + return Padding( + padding: EdgeInsets.symmetric(vertical: 6.h), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(primary, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: tones.textPrimary)), + if (secondary.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text(secondary, + style: TextStyle( + fontSize: 11.sp, color: tones.textMuted)), + ], + ], + ), + ), + if (trailing.isNotEmpty) ...[ + SizedBox(width: 8.w), + Text(trailing, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary)), + ], + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_markdown_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_markdown_block.dart new file mode 100644 index 00000000..1f3082f3 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_markdown_block.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a markdown block. Also used as the fallback renderer for unknown +/// block types (their `text`/`summary`). +class ChatMarkdownBlock extends StatelessWidget { + final String text; + const ChatMarkdownBlock({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + final tones = context.tones; + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: GptMarkdown( + text, + style: + TextStyle(color: tones.textPrimary, fontSize: 14.sp, height: 1.45), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_progress_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_progress_block.dart new file mode 100644 index 00000000..e5b1fae4 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_progress_block.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a progress block: a labeled bar per item whose color shifts toward +/// warning/over as it approaches/exceeds its target. +class ChatProgressBlock extends StatelessWidget { + final ProgressBlock block; + const ChatProgressBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + if (block.items.isEmpty) return const SizedBox.shrink(); + return ChatBlockCard( + title: block.title, + child: Column( + children: block.items.map((it) { + final current = it.current ?? 0; + final target = it.target ?? 0; + final ratio = target > 0 ? (current / target).clamp(0.0, 1.0) : 0.0; + final pct = target > 0 ? current / target : 0; + final currency = it.currency ?? ''; + final barColor = pct >= 1 + ? tones.expense.deep + : pct >= 0.8 + ? tones.accentWarm + : tones.brand.deep; + return Padding( + padding: EdgeInsets.symmetric(vertical: 6.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(it.label ?? '', + style: TextStyle( + fontSize: 12.sp, color: tones.textPrimary)), + ), + Text( + '${currency.isNotEmpty ? '$currency ' : ''}${fmtNum(current)} / ${fmtNum(target)}', + style: TextStyle(fontSize: 11.sp, color: tones.textMuted), + ), + ], + ), + SizedBox(height: 6.h), + ClipRRect( + borderRadius: BorderRadius.circular(6.r), + child: LinearProgressIndicator( + value: ratio.toDouble(), + minHeight: 7.h, + backgroundColor: tones.borderLight, + valueColor: AlwaysStoppedAnimation(barColor), + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_question_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_question_block.dart new file mode 100644 index 00000000..37680a22 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_question_block.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_action_pill.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a question block: a prompt followed by tappable option pills that +/// send their message back to the chat. +class ChatQuestionBlock extends StatelessWidget { + final QuestionBlock block; + const ChatQuestionBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return ChatBlockCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if ((block.prompt ?? '').isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 10.h), + child: Text(block.prompt!, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: tones.textPrimary)), + ), + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: block.options + .map((o) => ChatActionPill(label: o.label, message: o.message)) + .toList(), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_quick_actions_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_quick_actions_block.dart new file mode 100644 index 00000000..5c55db0b --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_quick_actions_block.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_action_pill.dart'; + +/// Renders a quick-actions block: a wrap of tappable pills, each sending its +/// own label back to the chat as a message. +class ChatQuickActionsBlock extends StatelessWidget { + final QuickActionsBlock block; + const ChatQuickActionsBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + if (block.actions.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: block.actions + .map((a) => ChatActionPill(label: a.label, message: a.label)) + .toList(), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_table_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_table_block.dart new file mode 100644 index 00000000..5ca42f80 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_table_block.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a table block: a single-row block degrades to key/value pairs; +/// multi-row renders a proper table with a header row. +class ChatTableBlock extends StatelessWidget { + final TableBlock block; + const ChatTableBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final rows = block.rows; + if (rows.isEmpty) return const SizedBox.shrink(); + final columns = + block.columns.isNotEmpty ? block.columns : rows.first.keys.toList(); + + if (rows.length == 1) { + final row = rows.first; + return ChatBlockCard( + title: block.title, + child: Column( + children: row.entries + .map((e) => kvRow(tones, prettyKey(e.key), fmtNum(e.value))) + .toList(), + ), + ); + } + + bool isNumericCol(String c) => rows.every((r) { + final v = r[c]; + return v == null || v is num || num.tryParse('$v') != null; + }); + + return ChatBlockCard( + title: block.title, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.r), + child: Table( + columnWidths: { + for (var i = 0; i < columns.length; i++) i: const FlexColumnWidth(), + }, + border: TableBorder( + horizontalInside: BorderSide(color: tones.borderLight), + ), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + decoration: BoxDecoration(color: tones.brand.deep.withAlpha(28)), + children: columns + .map((c) => _cell(tones, prettyKey(c), + bold: true, right: isNumericCol(c))) + .toList(), + ), + ...rows.map( + (r) => TableRow( + children: columns + .map((c) => + _cell(tones, fmtNum(r[c]), right: isNumericCol(c))) + .toList(), + ), + ), + ], + ), + ), + ); + } +} + +Widget _cell(AppTones tones, String text, + {bool bold = false, bool right = false}) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + child: Text( + text, + textAlign: right ? TextAlign.right : TextAlign.left, + style: TextStyle( + fontSize: 12.sp, + fontWeight: bold ? FontWeight.w700 : FontWeight.w500, + color: tones.textPrimary, + ), + ), + ); +} diff --git a/lib/presentation/ai_chat/widgets/blocks/chat_timeline_block.dart b/lib/presentation/ai_chat/widgets/blocks/chat_timeline_block.dart new file mode 100644 index 00000000..41247acf --- /dev/null +++ b/lib/presentation/ai_chat/widgets/blocks/chat_timeline_block.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_card.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +/// Renders a timeline block: a vertical connector with a dot per item, each +/// showing a title, optional amount, date, and description. +class ChatTimelineBlock extends StatelessWidget { + final TimelineBlock block; + const ChatTimelineBlock({super.key, required this.block}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + if (block.items.isEmpty) return const SizedBox.shrink(); + final n = block.items.length; + return ChatBlockCard( + title: block.title, + child: Column( + children: List.generate(n, (i) { + final it = block.items[i]; + final isLast = i == n - 1; + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 16.r, + child: Column( + children: [ + SizedBox(height: 4.h), + Container( + width: 9.r, + height: 9.r, + decoration: BoxDecoration( + color: tones.brand.deep, + shape: BoxShape.circle, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2.r, + color: tones.brand.deep.withAlpha(50), + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(it.title ?? '', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: tones.textPrimary)), + ), + if (it.amount != null) + Text( + '${(it.currency ?? '').isNotEmpty ? '${it.currency} ' : ''}${fmtNum(it.amount)}', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary), + ), + ], + ), + if ((it.date ?? '').isNotEmpty) ...[ + SizedBox(height: 2.h), + Text(it.date!, + style: TextStyle( + fontSize: 11.sp, color: tones.textMuted)), + ], + if ((it.description ?? '').isNotEmpty) ...[ + SizedBox(height: 4.h), + Text(it.description!, + style: TextStyle( + fontSize: 12.sp, + height: 1.4, + color: tones.textSecondary)), + ], + ], + ), + ), + ), + ], + ), + ); + }), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/chat_composer.dart b/lib/presentation/ai_chat/widgets/chat_composer.dart new file mode 100644 index 00000000..e0c80088 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/chat_composer.dart @@ -0,0 +1,520 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/imports/widgets/import_file_picker.dart'; +import 'package:trakli/presentation/imports/widgets/import_source_button.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +class Composer extends StatefulWidget { + final TextEditingController controller; + final FocusNode focus; + final bool isSending; + final void Function(String text, List files, String? documentType) + onSend; + + const Composer({ + super.key, + required this.controller, + required this.focus, + required this.isSending, + required this.onSend, + }); + + @override + State createState() => ComposerState(); +} + +class ComposerState extends State { + bool _hasText = false; + final List _files = []; + String? _documentType; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTextChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onTextChanged); + super.dispose(); + } + + void _onTextChanged() { + final hasText = widget.controller.text.trim().isNotEmpty; + if (hasText != _hasText) setState(() => _hasText = hasText); + } + + void _submit() { + final text = widget.controller.text.trim(); + if (text.isEmpty && _files.isEmpty) return; + widget.onSend(text, List.of(_files), _documentType); + setState(() { + _files.clear(); + _documentType = null; + }); + } + + Future _openAttachSheet() async { + final tones = context.tones; + await showModalBottomSheet( + context: context, + backgroundColor: tones.bgSurface, + isScrollControlled: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + ), + builder: (sheetContext) { + Future pick( + Future Function() picker, + String? documentType, + ) async { + final file = await picker(); + if (file == null) return; + if (sheetContext.mounted) Navigator.pop(sheetContext); + if (!mounted) return; + setState(() { + _files.add(file); + if (documentType != null) _documentType = documentType; + }); + } + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.52, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (context, scrollController) { + return SafeArea( + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 16.h), + children: [ + Center( + child: Container( + width: 40.w, + height: 4.h, + decoration: BoxDecoration( + color: tones.borderLight, + borderRadius: BorderRadius.circular(2.r), + ), + ), + ), + SizedBox(height: 16.h), + Text( + LocaleKeys.importScanDocument.tr(), + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary, + ), + ), + SizedBox(height: 16.h), + Row( + children: [ + Expanded( + child: _SourceSquare( + icon: Icons.photo_camera_outlined, + label: LocaleKeys.importSourceCamera.tr(), + onTap: () => + pick(ImportFilePicker.captureFromCamera, null), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _SourceSquare( + icon: Icons.photo_library_outlined, + label: LocaleKeys.importSourceGallery.tr(), + onTap: () => + pick(ImportFilePicker.pickFromGallery, null), + ), + ), + ], + ), + SizedBox(height: 20.h), + Text( + LocaleKeys.importDocTypeLabel.tr(), + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: tones.textSecondary, + ), + ), + SizedBox(height: 12.h), + ImportSourceButton( + icon: Icons.account_balance_outlined, + label: LocaleKeys.importDocBankStatement.tr(), + subtitle: LocaleKeys.importSourceFileDesc.tr(), + onTap: () => + pick(ImportFilePicker.pickDocument, 'bank_statement'), + ), + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.receipt_long_outlined, + label: LocaleKeys.importDocReceipt.tr(), + subtitle: LocaleKeys.importSourceFileDesc.tr(), + onTap: () => + pick(ImportFilePicker.pickDocument, 'receipt'), + ), + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.description_outlined, + label: LocaleKeys.importDocInvoice.tr(), + subtitle: LocaleKeys.importSourceFileDesc.tr(), + onTap: () => pick(ImportFilePicker.pickDocument, 'invoice'), + ), + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.folder_outlined, + label: LocaleKeys.importSourceFile.tr(), + subtitle: LocaleKeys.importSourceFileDesc.tr(), + onTap: () => pick(ImportFilePicker.pickDocument, null), + ), + ], + ), + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final canSend = (_hasText || _files.isNotEmpty) && !widget.isSending; + + return Padding( + padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 10.h), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_files.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 8.h), + child: Wrap( + spacing: 6.w, + runSpacing: 6.h, + children: List.generate(_files.length, (i) { + return _AttachmentPreview( + file: _files[i], + onRemove: () => setState(() { + _files.removeAt(i); + if (_files.isEmpty) _documentType = null; + }), + ); + }), + ), + ), + Container( + decoration: BoxDecoration( + color: tones.bgSurface, + borderRadius: BorderRadius.circular(16.r), + border: Border.all(color: tones.borderLight), + ), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: _AttachButton(onTap: _openAttachSheet), + ), + SizedBox(width: 1.w), + Expanded( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 120.h), + child: TextField( + controller: widget.controller, + focusNode: widget.focus, + maxLines: null, + minLines: 1, + textInputAction: TextInputAction.newline, + keyboardType: TextInputType.multiline, + style: TextStyle( + color: tones.textPrimary, + fontSize: 14.sp, + height: 1.4, + ), + decoration: InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isCollapsed: true, + isDense: true, + contentPadding: EdgeInsets.fromLTRB(0, 12.h, 0, 12.h), + hintText: LocaleKeys.aiChatComposerPlaceholder.tr(), + hintStyle: TextStyle( + color: tones.textMuted, + fontSize: 14.sp, + ), + ), + ), + ), + ), + SizedBox(width: 8.w), + Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: _SendButton( + canSend: canSend, + isSending: widget.isSending, + onTap: _submit, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _AttachmentPreview extends StatelessWidget { + final File file; + final VoidCallback onRemove; + const _AttachmentPreview({required this.file, required this.onRemove}); + + static const _imageExts = { + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'heic', + 'bmp' + }; + + String get _name => ImportFilePicker.displayName(file.path); + + String get _ext { + final dot = _name.lastIndexOf('.'); + return dot == -1 ? '' : _name.substring(dot + 1).toLowerCase(); + } + + IconData get _docIcon => switch (_ext) { + 'pdf' => Icons.picture_as_pdf_rounded, + 'csv' || 'xls' || 'xlsx' => Icons.table_chart_rounded, + 'doc' || 'docx' || 'txt' || 'rtf' => Icons.description_rounded, + _ => Icons.insert_drive_file_rounded, + }; + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final size = 60.r; + final isImage = _imageExts.contains(_ext); + + return SizedBox( + width: size, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: SizedBox( + width: size, + height: size, + child: isImage + ? Image.file( + file, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _docTile(tones), + ) + : _docTile(tones), + ), + ), + Positioned( + top: 3.r, + right: 3.r, + child: GestureDetector( + onTap: onRemove, + child: Container( + padding: EdgeInsets.all(2.r), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + shape: BoxShape.circle, + ), + child: Icon(Icons.close_rounded, + size: 12.sp, color: Colors.white), + ), + ), + ), + ], + ), + SizedBox(height: 4.h), + Text( + _name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 9.sp, color: tones.textMuted), + ), + ], + ), + ); + } + + Widget _docTile(AppTones tones) { + return Container( + color: tones.bgCard, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_docIcon, size: 22.sp, color: tones.brand.deep), + if (_ext.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + _ext.toUpperCase(), + style: TextStyle( + fontSize: 8.sp, + fontWeight: FontWeight.w700, + color: tones.textMuted, + ), + ), + ], + ], + ), + ); + } +} + +class _SendButton extends StatelessWidget { + final bool canSend; + final bool isSending; + final VoidCallback onTap; + const _SendButton({ + required this.canSend, + required this.isSending, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final enabled = canSend && !isSending; + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 36.r, + height: 36.r, + decoration: BoxDecoration( + color: enabled ? tones.brand.deep : tones.borderLight, + shape: BoxShape.circle, + ), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: enabled ? onTap : null, + child: Center( + child: isSending + ? SizedBox( + width: 14.r, + height: 14.r, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Icon( + Icons.send_rounded, + color: enabled ? Colors.white : tones.textMuted, + size: 16.sp, + ), + ), + ), + ), + ); + } +} + +class _AttachButton extends StatelessWidget { + final VoidCallback onTap; + const _AttachButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Container( + width: 36.r, + height: 36.r, + decoration: BoxDecoration( + color: tones.bgCard, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add_rounded, + size: 20.sp, + color: tones.textSecondary, + ), + ), + ), + ); + } +} + +class _SourceSquare extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _SourceSquare({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Material( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14.r), + child: Container( + height: 96.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 28.sp, color: tones.brand.deep), + SizedBox(height: 8.h), + Text( + label, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: tones.textPrimary, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/chat_history_sheet.dart b/lib/presentation/ai_chat/widgets/chat_history_sheet.dart new file mode 100644 index 00000000..612fd223 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/chat_history_sheet.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; +import 'package:trakli/di/injection.dart'; +import 'package:trakli/domain/repositories/ai_repository.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/ai_chat/cubit/ai_chat_cubit.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +class HistorySheet extends StatefulWidget { + final AiChatCubit cubit; + const HistorySheet({super.key, required this.cubit}); + + @override + State createState() => HistorySheetState(); +} + +class HistorySheetState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _fetch(); + } + + Future> _fetch() async { + final result = await getIt().listSessions(); + return result.fold((_) => [], (sessions) => sessions); + } + + void _reload() => setState(() => _future = _fetch()); + + Future _confirmDelete(ChatSessionDto session) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final tones = ctx.tones; + return AlertDialog( + backgroundColor: tones.bgCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + content: Text( + LocaleKeys.aiChatDeleteConfirm.tr(), + style: TextStyle( + color: tones.textPrimary, + fontSize: 14.sp, + height: 1.4, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text( + LocaleKeys.cancel.tr(), + style: TextStyle(color: tones.textSecondary), + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + LocaleKeys.delete.tr(), + style: TextStyle( + color: tones.expense.deep, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) return; + await widget.cubit.deleteSession(session.id); + _reload(); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: tones.bgPage, + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + ), + child: Column( + children: [ + SizedBox(height: 8.h), + Container( + width: 40.w, + height: 4.h, + decoration: BoxDecoration( + color: tones.borderMedium, + borderRadius: BorderRadius.circular(2.r), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16.w, 12.h, 8.w, 12.h), + child: Row( + children: [ + Expanded( + child: Text( + LocaleKeys.aiChatHistory.tr(), + style: TextStyle( + color: tones.textPrimary, + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: Icon(Icons.close_rounded, + color: tones.textSecondary, size: 22.sp), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Divider(height: 1, color: tones.borderLight), + Expanded( + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + final sessions = snapshot.data ?? const []; + if (sessions.isEmpty) { + return _HistoryEmpty(); + } + return BlocBuilder( + bloc: widget.cubit, + buildWhen: (a, b) => a.session?.id != b.session?.id, + builder: (context, state) { + return ListView.separated( + controller: scrollController, + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: sessions.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: tones.borderLight), + itemBuilder: (context, i) { + final s = sessions[i]; + final isCurrent = state.session?.id == s.id; + return _SessionRow( + session: s, + isCurrent: isCurrent, + onTap: () { + widget.cubit.openSession(s.id); + Navigator.of(context).pop(); + }, + onDelete: () => _confirmDelete(s), + ); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _HistoryEmpty extends StatelessWidget { + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Padding( + padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 40.h), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56.r, + height: 56.r, + decoration: BoxDecoration( + color: tones.brand.background, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.smart_toy_rounded, + color: tones.brand.deep, + size: 26.sp, + ), + ), + SizedBox(height: 14.h), + Text( + LocaleKeys.aiChatNoSessions.tr(), + style: TextStyle( + color: tones.textPrimary, + fontSize: 15.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + LocaleKeys.aiChatNoSessionsHint.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: tones.textSecondary, + fontSize: 13.sp, + ), + ), + ], + ), + ); + } +} + +class _SessionRow extends StatelessWidget { + final ChatSessionDto session; + final bool isCurrent; + final VoidCallback onTap; + final VoidCallback onDelete; + + const _SessionRow({ + required this.session, + required this.isCurrent, + required this.onTap, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final title = (session.title?.trim().isNotEmpty ?? false) + ? session.title!.trim() + : LocaleKeys.aiChatUntitled.tr(); + + return InkWell( + onTap: onTap, + child: Container( + color: isCurrent ? tones.brand.background : Colors.transparent, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 36.r, + height: 36.r, + decoration: BoxDecoration( + color: isCurrent ? tones.brand.deep : tones.brand.background, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.smart_toy_rounded, + color: isCurrent ? Colors.white : tones.brand.deep, + size: 20.sp, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tones.textPrimary, + fontSize: 14.sp, + fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w600, + ), + ), + SizedBox(height: 2.h), + Text( + _relativeTime(session.updatedAt), + style: TextStyle( + color: tones.textMuted, + fontSize: 12.sp, + ), + ), + ], + ), + ), + IconButton( + icon: Icon( + Icons.delete_outline_rounded, + color: tones.textMuted, + size: 20.sp, + ), + onPressed: onDelete, + splashRadius: 20.r, + ), + ], + ), + ), + ); + } +} + +String _relativeTime(DateTime when) { + final now = DateTime.now(); + final diff = now.difference(when); + if (diff.inSeconds < 60) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ]; + return '${months[when.month - 1]} ${when.day}'; +} diff --git a/lib/presentation/ai_chat/widgets/chat_landing.dart b/lib/presentation/ai_chat/widgets/chat_landing.dart new file mode 100644 index 00000000..ed6e5925 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/chat_landing.dart @@ -0,0 +1,593 @@ +import 'dart:async'; + +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import 'package:trakli/core/utils/currency_formater.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_session_dto.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/auth/cubits/auth/auth_cubit.dart'; +import 'package:trakli/presentation/currency/cubit/currency_cubit.dart'; +import 'package:trakli/presentation/exchange_rate/cubit/exchange_rate_cubit.dart'; +import 'package:trakli/presentation/transactions/cubit/transaction_cubit.dart'; +import 'package:trakli/presentation/utils/dashboard_expenses.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; +import 'package:trakli/presentation/utils/enums.dart'; + +class ChatLanding extends StatefulWidget { + final void Function(String) onPick; + final ChatSessionDto? recentSession; + final VoidCallback onContinue; + const ChatLanding({ + super.key, + required this.onPick, + required this.onContinue, + this.recentSession, + }); + + @override + State createState() => ChatLandingState(); +} + +class ChatLandingState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _anim; + bool _resumeDismissed = false; + int _statIndex = 0; + + int _spotlightIndex = 0; + Timer? _spotlightTimer; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..forward(); + _spotlightTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (mounted) setState(() => _spotlightIndex++); + }); + } + + @override + void dispose() { + _spotlightTimer?.cancel(); + _anim.dispose(); + super.dispose(); + } + + String _greeting() { + final hour = DateTime.now().hour; + if (hour < 12) return LocaleKeys.aiGreetingMorning.tr(); + if (hour < 17) return LocaleKeys.aiGreetingAfternoon.tr(); + return LocaleKeys.aiGreetingEvening.tr(); + } + + /// Staggered fade + rise entrance animation for landing sections. + Widget _stagger(int index, Widget child) { + final start = (index * 0.07).clamp(0.0, 0.5); + final anim = CurvedAnimation( + parent: _anim, + curve: Interval(start, (start + 0.5).clamp(0.0, 1.0), + curve: Curves.easeOutCubic), + ); + return AnimatedBuilder( + animation: anim, + builder: (context, _) => Opacity( + opacity: anim.value, + child: Transform.translate( + offset: Offset(0, 14.h * (1 - anim.value)), + child: child, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final firstName = + context.watch().state.user?.firstName.trim() ?? ''; + final greeting = + firstName.isEmpty ? _greeting() : '${_greeting()}, $firstName'; + + final suggestions = [ + LocaleKeys.aiSuggestSpendMonth.tr(), + LocaleKeys.aiSuggestTopCategory.tr(), + LocaleKeys.aiSuggestIncomeVsExpense.tr(), + LocaleKeys.aiSuggestBalance.tr(), + ]; + + return ListView( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 32.h), + children: [ + SizedBox(height: 8.h), + _stagger(0, _hero(tones)), + SizedBox(height: 20.h), + _stagger( + 1, + Text( + greeting, + textAlign: TextAlign.center, + style: TextStyle( + color: tones.textPrimary, + fontSize: 20.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(height: 8.h), + _stagger( + 2, + Text( + LocaleKeys.aiChatEmptyHint.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: tones.textSecondary, + fontSize: 14.sp, + height: 1.5, + ), + ), + ), + SizedBox(height: 24.h), + _stagger(3, _statsCard(context)), + if (widget.recentSession != null && !_resumeDismissed) ...[ + SizedBox(height: 20.h), + _stagger(4, _resumeOptions(tones)), + ], + SizedBox(height: 24.h), + ...suggestions.asMap().entries.map( + (e) => _stagger( + 5 + e.key, + Padding( + padding: EdgeInsets.only(bottom: 10.h), + child: _suggestionChip(tones, e.value), + ), + ), + ), + ], + ); + } + + Widget _hero(AppTones tones) { + return Center( + child: Container( + width: 76.r, + height: 76.r, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [tones.brand.accent, tones.brand.deep], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: tones.brand.deep.withAlpha(60), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + padding: EdgeInsets.all(20.r), + child: Icon( + Icons.smart_toy_rounded, + color: Colors.white, + size: 34.r, + ), + ), + ); + } + + /// Compact, vertically-stacked resume options shown under the stats slider. + Widget _resumeOptions(AppTones tones) { + final rawTitle = widget.recentSession?.title?.trim() ?? ''; + final title = rawTitle.isEmpty ? LocaleKeys.aiChatUntitled.tr() : rawTitle; + return Column( + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle(color: tones.textMuted, fontSize: 11.sp), + ), + SizedBox(height: 8.h), + _pillButton( + tones, + label: LocaleKeys.aiContinueSession.tr(), + icon: Icons.history_rounded, + filled: true, + onTap: widget.onContinue, + ), + SizedBox(height: 8.h), + _pillButton( + tones, + label: LocaleKeys.aiStartNewSession.tr(), + icon: Icons.add_rounded, + filled: false, + onTap: () => setState(() => _resumeDismissed = true), + ), + ], + ); + } + + Widget _pillButton( + AppTones tones, { + required String label, + required IconData icon, + required bool filled, + required VoidCallback onTap, + }) { + final fg = filled ? Colors.white : tones.textPrimary; + return Center( + child: Material( + color: filled ? tones.brand.deep : Colors.transparent, + borderRadius: BorderRadius.circular(20.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + border: filled ? null : Border.all(color: tones.borderLight), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14.sp, color: fg), + SizedBox(width: 6.w), + Text( + label, + style: TextStyle( + color: fg, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _suggestionChip(AppTones tones, String q) { + return InkWell( + onTap: () => widget.onPick(q), + borderRadius: BorderRadius.circular(14.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: Row( + children: [ + Icon(Icons.bolt_rounded, size: 16.sp, color: tones.brand.deep), + SizedBox(width: 10.w), + Expanded( + child: Text( + q, + style: TextStyle( + color: tones.textPrimary, + fontSize: 13.sp, + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + ), + Icon(Icons.arrow_outward_rounded, + size: 14.sp, color: tones.textMuted), + ], + ), + ), + ); + } + + /// Auto-playing carousel of "this month" insights from transaction data. + Widget _statsCard(BuildContext context) { + final tones = context.tones; + return BlocBuilder( + buildWhen: (a, b) => a.transactions != b.transactions, + builder: (context, txState) { + final now = DateTime.now(); + final monthStart = DateTime(now.year, now.month, 1); + final monthEnd = DateTime(now.year, now.month + 1, 1); + final monthTx = txState.transactions.where((tx) { + final d = tx.transaction.datetime; + return !d.isBefore(monthStart) && d.isBefore(monthEnd); + }).toList(); + + final exchangeRate = context.watch().state.entity; + final currencySymbol = + context.watch().state.currency?.symbol ?? ''; + final totals = + calculateIncomeExpense(monthTx, exchangeRateEntity: exchangeRate); + final income = totals.totalIncome; + final expense = totals.totalExpense; + final net = income - expense; + final savingsRate = + income > 0 ? ((income - expense) / income * 100) : 0.0; + final expenseCount = monthTx + .where((t) => t.transaction.type == TransactionType.expense) + .length; + final avgExpense = expenseCount > 0 ? expense / expenseCount : 0.0; + final hasData = income != 0 || expense != 0; + + String money(double v) => + '$currencySymbol ${CurrencyFormater.formatAmount(context, v, compact: true)}'; + + if (!hasData) { + return _statPanel( + tones, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statHeader(tones), + SizedBox(height: 12.h), + Text( + LocaleKeys.aiLandingNoData.tr(), + style: TextStyle( + color: tones.textSecondary, + fontSize: 13.sp, + height: 1.5, + ), + ), + ], + ), + ); + } + + final panels = [ + _statPanel( + tones, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statHeader(tones), + SizedBox(height: 4.h), + Expanded( + child: DashboardExpenses( + totalIncome: income, + totalExpense: expense, + currencySymbol: currencySymbol, + ), + ), + ], + ), + ), + _statPanel( + tones, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _statHeader(tones), + SizedBox(height: 16.h), + _bigStat(tones, LocaleKeys.aiStatNetFlow.tr(), money(net), + net >= 0 ? tones.income.deep : tones.expense.deep), + SizedBox(height: 16.h), + _bigStat(tones, LocaleKeys.aiStatSaved.tr(), + '${savingsRate.toStringAsFixed(0)}%', tones.brand.deep), + ], + ), + ), + _statPanel( + tones, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _statHeader(tones), + SizedBox(height: 16.h), + _bigStat(tones, LocaleKeys.aiStatTransactions.tr(), + '${monthTx.length}', tones.brand.deep), + SizedBox(height: 16.h), + _bigStat(tones, LocaleKeys.aiStatAvgExpense.tr(), + money(avgExpense), tones.expense.deep), + ], + ), + ), + ]; + + final spotlights = _buildSpotlights( + savingsRate: savingsRate, + expense: expense, + net: net, + txCount: monthTx.length, + avgExpense: avgExpense, + money: money, + ); + + return Column( + children: [ + CarouselSlider( + items: panels, + options: CarouselOptions( + height: 270.h, + viewportFraction: 1.0, + autoPlay: true, + autoPlayInterval: const Duration(seconds: 5), + autoPlayAnimationDuration: const Duration(milliseconds: 600), + autoPlayCurve: Curves.easeInOutCubic, + onPageChanged: (i, _) => setState(() => _statIndex = i), + ), + ), + SizedBox(height: 12.h), + AnimatedSmoothIndicator( + activeIndex: _statIndex, + count: panels.length, + effect: ExpandingDotsEffect( + activeDotColor: tones.brand.deep, + dotColor: tones.borderLight, + dotWidth: 7.w, + dotHeight: 7.w, + expansionFactor: 3, + spacing: 5.w, + ), + ), + if (spotlights.isNotEmpty) ...[ + SizedBox(height: 14.h), + _spotlightTicker(tones, spotlights), + ], + ], + ); + }, + ); + } + + List<({String text, String prompt})> _buildSpotlights({ + required double savingsRate, + required double expense, + required double net, + required int txCount, + required double avgExpense, + required String Function(double) money, + }) { + return <({String text, String prompt})>[ + if (savingsRate > 0) + ( + text: LocaleKeys.aiSpotlightSaved + .tr(namedArgs: {'rate': savingsRate.toStringAsFixed(0)}), + prompt: LocaleKeys.aiSuggestIncomeVsExpense.tr(), + ), + if (expense > 0) + ( + text: LocaleKeys.aiSpotlightSpent + .tr(namedArgs: {'amount': money(expense)}), + prompt: LocaleKeys.aiSuggestSpendMonth.tr(), + ), + if (net > 0) + ( + text: LocaleKeys.aiSpotlightNetPositive + .tr(namedArgs: {'amount': money(net)}), + prompt: LocaleKeys.aiSuggestIncomeVsExpense.tr(), + ) + else if (net < 0) + ( + text: LocaleKeys.aiSpotlightNetNegative + .tr(namedArgs: {'amount': money(net.abs())}), + prompt: LocaleKeys.aiSuggestIncomeVsExpense.tr(), + ), + if (txCount > 0) + ( + text: LocaleKeys.aiSpotlightTransactions + .tr(namedArgs: {'count': '$txCount'}), + prompt: LocaleKeys.aiSuggestSpendMonth.tr(), + ), + if (avgExpense > 0) + ( + text: LocaleKeys.aiSpotlightAvgExpense + .tr(namedArgs: {'amount': money(avgExpense)}), + prompt: LocaleKeys.aiSuggestTopCategory.tr(), + ), + ]; + } + + Widget _spotlightTicker( + AppTones tones, + List<({String text, String prompt})> items, + ) { + final current = items[_spotlightIndex % items.length]; + return GestureDetector( + onTap: () => widget.onPick(current.prompt), + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 11.h), + decoration: BoxDecoration( + color: tones.brand.deep.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + Icon(Icons.lightbulb_outline_rounded, + size: 16.sp, color: tones.brand.deep), + SizedBox(width: 10.w), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + transitionBuilder: (child, anim) => + FadeTransition(opacity: anim, child: child), + child: Text( + current.text, + key: ValueKey(_spotlightIndex % items.length), + style: TextStyle( + color: tones.textSecondary, + fontSize: 13.sp, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _statPanel(AppTones tones, {required Widget child}) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(16.r), + border: Border.all(color: tones.borderLight), + ), + child: child, + ); + } + + Widget _statHeader(AppTones tones) { + return Row( + children: [ + Icon(Icons.insights_rounded, size: 16.sp, color: tones.brand.deep), + SizedBox(width: 8.w), + Text( + LocaleKeys.thisMonth.tr(), + style: TextStyle( + color: tones.textPrimary, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } + + Widget _bigStat(AppTones tones, String label, String value, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: tones.textMuted, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 28.sp, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/ai_chat/widgets/message_bubble.dart b/lib/presentation/ai_chat/widgets/message_bubble.dart new file mode 100644 index 00000000..56c91eb1 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/message_bubble.dart @@ -0,0 +1,364 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_message_dto.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/ai_chat/cubit/ai_chat_cubit.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_block_widget.dart'; +import 'package:trakli/presentation/ai_chat/widgets/blocks/chat_legacy_result.dart'; +import 'package:trakli/presentation/ai_chat/widgets/proposed_action_card.dart'; +import 'package:trakli/presentation/auth/cubits/auth/auth_cubit.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; + +class MessageRow extends StatelessWidget { + final ChatMessageDto message; + const MessageRow({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + if (message.isUser) { + return Padding( + padding: EdgeInsets.only(bottom: 12.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible(child: _Bubble(message: message)), + SizedBox(width: 8.w), + const _UserAvatar(), + ], + ), + ); + } + + final blocks = message.blocks; + final legacy = message.legacyResult; + final hasContent = message.displayText.trim().isNotEmpty; + + final showContentBubble = message.isInFlight || + message.isFailed || + message.isServiceUnavailable || + (hasContent && blocks.isEmpty); + + const structured = ['table', 'list', 'pair_list', 'record']; + final legacyToShow = (blocks.isEmpty && + legacy != null && + (!hasContent || structured.contains(legacy.formatType))) + ? legacy + : null; + + final showBubble = + showContentBubble || (blocks.isEmpty && legacyToShow == null); + + return Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _AssistantAvatar(), + if (showBubble) ...[ + SizedBox(height: 8.h), + _Bubble(message: message), + ], + for (final block in blocks) + if (block is ProposedActionBlock) + Padding( + padding: EdgeInsets.only(top: 8.h), + child: ProposedActionCard( + key: ValueKey(block.id), + block: block, + ), + ) + else + ChatBlockWidget(block: block), + if (legacyToShow != null) ChatLegacyResult(result: legacyToShow), + ], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + final ChatMessageDto message; + const _Bubble({required this.message}); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final isUser = message.isUser; + final inFlight = message.isInFlight; + final failed = message.isFailed; + + final radius = BorderRadius.only( + topLeft: Radius.circular(isUser ? 18.r : 6.r), + topRight: Radius.circular(isUser ? 6.r : 18.r), + bottomLeft: Radius.circular(18.r), + bottomRight: Radius.circular(18.r), + ); + + if (isUser) { + return GestureDetector( + onLongPress: () => _copy(context, message.displayText), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [tones.brand.accent, tones.brand.deep], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: radius, + ), + child: Text( + message.displayText, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + height: 1.4, + ), + ), + ), + ); + } + + final bg = failed ? tones.expense.background : tones.bgCard; + final fg = failed ? tones.expense.deep : tones.textPrimary; + final isError = failed || message.isServiceUnavailable; + + final String errorText = failed + ? (message.error ?? LocaleKeys.aiChatError.tr()) + : LocaleKeys.aiServiceUnavailable.tr(); + final copyText = inFlight + ? '' + : isError + ? errorText + : message.displayText; + + final Widget body; + if (inFlight) { + body = Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: _ThinkingDots(color: tones.brand.deep.withAlpha(180)), + ); + } else if (isError) { + body = Text( + errorText, + style: TextStyle(color: fg, fontSize: 14.sp, height: 1.45), + ); + } else { + body = GptMarkdown( + message.displayText, + style: TextStyle(color: fg, fontSize: 14.sp, height: 1.45), + ); + } + + return GestureDetector( + onLongPress: () => _copy(context, copyText), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + decoration: BoxDecoration( + color: bg, + borderRadius: radius, + border: Border.all( + color: + failed ? tones.expense.accent.withAlpha(60) : tones.borderLight, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(8), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + body, + if (isError) ...[ + SizedBox(height: 6.h), + Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () => context.read().retry(message.id), + borderRadius: BorderRadius.circular(8.r), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.refresh_rounded, + size: 14.sp, color: tones.brand.deep), + SizedBox(width: 4.w), + Text( + LocaleKeys.retry.tr(), + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: tones.brand.deep, + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + void _copy(BuildContext context, String text) { + if (text.trim().isEmpty) return; + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocaleKeys.copiedToClipboard.tr()), + duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + ), + ); + } +} + +class _ThinkingDots extends StatefulWidget { + final Color color; + const _ThinkingDots({required this.color}); + + @override + State<_ThinkingDots> createState() => _ThinkingDotsState(); +} + +class _ThinkingDotsState extends State<_ThinkingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _ctl; + + @override + void initState() { + super.initState(); + _ctl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctl, + builder: (context, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final t = (_ctl.value + i * 0.2) % 1.0; + final scale = 0.6 + 0.4 * (1 - (t - 0.5).abs() * 2).clamp(0.0, 1.0); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 2.w), + child: Transform.scale( + scale: scale, + child: Container( + width: 7.r, + height: 7.r, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + }), + ); + }, + ); + } +} + +class _AssistantAvatar extends StatelessWidget { + const _AssistantAvatar(); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + return Container( + width: 32.r, + height: 32.r, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [tones.brandSoft.background, tones.brand.background], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + border: Border.all(color: tones.brand.deep.withAlpha(30), width: 1), + ), + alignment: Alignment.center, + child: Icon( + Icons.smart_toy_rounded, + color: tones.brand.deep, + size: 18.sp, + ), + ); + } +} + +class _UserAvatar extends StatelessWidget { + const _UserAvatar(); + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final user = context.read().state.user; + final initials = _initialsFor( + first: user?.firstName, + last: user?.lastName, + email: user?.email, + ); + + return Container( + width: 32.r, + height: 32.r, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [tones.brand.accent, tones.brand.deep], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + initials, + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ); + } +} + +String _initialsFor({String? first, String? last, String? email}) { + String pick(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim()[0].toUpperCase() : ''; + final a = pick(first); + final b = pick(last); + if (a.isNotEmpty || b.isNotEmpty) return '$a$b'; + final e = pick(email); + return e.isNotEmpty ? e : '·'; +} diff --git a/lib/presentation/ai_chat/widgets/proposed_action_card.dart b/lib/presentation/ai_chat/widgets/proposed_action_card.dart new file mode 100644 index 00000000..0e38d575 --- /dev/null +++ b/lib/presentation/ai_chat/widgets/proposed_action_card.dart @@ -0,0 +1,513 @@ +import 'dart:async'; + +import 'package:currency_picker/currency_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:trakli/data/datasources/ai/dto/chat_blocks_dto.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; +import 'package:trakli/presentation/ai_chat/cubit/ai_chat_cubit.dart'; +import 'package:trakli/presentation/category/cubit/category_cubit.dart'; +import 'package:trakli/presentation/parties/cubit/party_cubit.dart'; +import 'package:trakli/presentation/utils/design_tokens.dart'; +import 'package:trakli/presentation/utils/enums.dart'; +import 'package:trakli/presentation/wallets/cubit/wallet_cubit.dart'; + +class ProposedActionCard extends StatefulWidget { + final ProposedActionBlock block; + const ProposedActionCard({super.key, required this.block}); + + @override + State createState() => ProposedActionCardState(); +} + +class ProposedActionCardState extends State { + final Map _edited = {}; + bool _busy = false; + + @override + void initState() { + super.initState(); + for (final f in widget.block.fields) { + _edited[f.key] = f is CategoriesActionField ? f.initialIds : f.value; + } + } + + String _two(int n) => n.toString().padLeft(2, '0'); + String _cap(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; + String _isoLocal(DateTime d) => + '${d.year}-${_two(d.month)}-${_two(d.day)}T${_two(d.hour)}:${_two(d.minute)}'; + String _fmt(DateTime d) => + '${_two(d.day)}/${_two(d.month)}/${d.year} ${_two(d.hour)}:${_two(d.minute)}'; + + Map _overrides() { + final o = {}; + for (final f in widget.block.fields) { + final v = _edited[f.key]; + if (v == null) continue; + if (v is String && v.trim().isEmpty) continue; + if (v is List && v.isEmpty) continue; + o[f.key] = v; + } + return o; + } + + Future _confirm() async { + setState(() => _busy = true); + await context.read().confirmAction( + actionId: widget.block.id, + overrides: _overrides(), + ); + if (mounted) setState(() => _busy = false); + } + + Future _reject() async { + setState(() => _busy = true); + await context.read().rejectAction(actionId: widget.block.id); + if (mounted) setState(() => _busy = false); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final block = widget.block; + final pending = block.isPending; + final accent = block.risk == 'high' ? tones.accentWarm : tones.brand.deep; + + return Container( + padding: EdgeInsets.all(14.r), + decoration: BoxDecoration( + color: tones.bgCard, + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: tones.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + block.risk == 'high' + ? Icons.warning_amber_rounded + : Icons.shopping_cart_outlined, + size: 16.sp, + color: accent, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + block.summary, + style: TextStyle( + color: tones.textPrimary, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + SizedBox(height: 12.h), + if (pending) + for (final f in block.fields) + Padding( + padding: EdgeInsets.only(bottom: 10.h), + child: _field(tones, f), + ) + else + for (final f in block.fields) _readonlyField(tones, f), + SizedBox(height: 4.h), + if (pending) + Row( + children: [ + Expanded( + child: _actionButton( + tones, + label: LocaleKeys.confirm.tr(), + filled: true, + busy: _busy, + onTap: _busy ? null : _confirm, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: _actionButton( + tones, + label: LocaleKeys.dismiss.tr(), + filled: false, + busy: false, + onTap: _busy ? null : _reject, + ), + ), + ], + ) + else + Row( + children: [ + Icon( + block.isExecuted + ? Icons.check_circle_rounded + : Icons.cancel_rounded, + size: 15.sp, + color: block.isExecuted ? tones.income.deep : tones.textMuted, + ), + SizedBox(width: 6.w), + Text( + block.isExecuted + ? LocaleKeys.done.tr() + : LocaleKeys.dismissed.tr(), + style: TextStyle( + color: + block.isExecuted ? tones.income.deep : tones.textMuted, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _field(AppTones tones, ActionField f) { + if (f is CategoriesActionField) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(f.label, + style: TextStyle(color: tones.textMuted, fontSize: 12.sp)), + SizedBox(height: 6.h), + _categoryChips(tones, f), + ], + ); + } + return Row( + children: [ + Expanded( + flex: 2, + child: Text(f.label, + style: TextStyle(color: tones.textMuted, fontSize: 12.sp)), + ), + SizedBox(width: 8.w), + Expanded(flex: 3, child: _input(tones, f)), + ], + ); + } + + /// Searchable currency picker (reuses the app's currency_picker). + Widget _currencyInput(AppTones tones, CurrencyActionField f) { + final code = (_edited[f.key]?.toString() ?? '').toUpperCase(); + return InkWell( + borderRadius: BorderRadius.circular(8.r), + onTap: () => showCurrencyPicker( + context: context, + theme: CurrencyPickerThemeData( + bottomSheetHeight: 0.7.sh, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + flagSize: 24.sp, + ), + onSelect: (Currency c) => setState(() => _edited[f.key] = c.code), + ), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 9.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: tones.borderLight), + ), + child: Row( + children: [ + Expanded( + child: Text( + code.isEmpty ? '—' : code, + style: TextStyle(fontSize: 12.sp, color: tones.textPrimary), + ), + ), + Icon(Icons.expand_more_rounded, + size: 16.sp, color: tones.textMuted), + ], + ), + ), + ); + } + + /// Wallet type (bank / cash / credit_card / mobile) as a dropdown. + Widget _walletTypeInput(AppTones tones, WalletTypeActionField f) { + final current = _edited[f.key]?.toString(); + return _dropdown( + value: + WalletType.values.any((w) => w.serverKey == current) ? current : null, + items: WalletType.values + .map((w) => DropdownMenuItem( + value: w.serverKey, + child: Text(w.customName, style: TextStyle(fontSize: 12.sp)), + )) + .toList(), + onChanged: (v) => setState(() => _edited[f.key] = v), + ); + } + + Widget _input(AppTones tones, ActionField f) => switch (f) { + final CurrencyActionField x => _currencyInput(tones, x), + final WalletTypeActionField x => _walletTypeInput(tones, x), + final EnumActionField x => _enumDropdown(tones, x), + final WalletRefActionField x => _walletDropdown(tones, x), + final PartyRefActionField x => _partyDropdown(tones, x), + final NumberActionField x => _numberInput(tones, x), + final DateTimeActionField x => _datetimeField(tones, x), + final CategoriesActionField x => _categoryChips(tones, x), + final TextActionField x => _textInput(tones, x), + }; + + Widget _enumDropdown(AppTones tones, EnumActionField f) => _dropdown( + value: _edited[f.key]?.toString(), + items: f.options + .map((o) => DropdownMenuItem( + value: o, + child: Text(_cap(o), style: TextStyle(fontSize: 12.sp)), + )) + .toList(), + onChanged: (v) => setState(() => _edited[f.key] = v), + ); + + Widget _walletDropdown(AppTones tones, WalletRefActionField f) { + final wallets = + context.watch().state.wallets.where((w) => w.id != null); + return _dropdown( + value: _edited[f.key] is num ? (_edited[f.key] as num).toInt() : null, + items: wallets + .map((w) => DropdownMenuItem( + value: w.id!, + child: Text(w.name, + style: TextStyle(fontSize: 12.sp), + overflow: TextOverflow.ellipsis), + )) + .toList(), + onChanged: (v) => setState(() => _edited[f.key] = v), + ); + } + + Widget _partyDropdown(AppTones tones, PartyRefActionField f) { + final parties = + context.watch().state.parties.where((p) => p.id != null); + return _dropdown( + value: _edited[f.key] is num ? (_edited[f.key] as num).toInt() : null, + items: [ + DropdownMenuItem( + value: null, + child: Text(LocaleKeys.none.tr(), style: TextStyle(fontSize: 12.sp)), + ), + ...parties.map((p) => DropdownMenuItem( + value: p.id!, + child: Text(p.name, + style: TextStyle(fontSize: 12.sp), + overflow: TextOverflow.ellipsis), + )), + ], + onChanged: (v) => setState(() => _edited[f.key] = v), + ); + } + + Widget _numberInput(AppTones tones, NumberActionField f) => TextFormField( + initialValue: _edited[f.key]?.toString() ?? '', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: TextStyle(fontSize: 12.sp, color: tones.textPrimary), + decoration: _inputDecoration(tones), + onChanged: (v) => _edited[f.key] = num.tryParse(v) ?? v, + ); + + Widget _textInput(AppTones tones, TextActionField f) => TextFormField( + initialValue: _edited[f.key]?.toString() ?? '', + style: TextStyle(fontSize: 12.sp, color: tones.textPrimary), + decoration: _inputDecoration(tones), + onChanged: (v) => _edited[f.key] = v, + ); + + Widget _categoryChips(AppTones tones, CategoriesActionField f) { + final selected = (_edited[f.key] as List?)?.cast() ?? []; + final categories = context + .watch() + .state + .categories + .where((c) => c.id != null) + .toList(); + return Wrap( + spacing: 6.w, + runSpacing: 6.h, + children: categories.map((c) { + final isSel = selected.contains(c.id); + return FilterChip( + label: Text(c.name, style: TextStyle(fontSize: 11.sp)), + selected: isSel, + onSelected: (v) { + setState(() { + final list = List.from(selected); + if (v) { + list.add(c.id!); + } else { + list.remove(c.id); + } + _edited[f.key] = list; + }); + }, + ); + }).toList(), + ); + } + + Widget _datetimeField(AppTones tones, DateTimeActionField f) { + final raw = _edited[f.key]?.toString() ?? ''; + final dt = DateTime.tryParse(raw); + return InkWell( + onTap: () async { + final now = DateTime.now(); + final base = dt ?? now; + final date = await showDatePicker( + context: context, + initialDate: base, + firstDate: DateTime(2000), + lastDate: DateTime(now.year + 1), + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(base), + ); + final picked = DateTime(date.year, date.month, date.day, + time?.hour ?? base.hour, time?.minute ?? base.minute); + setState(() => _edited[f.key] = _isoLocal(picked)); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: tones.borderLight), + ), + child: Row( + children: [ + Expanded( + child: Text( + dt != null ? _fmt(dt) : '—', + style: TextStyle(fontSize: 12.sp, color: tones.textPrimary), + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.calendar_today_outlined, + size: 14.sp, color: tones.textMuted), + ], + ), + ), + ); + } + + Widget _dropdown({ + required T? value, + required List> items, + required ValueChanged onChanged, + }) { + final tones = context.tones; + final safeValue = items.any((it) => it.value == value) ? value : null; + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: tones.borderLight), + ), + child: DropdownButton( + value: safeValue, + isExpanded: true, + isDense: true, + underline: const SizedBox.shrink(), + style: TextStyle(fontSize: 12.sp, color: tones.textPrimary), + items: items, + onChanged: onChanged, + ), + ); + } + + InputDecoration _inputDecoration(AppTones tones) => InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide(color: tones.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide(color: tones.borderLight), + ), + ); + + Widget _readonlyField(AppTones tones, ActionField f) { + final display = (f.display != null && f.display!.isNotEmpty) + ? f.display! + : '${f.value ?? ''}'; + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text(f.label, + style: TextStyle(color: tones.textMuted, fontSize: 12.sp)), + ), + SizedBox(width: 8.w), + Expanded( + flex: 3, + child: Text( + display, + textAlign: TextAlign.right, + style: TextStyle( + color: tones.textPrimary, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _actionButton( + AppTones tones, { + required String label, + required bool filled, + required bool busy, + required VoidCallback? onTap, + }) { + final fg = filled ? Colors.white : tones.textPrimary; + return Material( + color: filled ? tones.brand.deep : Colors.transparent, + borderRadius: BorderRadius.circular(10.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10.r), + child: Container( + height: 38.h, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.r), + border: filled ? null : Border.all(color: tones.borderLight), + ), + child: busy + ? SizedBox( + width: 16.r, + height: 16.r, + child: CircularProgressIndicator(strokeWidth: 2, color: fg), + ) + : Text( + label, + style: TextStyle( + color: fg, + fontSize: 13.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/budget/budget_screen.dart b/lib/presentation/budget/budget_screen.dart index a5206a5d..2c26709d 100644 --- a/lib/presentation/budget/budget_screen.dart +++ b/lib/presentation/budget/budget_screen.dart @@ -41,6 +41,7 @@ class _BudgetScreenState extends State { backgroundColor: tones.bgPage, appBar: PageAppBar( title: LocaleKeys.budgetPageTitle.tr(), + showBack: false, onSearchChanged: (v) => setState(() => _query = v), searchHint: LocaleKeys.searchBudgets.tr(), actions: [ diff --git a/lib/presentation/imports/document_scan_screen.dart b/lib/presentation/imports/document_scan_screen.dart index 6fc48c1c..b16199c9 100644 --- a/lib/presentation/imports/document_scan_screen.dart +++ b/lib/presentation/imports/document_scan_screen.dart @@ -15,7 +15,11 @@ import 'package:trakli/presentation/utils/colors.dart'; import 'package:trakli/presentation/utils/helpers.dart'; class DocumentScanScreen extends StatefulWidget { - const DocumentScanScreen({super.key}); + /// Optional file to pre-select (e.g. when launched from the AI composer + /// after the user already picked a document). + final File? initialFile; + + const DocumentScanScreen({super.key, this.initialFile}); @override State createState() => _DocumentScanScreenState(); @@ -25,6 +29,12 @@ class _DocumentScanScreenState extends State { File? _picked; DocumentType _type = DocumentType.receipt; + @override + void initState() { + super.initState(); + _picked = widget.initialFile; + } + Future _setFile(File? file) async { if (file == null) return; setState(() => _picked = file); @@ -67,106 +77,106 @@ class _DocumentScanScreenState extends State { body: SafeArea( child: BlocBuilder( builder: (context, state) { - return SingleChildScrollView( - padding: EdgeInsets.all(16.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - LocaleKeys.importScanDocumentDesc.tr(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - SizedBox(height: 20.h), - Text( - LocaleKeys.importDocTypeLabel.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - SizedBox(height: 8.h), - DropdownButtonFormField( - initialValue: _type, - decoration: const InputDecoration( - isDense: true, + return SingleChildScrollView( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + LocaleKeys.importScanDocumentDesc.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - items: DocumentType.values - .map((t) => DropdownMenuItem( - value: t, - child: Text(_labelFor(t)), - )) - .toList(), - onChanged: state.isUploading - ? null - : (v) { - if (v != null) setState(() => _type = v); - }, - ), - SizedBox(height: 20.h), - Text( - LocaleKeys.importSourceLabel.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - SizedBox(height: 8.h), - ImportSourceButton( - icon: Icons.photo_camera_outlined, - label: LocaleKeys.importSourceCamera.tr(), - subtitle: LocaleKeys.importSourceCameraDesc.tr(), - onTap: state.isUploading - ? null - : () async => - _setFile(await ImportFilePicker.captureFromCamera()), - ), - SizedBox(height: 8.h), - ImportSourceButton( - icon: Icons.photo_library_outlined, - label: LocaleKeys.importSourceGallery.tr(), - subtitle: LocaleKeys.importSourceGalleryDesc.tr(), - onTap: state.isUploading - ? null - : () async => - _setFile(await ImportFilePicker.pickFromGallery()), - ), - SizedBox(height: 8.h), - ImportSourceButton( - icon: Icons.folder_outlined, - label: LocaleKeys.importSourceFile.tr(), - subtitle: LocaleKeys.importSourceFileDesc.tr(), - onTap: state.isUploading - ? null - : () async => - _setFile(await ImportFilePicker.pickDocument()), - ), - if (_picked != null) ...[ - SizedBox(height: 12.h), - ImportFilePreview( - fileName: ImportFilePicker.displayName(_picked!.path), - onClear: state.isUploading + SizedBox(height: 20.h), + Text( + LocaleKeys.importDocTypeLabel.tr(), + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 8.h), + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration( + isDense: true, + ), + items: DocumentType.values + .map((t) => DropdownMenuItem( + value: t, + child: Text(_labelFor(t)), + )) + .toList(), + onChanged: state.isUploading ? null - : () => setState(() => _picked = null), + : (v) { + if (v != null) setState(() => _type = v); + }, ), - ], - SizedBox(height: 24.h), - SizedBox( - height: 52.h, - width: double.infinity, - child: ElevatedButton( - onPressed: (_picked == null || state.isUploading) + SizedBox(height: 20.h), + Text( + LocaleKeys.importSourceLabel.tr(), + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.photo_camera_outlined, + label: LocaleKeys.importSourceCamera.tr(), + subtitle: LocaleKeys.importSourceCameraDesc.tr(), + onTap: state.isUploading ? null - : _analyze, - child: state.isUploading - ? SizedBox( - width: 20.w, - height: 20.h, - child: - const CircularProgressIndicator(strokeWidth: 2), - ) - : Text(LocaleKeys.importAnalyze.tr()), + : () async => _setFile( + await ImportFilePicker.captureFromCamera()), ), - ), - ], - ), - ); - }, + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.photo_library_outlined, + label: LocaleKeys.importSourceGallery.tr(), + subtitle: LocaleKeys.importSourceGalleryDesc.tr(), + onTap: state.isUploading + ? null + : () async => + _setFile(await ImportFilePicker.pickFromGallery()), + ), + SizedBox(height: 8.h), + ImportSourceButton( + icon: Icons.folder_outlined, + label: LocaleKeys.importSourceFile.tr(), + subtitle: LocaleKeys.importSourceFileDesc.tr(), + onTap: state.isUploading + ? null + : () async => + _setFile(await ImportFilePicker.pickDocument()), + ), + if (_picked != null) ...[ + SizedBox(height: 12.h), + ImportFilePreview( + fileName: ImportFilePicker.displayName(_picked!.path), + onClear: state.isUploading + ? null + : () => setState(() => _picked = null), + ), + ], + SizedBox(height: 24.h), + SizedBox( + height: 52.h, + width: double.infinity, + child: ElevatedButton( + onPressed: (_picked == null || state.isUploading) + ? null + : _analyze, + child: state.isUploading + ? SizedBox( + width: 20.w, + height: 20.h, + child: const CircularProgressIndicator( + strokeWidth: 2), + ) + : Text(LocaleKeys.importAnalyze.tr()), + ), + ), + ], + ), + ); + }, ), ), ); diff --git a/lib/presentation/imports/suggestion_review_screen.dart b/lib/presentation/imports/suggestion_review_screen.dart index 2e717117..1073c175 100644 --- a/lib/presentation/imports/suggestion_review_screen.dart +++ b/lib/presentation/imports/suggestion_review_screen.dart @@ -232,21 +232,22 @@ class _SuggestionReviewScreenState extends State { }, ), ), - bottomNavigationBar: SafeArea( - child: Padding( - padding: EdgeInsets.all(12.w), - child: BlocBuilder( - builder: (context, state) { - final session = state.currentSession; - final canConfirm = session != null && - session.status == ImportSessionStatus.ready && - !state.isConfirming; - return SizedBox( + bottomNavigationBar: BlocBuilder( + builder: (context, state) { + final session = state.currentSession; + final showConfirm = session != null && + session.status == ImportSessionStatus.ready && + session.suggestions.isNotEmpty; + if (!showConfirm) return const SizedBox.shrink(); + return SafeArea( + child: Padding( + padding: EdgeInsets.all(12.w), + child: SizedBox( height: 52.h, width: double.infinity, child: ElevatedButton( onPressed: - canConfirm ? () => _onConfirmTap(session) : null, + state.isConfirming ? null : () => _onConfirmTap(session), child: state.isConfirming ? const SizedBox( width: 20, @@ -262,10 +263,10 @@ class _SuggestionReviewScreenState extends State { ], ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ); } @@ -285,15 +286,7 @@ class _SuggestionReviewScreenState extends State { // Empty suggestions take priority over the failed-status banner. if (session.suggestions.isEmpty) { - return Center( - child: Padding( - padding: EdgeInsets.all(24.w), - child: Text( - LocaleKeys.importNoSuggestions.tr(), - textAlign: TextAlign.center, - ), - ), - ); + return const _NoSuggestions(); } if (session.status == ImportSessionStatus.failed) { @@ -427,6 +420,52 @@ class _AnalysisFailed extends StatelessWidget { } } +class _NoSuggestions extends StatelessWidget { + const _NoSuggestions(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: EdgeInsets.all(32.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 96.r, + height: 96.r, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.surfaceContainerHighest, + ), + child: Icon( + Icons.search_off_rounded, + size: 48.r, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + SizedBox(height: 20.h), + Text( + LocaleKeys.importNoSuggestions.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24.h), + ElevatedButton.icon( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + label: Text(LocaleKeys.cancel.tr()), + ), + ], + ), + ), + ); + } +} + class _ConfirmSheet extends StatefulWidget { final int acceptedCount; final int totalCount; diff --git a/lib/presentation/imports/widgets/import_file_picker.dart b/lib/presentation/imports/widgets/import_file_picker.dart index b524d4aa..57684c67 100644 --- a/lib/presentation/imports/widgets/import_file_picker.dart +++ b/lib/presentation/imports/widgets/import_file_picker.dart @@ -8,6 +8,10 @@ import 'package:path_provider/path_provider.dart'; class ImportFilePicker { ImportFilePicker._(); + /// Downscale and recompress images so they stay under the upload size limit. + static const double _maxImageDimension = 2000; + static const int _imageQuality = 85; + static Future pickSpreadsheet() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -31,13 +35,23 @@ class ImportFilePicker { } static Future captureFromCamera() async { - final picked = await ImagePicker().pickImage(source: ImageSource.camera); + final picked = await ImagePicker().pickImage( + source: ImageSource.camera, + maxWidth: _maxImageDimension, + maxHeight: _maxImageDimension, + imageQuality: _imageQuality, + ); if (picked == null) return null; return _stableCopy(File(picked.path)); } static Future pickFromGallery() async { - final picked = await ImagePicker().pickImage(source: ImageSource.gallery); + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: _maxImageDimension, + maxHeight: _maxImageDimension, + imageQuality: _imageQuality, + ); if (picked == null) return null; return _stableCopy(File(picked.path)); } diff --git a/lib/presentation/root/main_navigation_screen.dart b/lib/presentation/root/main_navigation_screen.dart index 6e432c68..b683b31e 100644 --- a/lib/presentation/root/main_navigation_screen.dart +++ b/lib/presentation/root/main_navigation_screen.dart @@ -1,15 +1,18 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:heroicons/heroicons.dart'; import 'package:trakli/presentation/utils/design_tokens.dart'; import 'package:trakli/core/sync/sync_database.dart'; import 'package:trakli/di/injection.dart'; import 'package:trakli/gen/assets.gen.dart'; import 'package:trakli/gen/translations/codegen_loader.g.dart'; import 'package:trakli/presentation/add_transaction_screen.dart'; +import 'package:trakli/presentation/ai_chat/ai_chat_screen.dart'; import 'package:trakli/presentation/auth/cubits/auth/auth_cubit.dart'; import 'package:trakli/presentation/root/bloc/main_navigation_page_cubit.dart'; import 'package:trakli/presentation/utils/app_navigator.dart'; @@ -43,35 +46,41 @@ class _MainNavigationScreenState extends State { child: BlocBuilder( builder: (context, state) { final cubit = context.read(); - final isAiTab = state == MainNavigationPageState.other; return Scaffold( key: scaffoldKey, - resizeToAvoidBottomInset: isAiTab, - extendBody: !isAiTab, + resizeToAvoidBottomInset: false, + extendBody: true, drawer: Drawer( shape: const RoundedRectangleBorder(), width: 0.8.sw, child: const CustomDrawer(), ), - body: RefreshIndicator( - displacement: 20.0, - onRefresh: () async { - final isAuthenticated = - context.read().state.isAuthenticated; - - if (isAuthenticated) { - getIt().sync(); - } - }, - child: screens.elementAt( - MainNavigationPageState.values.indexOf(state), - )), + body: Stack( + children: [ + RefreshIndicator( + displacement: 20.0, + onRefresh: () async { + final isAuthenticated = + context.read().state.isAuthenticated; + + if (isAuthenticated) { + getIt().sync(); + } + }, + child: screens.elementAt( + MainNavigationPageState.values.indexOf(state), + ), + ), + const _AiAssistantFab(), + ], + ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: SizedBox( - height: isAiTab ? 44.r : 64.r, - width: isAiTab ? 44.r : 64.r, + height: 44.r, + width: 44.r, child: FloatingActionButton( + heroTag: 'addFab', shape: const CircleBorder(), backgroundColor: Theme.of(context).primaryColor, onPressed: () { @@ -109,14 +118,12 @@ class _MainNavigationScreenState extends State { text: LocaleKeys.wallet.tr(), ), FABBottomAppBarItem( - iconBuilder: (color) => HeroIcon( - HeroIcons.sparkles, - style: HeroIconStyle.outline, + iconBuilder: (color) => Icon( + Icons.savings_outlined, color: color, size: 24, ), - text: LocaleKeys.ai.tr(), - selectedIndicatorPath: Assets.images.sparkles, + text: LocaleKeys.drawerBudgets.tr(), ), ], backgroundColor: Theme.of(context).colorScheme.surface, @@ -129,3 +136,116 @@ class _MainNavigationScreenState extends State { ); } } + +enum _FabAnim { pulse, spin } + +class _AiAssistantFab extends StatefulWidget { + const _AiAssistantFab(); + + @override + State<_AiAssistantFab> createState() => _AiAssistantFabState(); +} + +class _AiAssistantFabState extends State<_AiAssistantFab> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + late final Animation _rotation; + final Random _rng = Random(); + Timer? _timer; + _FabAnim _mode = _FabAnim.pulse; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _scale = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 1.14) + .chain(CurveTween(curve: Curves.easeOut)), + weight: 1, + ), + TweenSequenceItem( + tween: Tween(begin: 1.14, end: 1.0) + .chain(CurveTween(curve: Curves.easeIn)), + weight: 1, + ), + ]).animate(_controller); + _rotation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _startAttentionLoop(); + } + + void _startAttentionLoop() { + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted) return; + _runBurst(); + _timer = Timer.periodic(const Duration(seconds: 20), (_) => _runBurst()); + }); + } + + Future _runBurst() async { + if (!mounted) return; + final mode = _rng.nextBool() ? _FabAnim.pulse : _FabAnim.spin; + setState(() => _mode = mode); + final reps = mode == _FabAnim.pulse ? 2 : 1; + for (var i = 0; i < reps && mounted; i++) { + await _controller.forward(from: 0); + } + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tones = context.tones; + final scaleAnim = _mode == _FabAnim.pulse + ? _scale + : const AlwaysStoppedAnimation(1.0); + final turnsAnim = _mode == _FabAnim.spin + ? _rotation + : const AlwaysStoppedAnimation(0.0); + return Positioned( + right: 16.w, + bottom: 150.h, + child: ScaleTransition( + scale: scaleAnim, + child: Container( + height: 56.r, + width: 56.r, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: tones.accentWarm, + ), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => AppNavigator.push(context, const AiChatScreen()), + child: Center( + child: RotationTransition( + turns: turnsAnim, + child: Icon( + Icons.smart_toy_rounded, + color: Colors.white, + size: 26.r, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/utils/enums.dart b/lib/presentation/utils/enums.dart index d3046432..5eee17be 100644 --- a/lib/presentation/utils/enums.dart +++ b/lib/presentation/utils/enums.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:trakli/gen/translations/codegen_loader.g.dart'; -import 'package:trakli/presentation/ai_chat/ai_chat_screen.dart'; +import 'package:trakli/presentation/budget/budget_screen.dart'; import 'package:trakli/presentation/home_screen.dart'; import 'package:trakli/presentation/statistics/statistics_screen.dart'; import 'package:trakli/presentation/wallets/wallet_screen.dart'; @@ -83,7 +83,7 @@ enum NavigationScreen { home, statistics, wallet, - aiChat; + budget; Widget get screen { switch (this) { @@ -93,8 +93,8 @@ enum NavigationScreen { return const StatisticsScreen(); case NavigationScreen.wallet: return const WalletScreen(); - case NavigationScreen.aiChat: - return const AiChatScreen(); + case NavigationScreen.budget: + return const BudgetScreen(); } } } diff --git a/pubspec.lock b/pubspec.lock index 93295228..e7fd4ced 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -734,6 +734,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -944,6 +952,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + gpt_markdown: + dependency: "direct main" + description: + name: gpt_markdown + sha256: c14c2a4599a67df5b6a984808cbb7631b8d15c91984ffdd7998597b93a6ff136 + url: "https://pub.dev" + source: hosted + version: "1.1.7" graphs: dependency: transitive description: @@ -1276,10 +1292,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1909,6 +1925,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -2150,5 +2174,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index fc2d5ce1..4cef9e39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -106,6 +106,7 @@ dependencies: update_available: ^3.2.1 in_app_update: ^4.2.5 firebase_remote_config: ^5.5.0 + gpt_markdown: ^1.1.7 # Pinning these to the versions in pubspec.lock; the current Flutter SDK # pins newer matcher/test_api but other transitives (freezed, bloc_test) diff --git a/sample-ai-queries.md b/sample-ai-queries.md new file mode 100644 index 00000000..11eff35e --- /dev/null +++ b/sample-ai-queries.md @@ -0,0 +1,17 @@ +Renderer Try asking… +markdown (text) "Give me 3 tips to save money this month." / "Explain how budgets work in Trakli." +table "List my last 10 transactions in a table." / "Show all my wallets with their balances." +kpi "Give me my key stats for this month." / "Summarize my income, expenses and net this month." +chart "Show a pie chart of my spending by category this month." / "Plot my expenses over the last 6 months." +comparison "Compare my spending this month vs last month." / "Income vs expenses, this month and last month." +list "What are my biggest expenses this week?" / "List my recent transactions with amounts." +callout "Am I overspending this month?" / "Any warnings about my budgets?" +timeline "Show my recent transactions as a timeline." / "Walk me through my spending chronologically." +progress "How am I doing against my budgets?" / "Show my budget progress." +question (clarify) Say something ambiguous so the AI must ask back: "Add a transaction." / "Record an expense." (no amount) +quick_actions "What should I do next?" / "Help me get started." (often appended after a summary) +proposed_action (the form) "Record income of 100 on my main wallet." / "Log a $12 lunch expense yesterday." / "Create a $500 groceries budget." / "Transfer 50 from cash to bank." +import_review Not a text prompt — tap the composer +, attach a bank statement/receipt; the AI returns the import‑review card. +canvas "Generate a full monthly financial report." / "Create a detailed breakdown of my finances." (large multi‑block output) +A few tips: +