commit 16bff634b54c86f52dc3c4a57bfd1ad3d56428fa Author: alvocool Date: Mon Jul 27 09:11:17 2026 +0300 Initial commit: Grounded Flutter frontend A to-do app that doesn't believe you — an enforcement layer rather than a neutral ledger. Architecture ported from Autoreceptives/Frontend/Receptive: stacked MVVM with the mandatory 4-file screen pattern, one ParentViewModel owning the loading / network / error overlays and the handleError decision tree, one AppDataManager gateway, dio comms carrying the three identity headers, secure storage with random-suffixed keys, and a single-chokepoint Navigator. Package root and Dart package name are both Grounded; org is nya. The enforcement engine, one unit per formula in utils/: - DebtEngine w(class) x severity(d) x decay(t), sublinear severity so old misses cannot swamp the score; abandonment 2x with 30-day decay immunity; late complete retains 30% - StandingEngine Good -> Warned -> Grounded -> Lockdown, derived not set; Grounded replaces home with the overdue queue - CapacityEngine blocks over-scheduling against p50 of historically completed minutes, with a learned per-category estimation multiplier - IntegrityEngine session integrity, weekly volume, plyometric contact ceiling and enforced recovery gaps - ExcuseAnalyser on-device excuse clustering plus the confrontation copy - GuardrailEngine distress detection and rationed amnesty - ToneEngine all enforcement copy, so the tone cap lives in one place CommitmentEvent is append-only and is the source of truth rather than the status field, which is what makes honest history and excuse analysis possible. Goals contain commitments via parentId, and a task can be run from a full-screen runner that derives elapsed time from wall-clock so screen-off cannot lose time. Backgrounding pauses the clock and is counted. The runner is mirrored into an ongoing notification, with alarm-class full-screen intents reserved for non-negotiables. Design language, fonts, icon and native splash are in place; Mason bricks are retargeted to this project and verified end-to-end. flutter analyze lib/ reports no errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mfu2gQLSFN21YRBcU2NrTt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4141ca8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Normalise line endings so the repo stays clean across Windows and CI. +* text=auto eol=lf + +# Windows-only tooling keeps CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binaries — never touched, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.pdf binary +*.zip binary +*.jar binary +*.keystore binary +*.jks binary + +# Generated / vendored — collapse in diffs and exclude from language stats. +pubspec.lock linguist-generated=true -diff +**/ios/Runner.xcodeproj/project.pbxproj -diff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0549613 --- /dev/null +++ b/.gitignore @@ -0,0 +1,209 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Grounded — repository ignore rules +# ───────────────────────────────────────────────────────────────────────────── + +# ── Secrets & environment ──────────────────────────────────────────────────── +# .env carries the backend hosts and app version. Committing it leaks the +# deployment topology, so the template is tracked and the real file is not. +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.jks +*.keystore +*.mobileprovision +key.properties +secrets.properties +google-services.json +GoogleService-Info.plist +firebase_options.dart +service-account*.json +credentials.json +*.retrofit.dart.log + +# ── Dart / Flutter ─────────────────────────────────────────────────────────── +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +coverage/ +doc/api/ +**/doc/api/ +*.dart.js +*.info.json +*.js_ +*.js.deps +*.js.map +.flutter-versions +pubspec_overrides.yaml + +# Generated sources +*.g.dart +*.freezed.dart +*.mocks.dart +*.config.dart + +# Symbolication / obfuscation +app.*.symbols +app.*.map.json + +# ── Mason ──────────────────────────────────────────────────────────────────── +# Bricks themselves are tracked; the resolved cache is not. +.mason/ +**/hooks/.dart_tool/ +**/hooks/pubspec.lock + +# ── Android ────────────────────────────────────────────────────────────────── +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +**/android/app/release/ +**/android/app/debug/ +**/android/app/profile/ +*.apk +*.aab +*.ap_ +*.dex +.cxx/ + +# ── iOS ────────────────────────────────────────────────────────────────────── +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral/ +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/Flutter/.last_build_id +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* +*.ipa +*.dSYM.zip +*.dSYM + +# ── macOS ──────────────────────────────────────────────────────────────────── +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral/ +**/macos/Flutter/Flutter-Debug.xcconfig +**/macos/Flutter/Flutter-Release.xcconfig +**/macos/Flutter/Flutter-Profile.xcconfig +**/macos/Pods/ +**/xcuserdata/ +**/DerivedData/ + +# ── Windows ────────────────────────────────────────────────────────────────── +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake +**/windows/flutter/ephemeral/ +*.exe +*.msix +*.pdb +*.ilk + +# ── Linux ──────────────────────────────────────────────────────────────────── +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake +**/linux/flutter/ephemeral/ + +# ── Web ────────────────────────────────────────────────────────────────────── +**/web/*.g.dart +lib/generated_plugin_registrant.dart + +# ── IDEs & editors ─────────────────────────────────────────────────────────── +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +!.vscode/launch.json +!.vscode/settings.json +*.swp +*.swo +*~ +.history/ +.atom/ +.buildlog/ +.build/ +migrate_working_dir/ +.metals/ +.bloop/ + +# ── OS noise ───────────────────────────────────────────────────────────────── +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Thumbs.db:encryptable +desktop.ini +$RECYCLE.BIN/ +*.lnk + +# ── Logs, temp, caches ─────────────────────────────────────────────────────── +*.log +*.tmp +*.temp +*.bak +*.orig +*.rej +*.pyc +__pycache__/ +*.class +.cache/ +tmp/ +temp/ +node_modules/ + +# ── Archives & large binaries ──────────────────────────────────────────────── +*.zip +*.tar +*.tar.gz +*.tgz +*.rar +*.7z +*.iso +*.dmg + +# ── Working scratch ────────────────────────────────────────────────────────── +# Icon exploration output and other throwaways. +**/assets/icons/_*.png +**/assets/icons/try_*.png +scratch/ +scratchpad/ +*.local + +# ── Agent tooling ──────────────────────────────────────────────────────────── +.claude/settings.local.json +.agents/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..099b1a1 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Grounded + +*A to-do app that doesn't believe you.* + +Most to-do apps are neutral ledgers — they record intent and never object when +you ignore it. Grounded is an **enforcement layer**: it holds a model of what +you committed to, notices when reality diverges, and imposes escalating +consequences you agreed to in advance. + +Design axiom: **the checkbox is the enemy.** Every feature exists because +self-reported completion is worthless to someone trying to stop lying to +themselves. + +--- + +## Repository + +``` +frontend/ Flutter app (Android · iOS · Web · Windows) +``` + +See [`frontend/CLAUDE.md`](frontend/CLAUDE.md) for the architecture and +conventions, and [`frontend/design.md`](frontend/design.md) for the design +language. + +--- + +## The model + +Three primitives; everything else is derived. + +| Primitive | Meaning | +|---|---| +| **Commitment** | Something you said you'd do. Has a due *window*, a class, and a proof requirement. | +| **Debt** | The weight of what you've missed — a decaying, weighted score rather than a count. | +| **Standing** | `Good → Warned → Grounded → Lockdown`. Derived from debt; decides what the app lets you do. | + +Streaks reward perfection and collapse permanently on one miss. Debt is +continuous, forgiving in shape, hard to ignore, and gives you a way back. + +**Goals** contain commitments — "Workout" holds "Monday shoulders". A goal +never carries debt; the tasks inside it do. + +--- + +## What makes it different + +- **Due windows, not due dates.** `Mon 06:00–08:00` can actually close. A miss + is a permanent event, never a silent rollover to today. +- **Capacity blocking.** Chronic overdue is usually overcommitment misdiagnosed + as laziness. The app plans against what you *historically complete*, learns + your per-category estimation multiplier, and refuses plans that don't fit. +- **Excuses get clustered.** *"'Too tired' has appeared 14 times this month, 11 + of them on gym days, 9 of them after 7pm. Consider moving gym to morning."* +- **Proof of completion.** Honour · Photo · Timer · Location · Health · Witness. + Completing outside the window records as *late complete* — distinct in + history, and it never reduces debt to zero. +- **A live runner.** Starting a task takes over the screen, keeps time from + wall-clock so screen-off can't cheat it, and pauses when you leave the app. +- **Plyometrics is where it stops you.** Weekly ground-contact ceilings and + enforced recovery gaps — connective tissue doesn't recover on a motivation + schedule. + +## And what keeps it usable + +An app built on guilt has an obvious failure mode: the people who need it most +delete it during their worst week. So — rationed **amnesty tokens**, **sick and +travel mode** that pauses debt entirely, a **tone slider** hard-capped so no +copy ever attacks the person rather than the behaviour, and **distress +detection** that drops the strict persona completely when debt spikes while +engagement and readiness fall. Strictness is never the response to someone who +is actually struggling. + +--- + +## Running it + +```bash +cd frontend +cp .env.example .env # then point it at your backend +flutter pub get +flutter run +``` + +Requires Flutter with Dart `^3.6.2`. + +--- + +## Status + +Frontend scaffold complete: the full architecture, the debt/standing/capacity/ +integrity engines, and every screen in the MVP cut. The backend is not in this +repository yet — `CommsDirections.dart` defines the contract it needs to +satisfy. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1524555 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# Copy to `.env` before running. The real file is gitignored. +# +# GROUNDED_LOCAL_PATH is the host only — CommsDirections appends the service +# port and context path (Prospect 40003, Training 40004, Discipline 40005). + +GROUNDED_LOCAL_PATH="http://192.168.0.100" + +GROUNDED_PRODUCTION_PATH="https://app.grounded.com" + +LOCALISED_APP_VERSION="1.0.0" diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/frontend/.metadata b/frontend/.metadata new file mode 100644 index 0000000..f8eee6d --- /dev/null +++ b/frontend/.metadata @@ -0,0 +1,39 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: android + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: ios + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: web + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: windows + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..daa73c3 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,179 @@ +# Grounded — Flutter Frontend + +*A to-do app that doesn't believe you.* + +Architecture ported from `Documents/Autoreceptives/Frontend/Receptive`. Same +structure, conventions and infrastructure; the business logic is the debt and +enforcement engine. + +--- + +## 1. Stack + +Flutter (Dart `^3.6.2`), **stacked** MVVM, **dio**, **flutter_secure_storage**, +**flutter_dotenv**, **fl_chart**, **flutter_local_notifications**. +Package name and `lib/` root are both `Grounded`, so imports read +`package:Grounded/Grounded/...`. Org is `nya` (`nya.grounded`). + +Codegen is via **Mason** — screens, endpoints and storage fields are generated, +never hand-scaffolded. + +--- + +## 2. Layout (`lib/Grounded/`) + +``` +about/external/data/ responses & domain models (+ pages/ for pagination) +about/external/initial/ request bodies +about/internal/application/ enums and app-internal models +about/internal/file/ FileStorage +comms/ CommsDirections · ConnectComms · Comms +informatics/ DataManager · AppDataManager +memory/ ConnectInternalMemory · InternalMemory +configs/ Navigator · Env · NotificationServiceConfig +designs/ Component · Shell · Responsive · buttons/ input/ text/ +see// screens, 4 files each +utils/ Colors · CommonUtils · engines · validators +``` + +--- + +## 3. The 4-file screen pattern (mandatory) + +| File | Role | +|---|---| +| `Foo.dart` | `StatefulWidget` shell — creates `FooState` only. | +| `FooState.dart` | All UI + local state; implements `ConnectFoo`. | +| `ViewFoo.dart` | `extends ParentViewModel` — business logic, calls `DataManager`, pushes results back through `ConnectFoo`. | +| `ConnectFoo.dart` | Abstract callbacks so the ViewModel never touches widgets. | + +`ViewModelBuilder.reactive` wires them. `onViewModelReady` assigns +`_model` and calls one named `_initiate()`. **No logic in `builder:` before the +`return`.** + +Generate with: `mason make mvvc_template --project Grounded --screen Foo` + +--- + +## 4. ParentViewModel + +Constructs the single `AppDataManager` and owns everything cross-cutting: +`showLoading` / `closeLoading`, `hasNetwork(retry)`, `showError`, +`showApplicationNotification`, and `handleError` — whose decision tree routes +`401 → sessionExpired()` and error code `5000.901 → updateMe()`. + +Guard every network call: + +```dart +void loadThing(ThingRequest request) async { + if (!await hasNetwork(() => loadThing(request))) return; + showLoading('Loading…'); + try { + final response = await getDataManager().getThing(request); + closeLoading(); + connection.onThingLoaded(Thing.fromJson(response.data)); + } catch (e) { + handleError(e, () => loadThing(request), () => dismissError(), 'Retry'); + } +} +``` + +--- + +## 5. Domain — the enforcement engine + +Three primitives: **Commitment** (what you said you'd do), **Debt** (weighted, +decaying score), **Standing** (`Good → Warned → Grounded → Lockdown`). + +**Goals** contain commitments via `Commitment.parentId` — "Workout" holds +"Monday shoulders". Goals never carry debt; the tasks inside them do. + +Engines in `utils/`, each mapping 1:1 to the spec's formulas: + +| Engine | Owns | +|---|---| +| `DebtEngine` | `w(class) × severity(d) × decay(t)`; abandonment 2×, no decay 30d; late complete retains 30% | +| `StandingEngine` | tier derivation; what each tier permits | +| `CapacityEngine` | blocks over-scheduling against p50 of historical completed minutes × 0.85 | +| `IntegrityEngine` | session integrity, weekly volume, plyo contact ceiling, recovery gate | +| `ExcuseAnalyser` | on-device excuse clustering + the confrontation copy | +| `GuardrailEngine` | distress detection, amnesty tokens | +| `ToneEngine` | **all** enforcement copy — the one place the tone cap is enforced | + +Thresholds live in `Thresholds.dart`. Never inline a limit. + +`CommitmentEvent` is append-only and is the source of truth — **not** the +status field. That's what makes honest history and excuse analysis possible. + +--- + +## 6. Design language + +See `design.md`. In short: black chrome → white sheet with a 28 radius → +oversized **Light** display title → tiny grey all-caps labels above values. + +- Every screen is built from `Sheet` (`designs/Shell.dart`). +- Display titles are `TextType.Light`. Bold is for labels and metrics only. +- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`. +- Never inline hex — use `utils/Colors.dart`. +- Standing owns a colour used consistently everywhere (chrome, chip, meter). +- One primary button per screen; everything else outlined or text. + +Font: **General Sans** (Fontshare, FFL — see `fonts/LICENSE-GeneralSans.txt`), +wrapped as `TextType.{Bold,Medium,Regular,Light}`. + +--- + +## 7. Live task runner + +`see/live/` is the full-screen runner: a task being run takes over the screen. +Elapsed time is derived from **wall-clock**, not counted by the ticker, so a +screen-off period cannot lose time. Backgrounding pauses the clock and +increments `backgroundedCount` — that's what makes Timer proof mean anything. + +`LocalNotificationEngine` mirrors it into an ongoing notification and fires +alarm-class **full-screen intents** for non-negotiables. Android manifest +carries `USE_FULL_SCREEN_INTENT`, `SCHEDULE_EXACT_ALARM`, and the activity is +`showWhenLocked` + `turnScreenOn`. + +--- + +## 8. Hard conventions + +1. Named event handlers only — `onPressed: _onSave`, never `() => _save()`. +2. `selectField` loads on tap; never pre-load picker options. +3. List parsing via `utils/ObjectConvertors.dart` — never inline `.map(...fromJson)`. +4. Pagination: `HistoryRequest { PageAndSort }`; responses are `*Page`; `last == true` stops loading. +5. Reload-on-return: child `Navigator.pop(context, true)`, parent refreshes on the result. +6. New endpoint → `mason make api_endpoint` (injects into all four HTTP files). +7. New secure field → `mason make internal_memory`. +8. One class per file, named after the class. + +--- + +## 9. Environment + +`.env` is a bundled asset, loaded in `main()` before `runApp`: + +``` +GROUNDED_PRODUCTION_PATH · GROUNDED_LOCAL_PATH · LOCALISED_APP_VERSION +``` + +`isProd` in `CommsDirections.dart` flips local↔production. Requests carry the +three identity headers: `what`, `whom`, `version`. + +--- + +## 10. Commands + +```bash +flutter pub get +flutter analyze lib/ # bricks/ are excluded — templates aren't valid Dart +flutter run + +mason get +mason make mvvc_template --project Grounded --screen MyScreen + +dart run flutter_launcher_icons +dart run flutter_native_splash:create +``` diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..852aafc --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,17 @@ +# grounded + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml new file mode 100644 index 0000000..138d779 --- /dev/null +++ b/frontend/analysis_options.yaml @@ -0,0 +1,24 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + # Mason templates are not valid Dart until they are rendered. + - bricks/** + - build/** + + errors: + # The project deliberately uses PascalCase file names and a PascalCase + # package root, matching the house architecture. These are conventions, + # not defects. + file_names: ignore + camel_case_types: ignore + constant_identifier_names: ignore + non_constant_identifier_names: ignore + library_prefixes: ignore + +linter: + rules: + use_super_parameters: false + prefer_const_constructors: false + prefer_const_literals_to_create_immutables: false + library_private_types_in_public_api: false diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/frontend/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts new file mode 100644 index 0000000..72ec855 --- /dev/null +++ b/frontend/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "nya.grounded" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "nya.grounded" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/frontend/android/app/src/debug/AndroidManifest.xml b/frontend/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9772895 --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/kotlin/nya/grounded/MainActivity.kt b/frontend/android/app/src/main/kotlin/nya/grounded/MainActivity.kt new file mode 100644 index 0000000..73d19a4 --- /dev/null +++ b/frontend/android/app/src/main/kotlin/nya/grounded/MainActivity.kt @@ -0,0 +1,5 @@ +package nya.grounded + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/frontend/android/app/src/main/res/drawable-hdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..2d047e4 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..80bc7cf Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/drawable-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..adc1931 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-mdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..fa747f7 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9a6e840 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/drawable-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..92ef668 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..2d047e4 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..fa747f7 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..3c00d5f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..a28a19f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..043fa99 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-v21/background.png b/frontend/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..cb0e069 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-v21/background.png differ diff --git a/frontend/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable-xhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..3c00d5f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2ea8b4d Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/drawable-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..ee90019 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..a28a19f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a95c3ef Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..0c41fdb Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/frontend/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..043fa99 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..429a9c1 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..82f7f01 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable/background.png b/frontend/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..cb0e069 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable/background.png differ diff --git a/frontend/android/app/src/main/res/drawable/launch_background.xml b/frontend/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5822822 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..fede849 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..fd9748f Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..5c85366 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0189403 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/values-night-v31/styles.xml b/frontend/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..e051d71 --- /dev/null +++ b/frontend/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/values-night/styles.xml b/frontend/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..dbc9ea9 --- /dev/null +++ b/frontend/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/values-v31/styles.xml b/frontend/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..271e57e --- /dev/null +++ b/frontend/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/values/colors.xml b/frontend/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..cfde9b4 --- /dev/null +++ b/frontend/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #141414 + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0d1fa8f --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/android/app/src/profile/AndroidManifest.xml b/frontend/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/android/build.gradle.kts b/frontend/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/frontend/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/frontend/android/settings.gradle.kts b/frontend/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/frontend/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/frontend/assets/icons/icon.png b/frontend/assets/icons/icon.png new file mode 100644 index 0000000..397b041 Binary files /dev/null and b/frontend/assets/icons/icon.png differ diff --git a/frontend/assets/icons/icon_foreground.png b/frontend/assets/icons/icon_foreground.png new file mode 100644 index 0000000..9c9d291 Binary files /dev/null and b/frontend/assets/icons/icon_foreground.png differ diff --git a/frontend/assets/icons/logo.svg b/frontend/assets/icons/logo.svg new file mode 100644 index 0000000..cca97ca --- /dev/null +++ b/frontend/assets/icons/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/assets/icons/splash.png b/frontend/assets/icons/splash.png new file mode 100644 index 0000000..f034796 Binary files /dev/null and b/frontend/assets/icons/splash.png differ diff --git a/frontend/bricks/api_endpoint/CHANGELOG.md b/frontend/bricks/api_endpoint/CHANGELOG.md new file mode 100644 index 0000000..f0640d6 --- /dev/null +++ b/frontend/bricks/api_endpoint/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0+1 + +- TODO: Describe initial release. diff --git a/frontend/bricks/api_endpoint/LICENSE b/frontend/bricks/api_endpoint/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/frontend/bricks/api_endpoint/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/bricks/api_endpoint/README.md b/frontend/bricks/api_endpoint/README.md new file mode 100644 index 0000000..301e9b6 --- /dev/null +++ b/frontend/bricks/api_endpoint/README.md @@ -0,0 +1,83 @@ +## 📄 README.md + +### 🚀 API Endpoint Mason Brick +A smart, synchronized generator that appends API logic across `Comms`, `ConnectComms`, and `AppDataManager` while handling auto-imports and optional formatting. + +### 🛠 1. First-Time Setup +Run these commands to ensure Mason and the Dart hooks are ready to go: + +```bash +# 1. Install Mason CLI (if you haven't) +dart pub global activate mason_cli + +# 2. Initialize Mason in project root +mason init + +# 3. Install Hook dependencies +cd bricks/api_endpoint/hooks +dart pub get +cd ../../../ + +# 4. Register the brick +mason get +``` + +### 🔃 2. How to Refresh (When logic or prompts change) +If you update the `brick.yaml` or the `post_gen.dart` hook, Mason might use a cached version. Use this "Hard Refresh" command: + +```bash +rm -rf .mason && mason get +``` + +### 🏃 3. Running the Generator +To add a new endpoint, simply run: +```bash +mason make api_endpoint +``` + +**Interaction Flow:** +1. **Endpoint Path:** e.g., `orders/confirm` +2. **Function Name:** e.g., `confirmOrder` +3. **Method:** Choose `get` or `post`. +4. **Request Body:** * **Mandatory for POST**: e.g., `OrderDetails`. + * **Optional for GET**: Leave empty or provide a type like `String`. +5. **Auto-Import**: The brick scans `lib/` for your Body type and adds the `import` statement automatically. +6. **Format**: Choose `y` to run `dart format` on all 4 modified files. + +--- + +## 🐚 setup_brick.sh +Create this file in your project root to allow one-click setup or refreshing for your team. + +```bash +#!/bin/bash + +# setup_brick.sh +echo "🧹 Cleaning old Mason cache..." +rm -rf .mason + +echo "📦 Fetching Hook dependencies..." +if [ -d "bricks/api_endpoint/hooks" ]; then + cd bricks/api_endpoint/hooks + dart pub get + cd ../../../ +else + echo "❌ Error: bricks/api_endpoint/hooks directory not found!" + exit 1 +fi + +echo "🧱 Registering Mason Brick..." +mason get + +echo "✅ Setup Complete! Run 'mason make api_endpoint' to start." +``` + +### To use the script: +1. Create the file: `touch setup_brick.sh` +2. Give it permission: `chmod +x setup_brick.sh` +3. Run it: `./setup_brick.sh` + +--- + +### 💡 Pro-Tip: Project Name Detection +In your `post_gen.dart` hook, ensure you have updated the `package:your_project_name` string to match your real project name from `pubspec.yaml`, otherwise the auto-imports will show a red error in VS Code. diff --git a/frontend/bricks/api_endpoint/brick.yaml b/frontend/bricks/api_endpoint/brick.yaml new file mode 100644 index 0000000..ab8cb90 --- /dev/null +++ b/frontend/bricks/api_endpoint/brick.yaml @@ -0,0 +1,25 @@ +name: api_endpoint +description: Generates Comms and AppDataManager logic for a new endpoint. + +version: 0.1.0+1 + +environment: + mason: ^0.1.1 + + +vars: + path_url: + type: string + prompt: "🌐 What is the API path? (e.g., auth/login)" + function_name: + type: string + prompt: "🖋️ What is the function name? (e.g., signIn)" + request_type: + type: string + default: get + prompt: "🔃 Is this a 'get' or a 'post'?" + request_body: + type: string + default: "" + prompt: "📦 Request Body type? (Mandatory for POST, Optional for GET)" + diff --git a/frontend/bricks/api_endpoint/hooks/post_gen.dart b/frontend/bricks/api_endpoint/hooks/post_gen.dart new file mode 100644 index 0000000..fdc32ec --- /dev/null +++ b/frontend/bricks/api_endpoint/hooks/post_gen.dart @@ -0,0 +1,146 @@ +import 'dart:io'; +import 'package:mason/mason.dart'; +import 'package:yaml/yaml.dart'; + +void run(HookContext context) async { + final logger = context.logger; + + final String pathUrl = context.vars['path_url'] ?? ''; + final String functionName = context.vars['function_name'] ?? ''; + final String requestType = (context.vars['request_type'] as String).toLowerCase(); + final String requestBody = context.vars['request_body'] as String; + + if (requestType == 'post' && requestBody.isEmpty) { + logger.err('❌ Error: POST requests require a Request Body type.'); + return; + } + + final cName = functionName.camelCase; + final bool hasData = requestBody.isNotEmpty; + final String paramType = hasData ? requestBody : 'dynamic'; + + // Auto-Detect Project Name + String projectName = 'your_project'; + final pubspecFile = File('pubspec.yaml'); + if (pubspecFile.existsSync()) { + final doc = loadYaml(pubspecFile.readAsStringSync()); + projectName = doc['name'] ?? projectName; + } + + final progress = logger.progress('🚀 Injecting logic...'); + + final filesToUpdate = [ + 'lib/Grounded/comms/CommsDirections.dart', + 'lib/Grounded/comms/Comms.dart', + 'lib/Grounded/comms/ConnectComms.dart', + 'lib/Grounded/informatics/AppDataManager.dart' + ]; + + // Find Import Path and Filename + String? importLine; + String? importFileName; + if (hasData && !['String','int','double','bool'].contains(requestBody)) { + final result = await _findClassData(requestBody, projectName); + importLine = result?['line']; + importFileName = result?['fileName']; + } + + final String sharedParams = hasData ? '$paramType request' : ''; + final String callParams = hasData ? 'request' : ''; + + // 1. CommsDirections + await _inject(filesToUpdate[0], "String ${cName}Path = '$pathUrl';", logger, + isClass: false, duplicateCheck: "${cName}Path"); + + // 2. comms.dart + final String dioData = requestType == 'post' ? 'data: request' : 'options: Options(headers: dio.options.headers)'; + final String commsMethod = ''' + @override + Future $cName($sharedParams) async { + Pair navigation = await getRequestHeaders(${cName}Path, ""); + dio.options.headers = navigation.value; + return await dio.$requestType( + navigation.key, + $dioData + ); + }'''; + await _inject(filesToUpdate[1], commsMethod, logger, + importLine: importLine, importFileName: importFileName, duplicateCheck: "Future $cName"); + + // 3. ConnectComms.dart + await _inject(filesToUpdate[2], " Future $cName($sharedParams);", logger, + importLine: importLine, importFileName: importFileName, duplicateCheck: "Future $cName"); + + // 4. AppDataManager.dart + final String dataManagerMethod = ''' + @override + Future $cName($sharedParams) async { + return await connectComms.$cName($callParams); + }'''; + await _inject(filesToUpdate[3], dataManagerMethod, logger, + importLine: importLine, importFileName: importFileName, duplicateCheck: "Future $cName"); + + progress.complete('✨ Injection & Deduplication complete.'); + + final shouldFormat = logger.confirm('🎨 Run "dart format" on updated files?', defaultValue: false); + if (shouldFormat) { + for (final path in filesToUpdate) { + await Process.run('dart', ['format', path]); + } + logger.success('✅ Files formatted.'); + } +} + +/// Returns both the full import line and just the filename (e.g. user_request.dart) +Future?> _findClassData(String className, String projectName) async { + final directory = Directory('lib'); + if (!directory.existsSync()) return null; + final List files = directory.listSync(recursive: true); + for (var file in files) { + if (file is File && file.path.endsWith('.dart')) { + final content = await file.readAsString(); + if (content.contains('class $className') || content.contains('enum $className')) { + final fileName = file.path.split('/').last; + final packagePath = file.path.replaceFirst('lib/Grounded/', ''); + return { + 'line': "import 'package:$projectName/$packagePath';", + 'fileName': fileName, + }; + } + } + } + return null; +} + +Future _inject(String path, String content, Logger logger, + {bool isClass = true, String? importLine, String? importFileName, String? duplicateCheck}) async { + final file = File(path); + if (!await file.exists()) return; + String fileContent = await file.readAsString(); + + // 1. Prevent Duplicate Method/Variable + if (duplicateCheck != null && fileContent.contains(duplicateCheck)) { + return; // Silently skip methods that exist + } + + // 2. Smart Import Check: Look for the filename inside any import statement + if (importLine != null && importFileName != null) { + // Regex matches: import 'any/path/filename.dart'; or "any/path/filename.dart"; + final importRegex = RegExp("import ['\"].*?$importFileName['\"];"); + if (!importRegex.hasMatch(fileContent)) { + fileContent = "$importLine\n$fileContent"; + } + } + + if (isClass) { + final lastBrace = fileContent.lastIndexOf('}'); + if (lastBrace != -1) { + fileContent = fileContent.substring(0, lastBrace).trimRight() + '\n\n$content\n' + fileContent.substring(lastBrace); + } else { + fileContent += '\n\n$content'; + } + } else { + fileContent = fileContent.trimRight() + '\n\n$content\n'; + } + await file.writeAsString(fileContent); +} \ No newline at end of file diff --git a/frontend/bricks/api_endpoint/hooks/pubspec.yaml b/frontend/bricks/api_endpoint/hooks/pubspec.yaml new file mode 100644 index 0000000..2d5b58a --- /dev/null +++ b/frontend/bricks/api_endpoint/hooks/pubspec.yaml @@ -0,0 +1,10 @@ +name: api_endpoint_hooks +description: Hooks for the api_endpoint brick. +publish_to: none + +environment: + sdk: ">=2.12.0 <4.0.0" + +dependencies: + mason: ^0.1.0-dev.51 # Or the latest version + yaml: ^3.1.2 \ No newline at end of file diff --git a/frontend/bricks/api_endpoint/setup_brick.sh b/frontend/bricks/api_endpoint/setup_brick.sh new file mode 100644 index 0000000..cc0140f --- /dev/null +++ b/frontend/bricks/api_endpoint/setup_brick.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# setup_brick.sh +echo "🧹 Cleaning old Mason cache (Hard Refresh)..." +rm -rf .mason + +echo "📦 Fetching Hook dependencies (Yaml/Mason)..." +if [ -d "bricks/api_endpoint/hooks" ]; then + cd bricks/api_endpoint/hooks + dart pub get + cd ../../../ +else + echo "❌ Error: Hooks directory not found!" + exit 1 +fi + +echo "🧱 Getting latest Brick definition..." +mason get + +echo "✅ Ready! Run 'mason make api_endpoint'" \ No newline at end of file diff --git a/frontend/bricks/internal_memory/CHANGELOG.md b/frontend/bricks/internal_memory/CHANGELOG.md new file mode 100644 index 0000000..f0640d6 --- /dev/null +++ b/frontend/bricks/internal_memory/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0+1 + +- TODO: Describe initial release. diff --git a/frontend/bricks/internal_memory/LICENSE b/frontend/bricks/internal_memory/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/frontend/bricks/internal_memory/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/bricks/internal_memory/README.md b/frontend/bricks/internal_memory/README.md new file mode 100644 index 0000000..305fd07 --- /dev/null +++ b/frontend/bricks/internal_memory/README.md @@ -0,0 +1,92 @@ + + +# 🔐 Internal Memory Brick + +An advanced **Mason** brick designed for the **Autoreceptive** architecture. It automates the boilerplate for persistent local storage using `flutter_secure_storage` (via `autoreceptiveStorage`), handling JSON serialization for Objects and Lists, and raw storage for Strings. + +## 🚀 Purpose +This brick eliminates the manual task of: +1. Generating high-entropy, randomized secure keys. +2. Writing `get` and `set` interfaces in `ConnectInternalMemory`. +3. Implementing JSON encoding/decoding logic in `InternalMemory`. +4. Wrapping calls in `AppDataManager` to maintain the **Repository Pattern**. + +--- + +## 📂 Target Architecture +The brick targets the following specific paths in your project: +* **Interface**: `lib/Autoreceptive/memory/ConnectInternalMemory.dart` +* **Implementation**: `lib/Autoreceptive/memory/InternalMemory.dart` +* **Global Access**: `lib/Autoreceptive/informatics/AppDataManager.dart` + +--- + +## 🛠 Installation + +### 1. Register the Brick +From your project root, run: +```bash +mason add internal_memory --path ./bricks/internal_memory +``` + +### 2. Setup Hooks +The brick uses specialized Dart hooks for smart code injection and auto-formatting. +```bash +cd bricks/internal_memory/hooks && dart pub get && cd ../../../ +mason get +``` + +--- + +## 📖 Usage Guide + +Run the following command and follow the interactive prompts: +```bash +mason make internal_memory +``` + +### Variable Inputs: +| Variable | Description | Example | +| :--- | :--- | :--- | +| `function_name` | The camelCase name of the feature. | `userProfile` or `orderLocations` | +| `request_object` | The Dart type to store. | `MeDescription`, `String`, or `List` | + +--- + +## 📋 Logic Breakdown + +### 1. High-Entropy Keys +The brick generates a randomized `static const String` key at the **top** of the `InternalMemory` class to prevent key collisions and reverse-engineering: +```dart +static const String USER_PROFILE = "USER_PROFILE_x8H2_9kLp_mQz1"; +``` + +### 2. Intelligent Type Handling +The hook detects the `request_object` type and adjusts the logic: + +* **Standard Objects**: Uses `Type.fromJson(jsonDecode(s))` and `jsonEncode(data.toJson())`. +* **Lists**: Uses `.map((item) => Type.fromJson(item)).toList()` for deep serialization. +* **Strings**: Bypasses JSON logic for raw string storage. + +### 3. Smart Injection & Spacing +* **Clean Pairs**: In the interface, the getter and setter are injected as a tight block with a double-newline buffer from the previous feature. +* **Auto-Imports**: Automatically adds `import 'dart:convert';` and searches the `lib/` folder to find and add the correct model imports for your Objects. +* **Formatting**: Automatically triggers `dart format` on the `Autoreceptive` folder after injection. + +--- + +## ⚠️ Requirements +* **`autoreceptiveStorage`**: The `InternalMemory` class must have an instance of your secure storage wrapper named `autoreceptiveStorage`. +* **Model Factories**: For Objects and Lists, your models must implement: + * `factory Model.fromJson(Map json)` + * `Map toJson()` + +--- + +## 🔄 Refreshing Logic +If you update the `post_gen.dart` hook code, always run this to ensure Mason uses the latest version: +```bash +rm -rf .mason && mason get +``` + +--- diff --git a/frontend/bricks/internal_memory/brick.yaml b/frontend/bricks/internal_memory/brick.yaml new file mode 100644 index 0000000..ea86c9a --- /dev/null +++ b/frontend/bricks/internal_memory/brick.yaml @@ -0,0 +1,11 @@ +name: internal_memory +description: Generates Secure Storage logic with JSON serialization and AppDataManager sync. +version: 0.1.0+1 + +vars: + function_name: + type: string + prompt: "🖋️ What is the function name? (e.g., myDescription)" + request_object: + type: string + prompt: "📦 What is the Object type to store? (e.g., MeDescription)" \ No newline at end of file diff --git a/frontend/bricks/internal_memory/hooks/post_gen.dart b/frontend/bricks/internal_memory/hooks/post_gen.dart new file mode 100644 index 0000000..02165eb --- /dev/null +++ b/frontend/bricks/internal_memory/hooks/post_gen.dart @@ -0,0 +1,186 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math'; +import 'package:mason/mason.dart'; +import 'package:yaml/yaml.dart'; + +void run(HookContext context) async { + final logger = context.logger; + final String functionName = context.vars['function_name'] ?? ''; + final String requestObject = context.vars['request_object'] ?? ''; + + final cName = functionName.camelCase; + final pName = functionName.pascalCase; + final uName = functionName.snakeCase.toUpperCase(); + + final secureKeyValue = _generateSecureKey(uName); + + String projectName = 'your_project'; + final pubspecFile = File('pubspec.yaml'); + if (pubspecFile.existsSync()) { + final doc = loadYaml(pubspecFile.readAsStringSync()); + projectName = doc['name'] ?? projectName; + } + + final bool isList = requestObject.startsWith('List<'); + final bool isString = requestObject.toLowerCase() == 'string'; + + String baseType = requestObject; + if (isList) { + baseType = requestObject.substring(5, requestObject.length - 1); + } + + final files = [ + 'lib/Grounded/memory/ConnectInternalMemory.dart', + 'lib/Grounded/memory/InternalMemory.dart', + 'lib/Grounded/informatics/AppDataManager.dart' + ]; + + String? importLine; + String? importFileName; + if (!isString) { + final result = await _findClassData(baseType, projectName); + importLine = result?['line']; + importFileName = result?['fileName']; + } + + // --- Implementation Logic --- + String getLogic = ""; + String setLogic = ""; + + if (isString) { + getLogic = "return await groundedStorage.read(key: $uName) ?? '';"; + setLogic = "await groundedStorage.write(key: $uName, value: data);"; + } else if (isList) { + getLogic = ''' + final dataString = await groundedStorage.read(key: $uName); + if (dataString != null && dataString.isNotEmpty) { + return (jsonDecode(dataString) as List) + .map((item) => $baseType.fromJson(item)) + .toList(); + } + return [];'''; + setLogic = ''' + final jsonString = jsonEncode(data.map((item) => item.toJson()).toList()); + await groundedStorage.write(key: $uName, value: jsonString);'''; + } else { + getLogic = ''' + final jsonString = await groundedStorage.read(key: $uName); + if (jsonString != null && jsonString.isNotEmpty) { + return $baseType.fromJson(jsonDecode(jsonString)); + } + return $baseType();'''; + setLogic = ''' + await groundedStorage.write(key: $uName, value: jsonEncode(data.toJson()));'''; + } + + // --- 1. ConnectInternalMemory (No space between the pair) --- + final interfaceCode = " Future<$requestObject> get$pName();\n Future set$pName($requestObject data);"; + await _inject(files[0], interfaceCode, logger, importLine: importLine, importFileName: importFileName, duplicateCheck: "get$pName()"); + + // --- 2. InternalMemory (Key spacing at TOP) --- + final String keyLine = '\n\n static const String $uName = "$secureKeyValue";\n'; + final String methods = ''' + @override + Future<$requestObject> get$pName() async { + $getLogic + } + + @override + Future set$pName($requestObject data) async { + $setLogic + }'''; + + await _injectInternalMemory(files[1], keyLine, methods, logger, importLine: importLine, importFileName: importFileName, duplicateCheck: "get$pName()"); + + // --- 3. AppDataManager --- + final String wrapper = ''' + @override + Future<$requestObject> get$pName() async { + return await connectInternalMemory.get$pName(); + } + + @override + Future set$pName($requestObject data) async { + return await connectInternalMemory.set$pName(data); + }'''; + await _inject(files[2], wrapper, logger, importLine: importLine, importFileName: importFileName, duplicateCheck: "get$pName()"); + + // --- 4. Format --- + try { + await Process.run('dart', ['format', 'lib/Grounded/']); + } catch (_) {} + + logger.success('✨ Internal Memory synced with corrected spacing.'); +} + +String _generateSecureKey(String base) { + final random = Random.secure(); + final values = List.generate(12, (i) => random.nextInt(256)); + return "${base}_${base64Url.encode(values).replaceAll('=', '').substring(0, 16)}"; +} + +Future?> _findClassData(String className, String projectName) async { + final dir = Directory('lib'); + if (!dir.existsSync()) return null; + for (var f in dir.listSync(recursive: true)) { + if (f is File && f.path.endsWith('.dart')) { + final content = await f.readAsString(); + if (content.contains('class $className')) { + return { + 'line': "import 'package:$projectName/${f.path.replaceFirst('lib/', '')}';", + 'fileName': f.path.split('/').last, + }; + } + } + } + return null; +} + +Future _inject(String path, String content, Logger logger, {String? importLine, String? importFileName, String? duplicateCheck}) async { + final file = File(path); + if (!await file.exists()) return; + String fileContent = await file.readAsString(); + if (duplicateCheck != null && fileContent.contains(duplicateCheck)) return; + + if (importLine != null && importFileName != null && !fileContent.contains(importFileName)) { + fileContent = "$importLine\n$fileContent"; + } + + final lastBrace = fileContent.lastIndexOf('}'); + if (lastBrace != -1) { + // Inject with double newlines between previous content and new content + fileContent = fileContent.substring(0, lastBrace).trimRight() + '\n\n$content\n' + fileContent.substring(lastBrace); + await file.writeAsString(fileContent); + } +} + +Future _injectInternalMemory(String path, String keyLine, String methods, Logger logger, {String? importLine, String? importFileName, String? duplicateCheck}) async { + final file = File(path); + if (!await file.exists()) return; + String content = await file.readAsString(); + + if (duplicateCheck != null && content.contains(duplicateCheck)) return; + + if (importLine != null && importFileName != null && !content.contains(importFileName)) { + content = "$importLine\n$content"; + } + + if (!content.contains("import 'dart:convert';")) { + content = "import 'dart:convert';\n$content"; + } + + // Inject Key at the top with forced extra spacing + final classStart = content.indexOf('{'); + if (classStart != -1) { + content = content.substring(0, classStart + 1) + "$keyLine" + content.substring(classStart + 1); + } + + // Inject Methods at the bottom + final lastBrace = content.lastIndexOf('}'); + if (lastBrace != -1) { + content = content.substring(0, lastBrace).trimRight() + "\n\n$methods\n" + content.substring(lastBrace); + } + + await file.writeAsString(content); +} \ No newline at end of file diff --git a/frontend/bricks/internal_memory/hooks/pubspec.yaml b/frontend/bricks/internal_memory/hooks/pubspec.yaml new file mode 100644 index 0000000..4f79439 --- /dev/null +++ b/frontend/bricks/internal_memory/hooks/pubspec.yaml @@ -0,0 +1,8 @@ +name: internal_memory_hooks +description: Hooks for internal_memory. +publish_to: none +environment: + sdk: ">=3.0.0 <4.0.0" +dependencies: + mason: ^0.1.0-dev.51 + yaml: ^3.1.2 \ No newline at end of file diff --git a/frontend/bricks/mvvc_template/CHANGELOG.md b/frontend/bricks/mvvc_template/CHANGELOG.md new file mode 100644 index 0000000..f0640d6 --- /dev/null +++ b/frontend/bricks/mvvc_template/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0+1 + +- TODO: Describe initial release. diff --git a/frontend/bricks/mvvc_template/LICENSE b/frontend/bricks/mvvc_template/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/frontend/bricks/mvvc_template/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/bricks/mvvc_template/README.md b/frontend/bricks/mvvc_template/README.md new file mode 100644 index 0000000..319f2e8 --- /dev/null +++ b/frontend/bricks/mvvc_template/README.md @@ -0,0 +1,27 @@ +# mvvc_template + +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) + +A new brick created with the Mason CLI. + +_Generated by [mason][1] 🧱_ + +## Getting Started 🚀 + +This is a starting point for a new brick. +A few resources to get you started if this is your first brick template: + +- [Official Mason Documentation][2] +- [Code generation with Mason Blog][3] +- [Very Good Livestream: Felix Angelov Demos Mason][4] +- [Flutter Package of the Week: Mason][5] +- [Observable Flutter: Building a Mason brick][6] +- [Meet Mason: Flutter Vikings 2022][7] + +[1]: https://github.com/felangel/mason +[2]: https://docs.brickhub.dev +[3]: https://verygood.ventures/blog/code-generation-with-mason +[4]: https://youtu.be/G4PTjA6tpTU +[5]: https://youtu.be/qjA0JFiPMnQ +[6]: https://youtu.be/o8B1EfcUisw +[7]: https://youtu.be/LXhgiF5HiQg diff --git a/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart new file mode 100644 index 0000000..6692240 --- /dev/null +++ b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart @@ -0,0 +1,2 @@ +abstract class Connect{{screen}}{ +} \ No newline at end of file diff --git a/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart new file mode 100644 index 0000000..9ae0130 --- /dev/null +++ b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; + +import '../parent/ParentViewModel.dart'; +import 'Connect{{screen}}.dart'; + +class View{{screen}} extends ParentViewModel { + Connect{{screen}} connection; + + View{{screen}}(BuildContext context, this.connection) : super(context); + + // Business logic goes here. Guard every network call with hasNetwork, wrap + // it in showLoading/closeLoading, and push results back through + // `connection` rather than touching widgets directly. +} diff --git a/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart new file mode 100644 index 0000000..03046fb --- /dev/null +++ b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart @@ -0,0 +1,11 @@ + +import 'package:flutter/material.dart'; +import '{{screen}}State.dart'; + +class {{screen}} extends StatefulWidget{ + const {{screen}} ({Key? key}) : super(key: key); + + @override + State<{{screen}}> createState() => {{screen}}State(); + +} \ No newline at end of file diff --git a/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart new file mode 100644 index 0000000..69aa4f8 --- /dev/null +++ b/frontend/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart @@ -0,0 +1,77 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import 'package:Grounded/Grounded/about/internal/application/TextType.dart'; +import 'package:Grounded/Grounded/designs/Responsive.dart'; +import 'package:Grounded/Grounded/designs/Shell.dart'; +import 'package:Grounded/Grounded/designs/text/Text.dart'; +import 'package:Grounded/Grounded/utils/Colors.dart'; + +import 'Connect{{screen}}.dart'; +import '{{screen}}.dart'; +import 'View{{screen}}.dart'; + +class {{screen}}State extends State<{{screen}}> implements Connect{{screen}} { + View{{screen}}? _model; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => View{{screen}}(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints viewportConstraints) { + return Responsive( + mobile: _mobileView(viewportConstraints), + tablet: _tabletView(viewportConstraints), + desktop: _desktopView(viewportConstraints), + ); + }, + ), + ); + } + + void _initiate() { + // Load initial data through _model here; push results back via the + // Connect{{screen}} callbacks below. + } + + void _onBack() { + Navigator.pop(context); + } + + Widget _mobileView(BoxConstraints viewportConstraints) { + return Sheet( + eyebrow: "Grounded", + title: "{{screen}}", + onBack: _onBack, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("{{screen}}"), + const SizedBox(height: 14), + text( + "Replace this with the screen content.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + ], + ), + ); + } + + Widget _tabletView(BoxConstraints viewportConstraints) { + return _mobileView(viewportConstraints); + } + + Widget _desktopView(BoxConstraints viewportConstraints) { + return _mobileView(viewportConstraints); + } +} diff --git a/frontend/bricks/mvvc_template/brick.yaml b/frontend/bricks/mvvc_template/brick.yaml new file mode 100644 index 0000000..19ac2cd --- /dev/null +++ b/frontend/bricks/mvvc_template/brick.yaml @@ -0,0 +1,38 @@ +name: mvvc_template +description: A new brick created with the Mason CLI. + +# The following defines the brick repository url. +# Uncomment and update the following line before publishing the brick. +# repository: https://github.com/my_org/my_repo + +# The following defines the version and build number for your brick. +# A version number is three numbers separated by dots, like 1.2.34 +# followed by an optional build number (separated by a +). +version: 0.1.0+1 + +# The following defines the environment for the current brick. +# It includes the version of mason that the brick requires. +environment: + mason: ^0.1.1 + +# Variables specify dynamic values that your brick depends on. +# Zero or more variables can be specified for a given brick. +# Each variable has: +# * a type (string, number, boolean, enum, array, or list) +# * an optional short description +# * an optional default value +# * an optional list of default values (array only) +# * an optional prompt phrase used when asking for the variable +# * a list of values (enums only) +# * an optional separator (list only) +vars: + project: + type: string + description: The project name + default: Grounded + prompt: What is your project name? + screen: + type: string + description: The name of the new screen + default: ActivityNew + prompt: What is your file name? diff --git a/frontend/design.md b/frontend/design.md new file mode 100644 index 0000000..dd0aada --- /dev/null +++ b/frontend/design.md @@ -0,0 +1,55 @@ +# Grounded — Design Language + +Reference: black device chrome, a white content sheet that rises into it with a +large corner radius, oversized light display titles, tiny grey all-caps metadata +labels, pill chips, and a lot of whitespace. The app reads as a record, not a +dashboard. + +## Type — General Sans + +| Role | Family | Size | Use | +|---|---|---|---| +| Display | `TextType.Light` | 30–44 | Screen titles. Always light, never bold. | +| Title | `TextType.Bold` | 17–20 | Card and section titles. | +| Body | `TextType.Regular` | 13–14, `height: 1.5` | Descriptions, copy. | +| Label | `TextType.Bold` | 10–11, `letterSpacing: 0.8–1.2`, uppercase, `colorGrey2` | Metadata above a value. | +| Metric | `TextType.Bold` | 24–34 | Debt score, counts. | + +The label/value pair is the core unit: a tiny grey all-caps label sitting +directly above a large dark value. Used everywhere numbers appear. + +## Geometry + +- Sheet radius `28`, card radius `16`, chip radius `999`, control radius `12`. +- Screen padding `20` horizontal. Card padding `16`. Section gap `28`. +- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%). +- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows. + +## Colour + +Near-black ink (`colorPrimaryDark #141414`) on warm off-white +(`colorPrimaryLight #EDEEEA`), with a single green accent (`colorPrimary +#2F6F4E`). Enforcement tiers own the rest of the palette: + +| Standing | Colour | +|---|---| +| Good | green `#2F6F4E` | +| Warned | amber `#C98A04` | +| Grounded | rust `#B5442F` | +| Lockdown | oxblood `#7A1F14` | + +The tier colour is used consistently — header, chip, meter, queue accent — so +the standing is legible without reading the word. + +## Structure + +Every screen is: black `headerBar` (back, title, action) → white sheet with a +`28` top radius → `sectionHeader` (label + light display title) → content. + +## Rules + +- Display titles are Light. Bold is for labels and metrics only. +- Numbers get a label above them, never beside them. +- Chips carry state, never actions. +- One primary button per screen; everything else is outlined or text. +- Empty states are plain. An empty overdue queue is good news, not a party. diff --git a/frontend/fonts/LICENSE-GeneralSans.txt b/frontend/fonts/LICENSE-GeneralSans.txt new file mode 100644 index 0000000..f14ebba --- /dev/null +++ b/frontend/fonts/LICENSE-GeneralSans.txt @@ -0,0 +1,57 @@ +Fontshare EULA + +---—---------------------------------—------------------------------ +Free Font - End User License Agreement (FF EULA) +---—---------------------------------—------------------------------ +Notice to User +Indian Type Foundry designs, produces and distributes font software as digital fonts to end users worldwide. In addition to commercial fonts that are available for a fee, ITF also offers several fonts which can be used free of charge. The free fonts are distributed through a dedicated platform called www.fontshare.com (“Fontshare”) to end users worldwide. These free fonts are subject to this legally binding EULA between the Indian Type Foundry (“Indian Type Foundry” or “Licensor”) and you (“Licensee”).  +You acknowledge that the Font Software and designs embodied therein are protected by the copyright, other intellectual property rights and industrial property rights and by international treaties. They are and remain at all times the intellectual property of the Indian Type Foundry. +In addition to direct download, Fontshare also offers these free fonts via Fonthsare API using a code. In this case, the Font Software is delivered directly from the servers used by Indian Type Foundry to the Licensee's website, without the Licensee having to download the Font Software. +By downloading, accessing the API, installing, storing, copying or using one of any Font Software, you agree to the following terms.  + +Definitions +“Font Software” refers to the set of computer files or programs released under this license that instructs your computer to display and/or print each letters, characters, typographic designs, ornament and so forth. Font Software includes all bitmap and vector representations of fonts and typographic representations and embellishments created by or derived from the Font Software.  +“Original Version” refers to the Font Software as distributed by the Indian Type Foundry as the copyright holder.  +“Derivative Work” refers to the pictorial representation of the font created by the Font Software, including typographic characters such as letters, numerals, ornaments, symbols, or punctuation and special characters. + +01. Grant of License +You are hereby granted a non-exclusive, non-assignable, non-transferrable, terminable license to access, download and use the Font Software for your personal or commercial use for an unlimited period of time for free of charge.  +You may use the font Software in any media (including Print, Web, Mobile, Digital, Apps, ePub, Broadcasting and OEM) at any scale, at any location worldwide.  +You may use the Font Software to create logos and other graphic elements, images on any surface, vector files or other scalable drawings and static images.  +You may use the Font Software on any number of devices (computer, tablet, phone). The number of output devices (Printers) is not restricted.  +You may make only such reasonable number of back-up copies suitable to your permitted use.  +You may but are not required to identify Indian Type Foundry Fonts in your work credits.  + +02. Limitations of usage +You may not modify, edit, adapt, translate, reverse engineer, decompile or disassemble, alter or otherwise copy the Font Software or the designs embodied therein in whole or in part, without the prior written consent of the Licensor.  +The Fonts may not - beyond the permitted copies and the uses defined herein - be distributed, duplicated, loaned, resold or licensed in any way, whether by lending, donating or give otherwise to a person or entity. This includes the distribution of the Fonts by e-mail, on USB sticks, CD-ROMs, or other media, uploading them in a public server or making the fonts available on peer-to-peer networks. A passing on to external designers or service providers (design agencies, repro studios, printers, etc.) is also not permitted.  +You are not allowed to transmit the Font Software over the Internet in font serving or for font replacement by means of technologies such as but not limited to EOT, Cufon, sIFR or similar technologies that may be developed in the future without the prior written consent of the Licensor.  + +03. Embedding +You may embed the Font Software in PDF and other digital documents provided that is done in a secured, read-only mode. It must be ensured beyond doubt that the recipient cannot use the Font Software to edit or to create new documents. The design data (PDFs) created in this way and under these created design data (PDFs) may be distributed in any number.  +The extraction of the Font Software in whole or in part is prohibited.  + +04. Third party use, Commercial print service provider +You may include the Font Software in a non-editable electronic document solely for printing and display purposes and provide that electronic document to the commercial print service provider for the purpose of printing. If the print service needs to install the fonts, they too need to download the Font Software from the Licensor's website. + +05. Derivative Work +You are allowed to make derivative works as far as you use them for your personal or commercial use. However, you cannot modify, make changes or reverse engineer the original font software provided to you. Any derivative works are the exclusive property of the Licensor and shall be subject to the terms and conditions of this EULA. Derivative works may not be sub-licensed, sold, leased, rented, loaned, or given away without the express written permission of the Licensor.  + +06. Warranty and Liability +BECAUSE THIS SOFTWARE IS LICENSED FREE OF CHARGE, INDIAN TYPE FOUNDRY MAKES NO WARRANTIES, EXPRESS OR IMPLIED AS TO THE MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR OTHERWISE. THE FONT SOFTWARE WAS NOT MANUFACTURED FOR USE IN MANUFACTURING CONTROL DEVICES OR NAVIGATION DEVICES OR IN CIRCUMSTANCES THAT COULD RESULT IN ENVIRONMENTAL DAMAGE OR PERSONAL INJURY. WITHOUT LIMITING THE FOREGOING, INDIAN TYPE FOUNDRY SHALL IN NO EVENT BE LIABLE TO THE LICENSED USER OR ANY OTHER THIRD PARTY FOR ANY DIRECT, CONSEQUENTIAL OR INCIDENTAL DAMAGES, INCLUDING DAMAGES FROM LOSS OF BUSINESS PROFITS, BUSINESS INTERRUPTION, LOSS OF BUSINESS INFORMATION NOR FOR LOST PROFITS OR SAVINGS ARISING OUT OF THE USE OR INABILITY TO USE THE PRODUCT EVEN IF NOTIFIED IN ADVANCE, UNDER NO CIRCUMSTANCES SHALL INDIAN TYPE FOUNDRY’S LIABILITY EXCEED THE REPLACEMENT COST OF THE SOFTWARE.  +IF LICENSEE CHOOSES TO ACCESS THE FONT SOFTWARE THROUGH A CODE (API), IT MAY HAVE A DIRECT IMPACT ON LICENSEE'S WEBSITE OR APPLICATIONS. INDIAN TYPE FOUNDRY IS NOT RESPONSIBLE OR LIABLE FOR ANY INTERRUPTION, MALFUNCTION, DOWNTIME OR OTHER FAILURE OF THE WEBSITE OR ITS API. + +07. Updates, Maintenance and Support Services +Licensor will not provide you with any support services for the Software under this Agreement. + +08. Termination  +Any breach of the terms of this agreement shall be a cause for termination, provided that such breach is notified in writing to the Licensee by the Licensor and the Licensee failed to rectify the breach within 30 days of the receipt of such notification.  +In the event of termination and without limitation of any remedies under law or equity, you must delete the Font Software and all copies thereof. Proof of this must be provided upon request of the Licensor.   +We reserve the right to claim damages for the violation of the conditions.  + +09. Final Provisions +If individual provisions of this agreement are or become invalid, the validity of the remaining provisions shall remain unaffected. Invalid provisions shall be replaced by mutual agreement by such provisions that are suitable to achieve the desired economic purpose, taking into account the interests of both parties. The same shall apply mutatis mutandis to the filling of any gaps which may arise in this agreement. +This contract is subject to laws of the Republic of India. Place of performance and exclusive place of jurisdiction for all disputes between the parties arising out of or in connection with this contract is, as far as legally permissible, Ahmedabad, India. +-  +Last Updated on 22 March 2021 +Copyright 2021 Indian Type Foundry. All rights reserved.  \ No newline at end of file diff --git a/frontend/fonts/bold.ttf b/frontend/fonts/bold.ttf new file mode 100644 index 0000000..8fcaeca Binary files /dev/null and b/frontend/fonts/bold.ttf differ diff --git a/frontend/fonts/light.ttf b/frontend/fonts/light.ttf new file mode 100644 index 0000000..9cfd75d Binary files /dev/null and b/frontend/fonts/light.ttf differ diff --git a/frontend/fonts/medium.ttf b/frontend/fonts/medium.ttf new file mode 100644 index 0000000..1b25d91 Binary files /dev/null and b/frontend/fonts/medium.ttf differ diff --git a/frontend/fonts/regular.ttf b/frontend/fonts/regular.ttf new file mode 100644 index 0000000..efa0664 Binary files /dev/null and b/frontend/fonts/regular.ttf differ diff --git a/frontend/ios/.gitignore b/frontend/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/frontend/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/frontend/ios/Flutter/AppFrameworkInfo.plist b/frontend/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/frontend/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/frontend/ios/Flutter/Debug.xcconfig b/frontend/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/frontend/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/frontend/ios/Flutter/Release.xcconfig b/frontend/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/frontend/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/frontend/ios/Runner.xcodeproj/project.pbxproj b/frontend/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d20f5e9 --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = nya.grounded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/ios/Runner/AppDelegate.swift b/frontend/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/frontend/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..a999268 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..71cf5dc Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..e5b35a4 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..5a65045 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..a2b12ac Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..295a5b9 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..c9d727a Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..e5b35a4 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..2a3ad33 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..83188e4 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..8e3fae7 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..e06342e Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..9e77e53 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..82b7cad Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..83188e4 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..bc5050b Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..30279a8 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..5f40e46 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..8184f8a Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..0c4983d Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..f67a3bd Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..9f447e1 --- /dev/null +++ b/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..cb0e069 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..00cabce --- /dev/null +++ b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..92ef668 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..ee90019 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..0c41fdb Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..8d2b7d5 --- /dev/null +++ b/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner/Base.lproj/Main.storyboard b/frontend/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/frontend/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner/Info.plist b/frontend/ios/Runner/Info.plist new file mode 100644 index 0000000..899903d --- /dev/null +++ b/frontend/ios/Runner/Info.plist @@ -0,0 +1,72 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Grounded + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + grounded + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIStatusBarHidden + + + diff --git a/frontend/ios/Runner/Runner-Bridging-Header.h b/frontend/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/frontend/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/frontend/ios/Runner/SceneDelegate.swift b/frontend/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/frontend/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/frontend/ios/RunnerTests/RunnerTests.swift b/frontend/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/frontend/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/lib/Grounded/about/external/data/Commitment.dart b/frontend/lib/Grounded/about/external/data/Commitment.dart new file mode 100644 index 0000000..0e12def --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Commitment.dart @@ -0,0 +1,129 @@ +import '../../internal/application/CommitmentClass.dart'; +import '../../internal/application/CommitmentStatus.dart'; +import '../../internal/application/CommitmentType.dart'; +import '../../internal/application/EnergyCost.dart'; +import '../../internal/application/ProofType.dart'; + +/// Something the user said they would do. A due *window*, not a due date — +/// "Mon 06:00-08:00" beats "Monday", because a window can actually close. +class Commitment { + String? id; + + CommitmentType type; + + CommitmentClass commitmentClass; + + String title; + + String category; + + DateTime? dueStart; + + DateTime? dueEnd; + + int estMinutes; + + EnergyCost energy; + + ProofType proofType; + + /// Minimum foreground minutes when proofType is Timer. + int proofTimerMinutes; + + String rrule; + + String? parentId; + + CommitmentStatus status; + + int deferralCount; + + Commitment({ + this.id, + this.type = CommitmentType.TASK, + this.commitmentClass = CommitmentClass.Standard, + this.title = "", + this.category = "", + this.dueStart, + this.dueEnd, + this.estMinutes = 0, + this.energy = EnergyCost.Medium, + this.proofType = ProofType.Honour, + this.proofTimerMinutes = 0, + this.rrule = "", + this.parentId, + this.status = CommitmentStatus.Open, + this.deferralCount = 0, + }); + + factory Commitment.fromJson(Map json) { + return Commitment( + id: json['id'], + type: getCommitmentType(json['type']), + commitmentClass: getCommitmentClass(json['commitmentClass']), + title: json['title'] ?? "", + category: json['category'] ?? "", + dueStart: DateTime.tryParse(json['dueStart'] ?? ""), + dueEnd: DateTime.tryParse(json['dueEnd'] ?? ""), + estMinutes: json['estMinutes'] ?? 0, + energy: getEnergyCost(json['energy']), + proofType: getProofType(json['proofType']), + proofTimerMinutes: json['proofTimerMinutes'] ?? 0, + rrule: json['rrule'] ?? "", + parentId: json['parentId'], + status: getCommitmentStatus(json['status']), + deferralCount: json['deferralCount'] ?? 0, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['type'] = type.name; + data['commitmentClass'] = commitmentClass.name; + data['title'] = title; + data['category'] = category; + data['dueStart'] = dueStart?.toIso8601String(); + data['dueEnd'] = dueEnd?.toIso8601String(); + data['estMinutes'] = estMinutes; + data['energy'] = energy.name; + data['proofType'] = proofType.name; + data['proofTimerMinutes'] = proofTimerMinutes; + data['rrule'] = rrule; + data['parentId'] = parentId; + data['status'] = status.name; + data['deferralCount'] = deferralCount; + return data; + } + + /// The window has closed. A commitment goes Overdue at close — it never + /// silently rolls over to today. + bool get windowClosed { + if (dueEnd == null) { + return false; + } + return DateTime.now().isAfter(dueEnd!); + } + + /// Whole days past the close of the window; 0 while still open. + int get daysOverdue { + if (dueEnd == null || !windowClosed) { + return 0; + } + return DateTime.now().difference(dueEnd!).inDays; + } + + /// Whether completing right now would count as a late complete rather than a + /// clean one. Late is recorded distinctly and never reduces debt to zero. + bool get wouldBeLate { + return windowClosed; + } + + /// Non-negotiables are never deferrable, at any count. + bool deferrableUnder(int maxDeferrals) { + if (commitmentClass == CommitmentClass.NonNegotiable) { + return false; + } + return deferralCount < maxDeferrals; + } +} diff --git a/frontend/lib/Grounded/about/external/data/CommitmentEvent.dart b/frontend/lib/Grounded/about/external/data/CommitmentEvent.dart new file mode 100644 index 0000000..90a447f --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/CommitmentEvent.dart @@ -0,0 +1,53 @@ +import '../../internal/application/EventType.dart'; + +/// Append-only history. This log — not the status field — is the source of +/// truth, and it is what makes excuse analysis and honest history possible. +class CommitmentEvent { + String? id; + + String commitmentId; + + EventType event; + + DateTime? at; + + String excuseText; + + String? excuseClusterId; + + String? proofRef; + + CommitmentEvent({ + this.id, + this.commitmentId = "", + this.event = EventType.CREATED, + this.at, + this.excuseText = "", + this.excuseClusterId, + this.proofRef, + }); + + factory CommitmentEvent.fromJson(Map json) { + return CommitmentEvent( + id: json['id'], + commitmentId: json['commitmentId'] ?? "", + event: getEventType(json['event']), + at: DateTime.tryParse(json['at'] ?? ""), + excuseText: json['excuseText'] ?? "", + excuseClusterId: json['excuseClusterId'], + proofRef: json['proofRef'], + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['commitmentId'] = commitmentId; + data['event'] = event.name; + data['at'] = at?.toIso8601String(); + data['excuseText'] = excuseText; + data['excuseClusterId'] = excuseClusterId; + data['proofRef'] = proofRef; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/DebtEntry.dart b/frontend/lib/Grounded/about/external/data/DebtEntry.dart new file mode 100644 index 0000000..6663053 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/DebtEntry.dart @@ -0,0 +1,46 @@ +/// One line of the debt ledger. [decayedValue] is what the entry is worth +/// today, after recency decay has been applied. +class DebtEntry { + String? id; + + double delta; + + String reason; + + String? commitmentId; + + DateTime? at; + + double decayedValue; + + DebtEntry({ + this.id, + this.delta = 0, + this.reason = "", + this.commitmentId, + this.at, + this.decayedValue = 0, + }); + + factory DebtEntry.fromJson(Map json) { + return DebtEntry( + id: json['id'], + delta: (json['delta'] ?? 0).toDouble(), + reason: json['reason'] ?? "", + commitmentId: json['commitmentId'], + at: DateTime.tryParse(json['at'] ?? ""), + decayedValue: (json['decayedValue'] ?? 0).toDouble(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['delta'] = delta; + data['reason'] = reason; + data['commitmentId'] = commitmentId; + data['at'] = at?.toIso8601String(); + data['decayedValue'] = decayedValue; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/ExcuseCluster.dart b/frontend/lib/Grounded/about/external/data/ExcuseCluster.dart new file mode 100644 index 0000000..fea7b16 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/ExcuseCluster.dart @@ -0,0 +1,72 @@ +/// A recurring excuse plus the pattern the app confronts you with, e.g. +/// "Too tired has appeared 14 times this month, 11 of them on gym days, +/// 9 of them after 7pm. Consider moving gym to morning." +class ExcuseCluster { + String? id; + + String label; + + int occurrences; + + /// Weekday histogram (1 = Monday) — where this excuse concentrates. + Map byWeekday; + + /// Hour-of-day histogram — when it concentrates. + Map byHour; + + /// The category this excuse most often attaches to. + String dominantCategory; + + /// The confrontation copy rendered to the user. + String insight; + + ExcuseCluster({ + this.id, + this.label = "", + this.occurrences = 0, + Map? byWeekday, + Map? byHour, + this.dominantCategory = "", + this.insight = "", + }) : byWeekday = byWeekday ?? {}, + byHour = byHour ?? {}; + + factory ExcuseCluster.fromJson(Map json) { + final Map weekdays = {}; + if (json['byWeekday'] != null) { + (json['byWeekday'] as Map).forEach((key, value) { + weekdays[int.tryParse(key) ?? 1] = value ?? 0; + }); + } + + final Map hours = {}; + if (json['byHour'] != null) { + (json['byHour'] as Map).forEach((key, value) { + hours[int.tryParse(key) ?? 0] = value ?? 0; + }); + } + + return ExcuseCluster( + id: json['id'], + label: json['label'] ?? "", + occurrences: json['occurrences'] ?? 0, + byWeekday: weekdays, + byHour: hours, + dominantCategory: json['dominantCategory'] ?? "", + insight: json['insight'] ?? "", + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['label'] = label; + data['occurrences'] = occurrences; + data['byWeekday'] = + byWeekday.map((key, value) => MapEntry(key.toString(), value)); + data['byHour'] = byHour.map((key, value) => MapEntry(key.toString(), value)); + data['dominantCategory'] = dominantCategory; + data['insight'] = insight; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/ExercisePrescription.dart b/frontend/lib/Grounded/about/external/data/ExercisePrescription.dart new file mode 100644 index 0000000..f14e05e --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/ExercisePrescription.dart @@ -0,0 +1,91 @@ +import '../../internal/application/ProgressionRule.dart'; + +class ExercisePrescription { + String? id; + + String sessionTemplateId; + + String exerciseId; + + String exerciseName; + + /// Primary muscle group, for weekly volume tracking. + String muscleGroup; + + int sets; + + int targetReps; + + /// For timed holds and intervals; 0 when the work is rep-based. + int targetTimeSeconds; + + int restSeconds; + + /// Eccentric-pause-concentric-pause, e.g. "3010". + String tempo; + + ProgressionRule progressionRule; + + /// Ground contacts per set, for the plyometric weekly ceiling. + int contactsPerSet; + + /// Whether this is a hard exercise for integrity scoring. Derived from + /// historical RPE rather than set statically. + bool hard; + + ExercisePrescription({ + this.id, + this.sessionTemplateId = "", + this.exerciseId = "", + this.exerciseName = "", + this.muscleGroup = "", + this.sets = 0, + this.targetReps = 0, + this.targetTimeSeconds = 0, + this.restSeconds = 0, + this.tempo = "", + this.progressionRule = ProgressionRule.Reps, + this.contactsPerSet = 0, + this.hard = false, + }); + + factory ExercisePrescription.fromJson(Map json) { + return ExercisePrescription( + id: json['id'], + sessionTemplateId: json['sessionTemplateId'] ?? "", + exerciseId: json['exerciseId'] ?? "", + exerciseName: json['exerciseName'] ?? "", + muscleGroup: json['muscleGroup'] ?? "", + sets: json['sets'] ?? 0, + targetReps: json['targetReps'] ?? 0, + targetTimeSeconds: json['targetTimeSeconds'] ?? 0, + restSeconds: json['restSeconds'] ?? 0, + tempo: json['tempo'] ?? "", + progressionRule: getProgressionRule(json['progressionRule']), + contactsPerSet: json['contactsPerSet'] ?? 0, + hard: json['hard'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['sessionTemplateId'] = sessionTemplateId; + data['exerciseId'] = exerciseId; + data['exerciseName'] = exerciseName; + data['muscleGroup'] = muscleGroup; + data['sets'] = sets; + data['targetReps'] = targetReps; + data['targetTimeSeconds'] = targetTimeSeconds; + data['restSeconds'] = restSeconds; + data['tempo'] = tempo; + data['progressionRule'] = progressionRule.name; + data['contactsPerSet'] = contactsPerSet; + data['hard'] = hard; + return data; + } + + int get plannedContacts { + return contactsPerSet * sets; + } +} diff --git a/frontend/lib/Grounded/about/external/data/Goal.dart b/frontend/lib/Grounded/about/external/data/Goal.dart new file mode 100644 index 0000000..ed3bd26 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Goal.dart @@ -0,0 +1,110 @@ +import '../../internal/application/CommitmentClass.dart'; + +/// The container a set of commitments belongs to — "Workout", "Thesis", +/// "Get the flat sorted". A goal owns its tasks through +/// [Commitment.parentId]; it never carries debt itself, because debt belongs +/// to the specific thing you said you would do, not the ambition behind it. +class Goal { + String? id; + + String title; + + String description; + + String category; + + /// The default class inherited by tasks created inside this goal. + CommitmentClass defaultClass; + + DateTime? startDate; + + /// Optional deadline for the goal as a whole. + DateTime? targetDate; + + /// Colour accent, stored as a hex string so the goal reads consistently + /// wherever it appears. + String colourHex; + + bool archived; + + // ── Derived, supplied by the server ───────────────────────────────────── + + int totalTasks; + + int completedTasks; + + int overdueTasks; + + /// Debt accrued across every task under this goal. + double debtContribution; + + Goal({ + this.id, + this.title = "", + this.description = "", + this.category = "", + this.defaultClass = CommitmentClass.Standard, + this.startDate, + this.targetDate, + this.colourHex = "", + this.archived = false, + this.totalTasks = 0, + this.completedTasks = 0, + this.overdueTasks = 0, + this.debtContribution = 0, + }); + + factory Goal.fromJson(Map json) { + return Goal( + id: json['id'], + title: json['title'] ?? "", + description: json['description'] ?? "", + category: json['category'] ?? "", + defaultClass: getCommitmentClass(json['defaultClass']), + startDate: DateTime.tryParse(json['startDate'] ?? ""), + targetDate: DateTime.tryParse(json['targetDate'] ?? ""), + colourHex: json['colourHex'] ?? "", + archived: json['archived'] ?? false, + totalTasks: json['totalTasks'] ?? 0, + completedTasks: json['completedTasks'] ?? 0, + overdueTasks: json['overdueTasks'] ?? 0, + debtContribution: (json['debtContribution'] ?? 0).toDouble(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['description'] = description; + data['category'] = category; + data['defaultClass'] = defaultClass.name; + data['startDate'] = startDate?.toIso8601String(); + data['targetDate'] = targetDate?.toIso8601String(); + data['colourHex'] = colourHex; + data['archived'] = archived; + return data; + } + + /// 0..1 across the goal's tasks. + double get progress { + if (totalTasks == 0) { + return 0; + } + return completedTasks / totalTasks; + } + + /// A goal is in trouble when a meaningful share of its tasks are past their + /// windows, not merely because one slipped. + bool get slipping { + if (totalTasks == 0) { + return false; + } + return overdueTasks / totalTasks >= 0.34; + } + + int get remainingTasks { + final int remaining = totalTasks - completedTasks; + return remaining > 0 ? remaining : 0; + } +} diff --git a/frontend/lib/Grounded/about/external/data/GroundedError.dart b/frontend/lib/Grounded/about/external/data/GroundedError.dart new file mode 100644 index 0000000..18e0cb4 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/GroundedError.dart @@ -0,0 +1,45 @@ +import 'Severity.dart'; + +class GroundedError { + double code; + String message; + String helper; + String title; + String severity; + + GroundedError( + {required this.code, + required this.message, + required this.helper, + required this.title, + required this.severity}); + + factory GroundedError.fromJson(Map json) { + return GroundedError( + code: json['code'], + message: json['message'], + helper: json['helper'], + title: json['title'], + severity: json['severity'], + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + data['message'] = message; + data['helper'] = helper; + data['title'] = title; + data['severity'] = severity; + return data; + } + + Severity getSeverityEnum(String severityString) { + for (Severity severity in Severity.values) { + if (severityString == severity.name) { + return severity; + } + } + return Severity.error; + } +} diff --git a/frontend/lib/Grounded/about/external/data/Habit.dart b/frontend/lib/Grounded/about/external/data/Habit.dart new file mode 100644 index 0000000..833f02c --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Habit.dart @@ -0,0 +1,73 @@ +/// Frequency-based rather than instance-based: a single miss costs nothing, +/// falling below the target in the rolling window is what accrues debt. +class Habit { + String? id; + + String title; + + String category; + + /// Target completions per rolling window, e.g. 5. + int targetPerWindow; + + /// Rolling window length in days, e.g. 7. + int windowDays; + + /// Completions inside the current window. + int completionsInWindow; + + /// Marked keystone once the data says its completion predicts day quality — + /// the app works this out after ~60 days rather than taking your word. + bool keystone; + + /// 0..1 correlation with overall day quality; -1 until there is enough data. + double predictiveStrength; + + Habit({ + this.id, + this.title = "", + this.category = "", + this.targetPerWindow = 0, + this.windowDays = 7, + this.completionsInWindow = 0, + this.keystone = false, + this.predictiveStrength = -1, + }); + + factory Habit.fromJson(Map json) { + return Habit( + id: json['id'], + title: json['title'] ?? "", + category: json['category'] ?? "", + targetPerWindow: json['targetPerWindow'] ?? 0, + windowDays: json['windowDays'] ?? 7, + completionsInWindow: json['completionsInWindow'] ?? 0, + keystone: json['keystone'] ?? false, + predictiveStrength: (json['predictiveStrength'] ?? -1).toDouble(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['category'] = category; + data['targetPerWindow'] = targetPerWindow; + data['windowDays'] = windowDays; + data['completionsInWindow'] = completionsInWindow; + data['keystone'] = keystone; + data['predictiveStrength'] = predictiveStrength; + return data; + } + + /// Behind target for the window — the only condition under which a habit + /// accrues debt. + bool get behindTarget { + return completionsInWindow < targetPerWindow; + } + + int get shortfall { + final int gap = targetPerWindow - completionsInWindow; + return gap > 0 ? gap : 0; + } +} diff --git a/frontend/lib/Grounded/about/external/data/LiveSession.dart b/frontend/lib/Grounded/about/external/data/LiveSession.dart new file mode 100644 index 0000000..a403fbf --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/LiveSession.dart @@ -0,0 +1,118 @@ +/// A task actively being run. This is what the full-screen runner and the +/// ongoing notification are both driven from. +class LiveSession { + String commitmentId; + + String title; + + String goalTitle; + + /// Wall-clock start of the run. + DateTime startedAt; + + /// Seconds of *foreground* work accumulated. Backgrounding the app stops + /// this accruing — that is the whole point of Timer proof. + int accumulatedSeconds; + + /// When the current active stretch began; null while paused. + DateTime? resumedAt; + + /// Seconds required before the run counts as proof. + int requiredSeconds; + + /// How many times the user left the app mid-run. Surfaced afterwards rather + /// than hidden, because leaving repeatedly is the behaviour worth seeing. + int backgroundedCount; + + LiveSession({ + this.commitmentId = "", + this.title = "", + this.goalTitle = "", + required this.startedAt, + this.accumulatedSeconds = 0, + this.resumedAt, + this.requiredSeconds = 0, + this.backgroundedCount = 0, + }); + + factory LiveSession.fromJson(Map json) { + return LiveSession( + commitmentId: json['commitmentId'] ?? "", + title: json['title'] ?? "", + goalTitle: json['goalTitle'] ?? "", + startedAt: DateTime.tryParse(json['startedAt'] ?? "") ?? DateTime.now(), + accumulatedSeconds: json['accumulatedSeconds'] ?? 0, + resumedAt: DateTime.tryParse(json['resumedAt'] ?? ""), + requiredSeconds: json['requiredSeconds'] ?? 0, + backgroundedCount: json['backgroundedCount'] ?? 0, + ); + } + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + data['title'] = title; + data['goalTitle'] = goalTitle; + data['startedAt'] = startedAt.toIso8601String(); + data['accumulatedSeconds'] = accumulatedSeconds; + data['resumedAt'] = resumedAt?.toIso8601String(); + data['requiredSeconds'] = requiredSeconds; + data['backgroundedCount'] = backgroundedCount; + return data; + } + + bool get running { + return resumedAt != null; + } + + /// Foreground seconds as of now, including the stretch in progress. This is + /// derived from wall-clock rather than counted by the ticker, so the value + /// stays correct across a screen-off period where timers are throttled. + int elapsedSeconds({DateTime? now}) { + if (resumedAt == null) { + return accumulatedSeconds; + } + final DateTime moment = now ?? DateTime.now(); + return accumulatedSeconds + moment.difference(resumedAt!).inSeconds; + } + + int remainingSeconds({DateTime? now}) { + final int remaining = requiredSeconds - elapsedSeconds(now: now); + return remaining > 0 ? remaining : 0; + } + + /// 0..1 toward the requirement. + double progress({DateTime? now}) { + if (requiredSeconds <= 0) { + return 0; + } + final double value = elapsedSeconds(now: now) / requiredSeconds; + return value > 1 ? 1 : value; + } + + /// The requirement has been met — completion is now allowed. + bool satisfied({DateTime? now}) { + if (requiredSeconds <= 0) { + return true; + } + return elapsedSeconds(now: now) >= requiredSeconds; + } + + /// Pause and bank the stretch that just ended. + void pause({DateTime? now}) { + if (resumedAt == null) { + return; + } + final DateTime moment = now ?? DateTime.now(); + accumulatedSeconds = + accumulatedSeconds + moment.difference(resumedAt!).inSeconds; + resumedAt = null; + } + + void resume({DateTime? now}) { + if (resumedAt != null) { + return; + } + resumedAt = now ?? DateTime.now(); + } +} diff --git a/frontend/lib/Grounded/about/external/data/Program.dart b/frontend/lib/Grounded/about/external/data/Program.dart new file mode 100644 index 0000000..b1ff8b4 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Program.dart @@ -0,0 +1,66 @@ +/// A training program. Deload weeks are scheduled and enforced — training +/// through a deload logs as non-compliance, same as skipping. +class Program { + String? id; + + String name; + + int weeks; + + int sessionsPerWeek; + + /// 1-based week indices that are deloads. + List deloadWeeks; + + /// Weekly ceiling on plyometric ground contacts. Plyo is the one modality + /// where the app stops you rather than pushes you. + int weeklyContactCeiling; + + /// Mandatory hours between high-intensity lower-body sessions. + int lowerBodyRecoveryHours; + + bool active; + + Program({ + this.id, + this.name = "", + this.weeks = 0, + this.sessionsPerWeek = 0, + List? deloadWeeks, + this.weeklyContactCeiling = 0, + this.lowerBodyRecoveryHours = 48, + this.active = false, + }) : deloadWeeks = deloadWeeks ?? []; + + factory Program.fromJson(Map json) { + return Program( + id: json['id'], + name: json['name'] ?? "", + weeks: json['weeks'] ?? 0, + sessionsPerWeek: json['sessionsPerWeek'] ?? 0, + deloadWeeks: json['deloadWeeks'] == null + ? [] + : (json['deloadWeeks'] as List).map((item) => item as int).toList(), + weeklyContactCeiling: json['weeklyContactCeiling'] ?? 0, + lowerBodyRecoveryHours: json['lowerBodyRecoveryHours'] ?? 48, + active: json['active'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['name'] = name; + data['weeks'] = weeks; + data['sessionsPerWeek'] = sessionsPerWeek; + data['deloadWeeks'] = deloadWeeks; + data['weeklyContactCeiling'] = weeklyContactCeiling; + data['lowerBodyRecoveryHours'] = lowerBodyRecoveryHours; + data['active'] = active; + return data; + } + + bool isDeloadWeek(int week) { + return deloadWeeks.contains(week); + } +} diff --git a/frontend/lib/Grounded/about/external/data/ReportCard.dart b/frontend/lib/Grounded/about/external/data/ReportCard.dart new file mode 100644 index 0000000..ea52635 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/ReportCard.dart @@ -0,0 +1,192 @@ +import 'ExcuseCluster.dart'; + +/// The weekly parent-teacher conference. One assigned action for next week — +/// not five. +class ReportCard { + String? id; + + DateTime? periodStart; + + DateTime? periodEnd; + + /// Completion rate 0..1, keyed by commitment class name. + Map completionByClass; + + /// Completion rate 0..1, keyed by category. + Map completionByCategory; + + /// Completion rate 0..1, keyed by weekday (1 = Monday). + Map completionByWeekday; + + /// The recurring window where things go to die, as an hour of the day. + int worstHour; + + /// Debt score sampled per day across the period. + List debtTrend; + + /// The commitments dodged most, highest first. + List deferralLeaderboard; + + List excuseTaxonomy; + + /// Estimation multiplier per category, surfaced here rather than hidden. + Map estimationAccuracy; + + /// Training adherence 0..1 across the period. + double trainingAdherence; + + double programIntegrity; + + /// The single assigned action for next week. + String assignedAction; + + /// Cosmetic but effective. + String grade; + + /// Rationed, specific praise — empty when nothing was genuinely earned. + String praise; + + ReportCard({ + this.id, + this.periodStart, + this.periodEnd, + Map? completionByClass, + Map? completionByCategory, + Map? completionByWeekday, + this.worstHour = -1, + List? debtTrend, + List? deferralLeaderboard, + List? excuseTaxonomy, + Map? estimationAccuracy, + this.trainingAdherence = 0, + this.programIntegrity = 0, + this.assignedAction = "", + this.grade = "", + this.praise = "", + }) : completionByClass = completionByClass ?? {}, + completionByCategory = completionByCategory ?? {}, + completionByWeekday = completionByWeekday ?? {}, + debtTrend = debtTrend ?? [], + deferralLeaderboard = deferralLeaderboard ?? [], + excuseTaxonomy = excuseTaxonomy ?? [], + estimationAccuracy = estimationAccuracy ?? {}; + + factory ReportCard.fromJson(Map json) { + final Map byClass = {}; + if (json['completionByClass'] != null) { + (json['completionByClass'] as Map).forEach((key, value) { + byClass[key] = (value ?? 0).toDouble(); + }); + } + + final Map byCategory = {}; + if (json['completionByCategory'] != null) { + (json['completionByCategory'] as Map) + .forEach((key, value) { + byCategory[key] = (value ?? 0).toDouble(); + }); + } + + final Map byWeekday = {}; + if (json['completionByWeekday'] != null) { + (json['completionByWeekday'] as Map) + .forEach((key, value) { + byWeekday[int.tryParse(key) ?? 1] = (value ?? 0).toDouble(); + }); + } + + final Map estimation = {}; + if (json['estimationAccuracy'] != null) { + (json['estimationAccuracy'] as Map) + .forEach((key, value) { + estimation[key] = (value ?? 1).toDouble(); + }); + } + + return ReportCard( + id: json['id'], + periodStart: DateTime.tryParse(json['periodStart'] ?? ""), + periodEnd: DateTime.tryParse(json['periodEnd'] ?? ""), + completionByClass: byClass, + completionByCategory: byCategory, + completionByWeekday: byWeekday, + worstHour: json['worstHour'] ?? -1, + debtTrend: json['debtTrend'] == null + ? [] + : (json['debtTrend'] as List) + .map((item) => (item ?? 0).toDouble() as double) + .toList(), + deferralLeaderboard: json['deferralLeaderboard'] == null + ? [] + : (json['deferralLeaderboard'] as List) + .map((item) => DeferralCount.fromJson(item)) + .toList(), + excuseTaxonomy: json['excuseTaxonomy'] == null + ? [] + : (json['excuseTaxonomy'] as List) + .map((item) => ExcuseCluster.fromJson(item)) + .toList(), + estimationAccuracy: estimation, + trainingAdherence: (json['trainingAdherence'] ?? 0).toDouble(), + programIntegrity: (json['programIntegrity'] ?? 0).toDouble(), + assignedAction: json['assignedAction'] ?? "", + grade: json['grade'] ?? "", + praise: json['praise'] ?? "", + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['periodStart'] = periodStart?.toIso8601String(); + data['periodEnd'] = periodEnd?.toIso8601String(); + data['completionByClass'] = completionByClass; + data['completionByCategory'] = completionByCategory; + data['completionByWeekday'] = + completionByWeekday.map((key, value) => MapEntry(key.toString(), value)); + data['worstHour'] = worstHour; + data['debtTrend'] = debtTrend; + data['deferralLeaderboard'] = + deferralLeaderboard.map((item) => item.toJson()).toList(); + data['excuseTaxonomy'] = + excuseTaxonomy.map((item) => item.toJson()).toList(); + data['estimationAccuracy'] = estimationAccuracy; + data['trainingAdherence'] = trainingAdherence; + data['programIntegrity'] = programIntegrity; + data['assignedAction'] = assignedAction; + data['grade'] = grade; + data['praise'] = praise; + return data; + } +} + +/// One row of the deferral leaderboard — the tasks dodged most. +class DeferralCount { + String commitmentId; + + String title; + + int count; + + DeferralCount({ + this.commitmentId = "", + this.title = "", + this.count = 0, + }); + + factory DeferralCount.fromJson(Map json) { + return DeferralCount( + commitmentId: json['commitmentId'] ?? "", + title: json['title'] ?? "", + count: json['count'] ?? 0, + ); + } + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + data['title'] = title; + data['count'] = count; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/ResponseState.dart b/frontend/lib/Grounded/about/external/data/ResponseState.dart new file mode 100644 index 0000000..e0e09fd --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/ResponseState.dart @@ -0,0 +1 @@ +enum ResponseState { Success, Failure, Pending } diff --git a/frontend/lib/Grounded/about/external/data/RoutineChain.dart b/frontend/lib/Grounded/about/external/data/RoutineChain.dart new file mode 100644 index 0000000..14cb5e4 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/RoutineChain.dart @@ -0,0 +1,97 @@ +/// An ordered sequence where the chain, not the step, is the unit of +/// completion. Breaking mid-way logs partial. +class RoutineChain { + String? id; + + String title; + + List steps; + + /// Index of the step reached when the chain last broke; -1 when clean. + int lastBreakIndex; + + RoutineChain({ + this.id, + this.title = "", + List? steps, + this.lastBreakIndex = -1, + }) : steps = steps ?? []; + + factory RoutineChain.fromJson(Map json) { + return RoutineChain( + id: json['id'], + title: json['title'] ?? "", + steps: json['steps'] == null + ? [] + : (json['steps'] as List) + .map((item) => RoutineStep.fromJson(item)) + .toList(), + lastBreakIndex: json['lastBreakIndex'] ?? -1, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['steps'] = steps.map((item) => item.toJson()).toList(); + data['lastBreakIndex'] = lastBreakIndex; + return data; + } + + int get completedSteps { + return steps.where((step) => step.completed).length; + } + + /// 0..1 — a partially run chain is recorded as partial, not as done. + double get partialCompletion { + if (steps.isEmpty) { + return 0; + } + return completedSteps / steps.length; + } + + bool get complete { + return steps.isNotEmpty && completedSteps == steps.length; + } +} + +class RoutineStep { + String? id; + + String title; + + int order; + + int timerSeconds; + + bool completed; + + RoutineStep({ + this.id, + this.title = "", + this.order = 0, + this.timerSeconds = 0, + this.completed = false, + }); + + factory RoutineStep.fromJson(Map json) { + return RoutineStep( + id: json['id'], + title: json['title'] ?? "", + order: json['order'] ?? 0, + timerSeconds: json['timerSeconds'] ?? 0, + completed: json['completed'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['order'] = order; + data['timerSeconds'] = timerSeconds; + data['completed'] = completed; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/SessionLog.dart b/frontend/lib/Grounded/about/external/data/SessionLog.dart new file mode 100644 index 0000000..a6b3bf8 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/SessionLog.dart @@ -0,0 +1,105 @@ +import 'SetLog.dart'; + +class SessionLog { + String? id; + + String templateId; + + String templateName; + + DateTime? startedAt; + + DateTime? endedAt; + + /// Session RPE 1..10. + double sessionRpe; + + /// Readiness check-in at session start. + int sleepScore; + + int sorenessScore; + + int motivationScore; + + /// Did you do the session, or a watered-down version of it? + double integrityScore; + + bool duringDeload; + + List sets; + + SessionLog({ + this.id, + this.templateId = "", + this.templateName = "", + this.startedAt, + this.endedAt, + this.sessionRpe = 0, + this.sleepScore = 0, + this.sorenessScore = 0, + this.motivationScore = 0, + this.integrityScore = 0, + this.duringDeload = false, + List? sets, + }) : sets = sets ?? []; + + factory SessionLog.fromJson(Map json) { + return SessionLog( + id: json['id'], + templateId: json['templateId'] ?? "", + templateName: json['templateName'] ?? "", + startedAt: DateTime.tryParse(json['startedAt'] ?? ""), + endedAt: DateTime.tryParse(json['endedAt'] ?? ""), + sessionRpe: (json['sessionRpe'] ?? 0).toDouble(), + sleepScore: json['sleepScore'] ?? 0, + sorenessScore: json['sorenessScore'] ?? 0, + motivationScore: json['motivationScore'] ?? 0, + integrityScore: (json['integrityScore'] ?? 0).toDouble(), + duringDeload: json['duringDeload'] ?? false, + sets: json['sets'] == null + ? [] + : (json['sets'] as List).map((item) => SetLog.fromJson(item)).toList(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['templateId'] = templateId; + data['templateName'] = templateName; + data['startedAt'] = startedAt?.toIso8601String(); + data['endedAt'] = endedAt?.toIso8601String(); + data['sessionRpe'] = sessionRpe; + data['sleepScore'] = sleepScore; + data['sorenessScore'] = sorenessScore; + data['motivationScore'] = motivationScore; + data['integrityScore'] = integrityScore; + data['duringDeload'] = duringDeload; + data['sets'] = sets.map((item) => item.toJson()).toList(); + return data; + } + + int get durationMinutes { + if (startedAt == null || endedAt == null) { + return 0; + } + return endedAt!.difference(startedAt!).inMinutes; + } + + /// Weekly tonnage contribution for loaded work. + double get tonnage { + double total = 0; + for (SetLog entry in sets) { + total = total + (entry.loadKg * entry.reps); + } + return total; + } + + int get contacts { + int total = 0; + for (SetLog entry in sets) { + total = total + entry.contacts; + } + return total; + } +} diff --git a/frontend/lib/Grounded/about/external/data/SessionTemplate.dart b/frontend/lib/Grounded/about/external/data/SessionTemplate.dart new file mode 100644 index 0000000..28dcadb --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/SessionTemplate.dart @@ -0,0 +1,53 @@ +import 'ExercisePrescription.dart'; + +class SessionTemplate { + String? id; + + String programId; + + /// 0-based day within the training week. + int dayIndex; + + String name; + + /// True when this session loads the lower body hard enough to require the + /// program recovery gap before the next one. + bool highIntensityLowerBody; + + List prescriptions; + + SessionTemplate({ + this.id, + this.programId = "", + this.dayIndex = 0, + this.name = "", + this.highIntensityLowerBody = false, + List? prescriptions, + }) : prescriptions = prescriptions ?? []; + + factory SessionTemplate.fromJson(Map json) { + return SessionTemplate( + id: json['id'], + programId: json['programId'] ?? "", + dayIndex: json['dayIndex'] ?? 0, + name: json['name'] ?? "", + highIntensityLowerBody: json['highIntensityLowerBody'] ?? false, + prescriptions: json['prescriptions'] == null + ? [] + : (json['prescriptions'] as List) + .map((item) => ExercisePrescription.fromJson(item)) + .toList(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['programId'] = programId; + data['dayIndex'] = dayIndex; + data['name'] = name; + data['highIntensityLowerBody'] = highIntensityLowerBody; + data['prescriptions'] = prescriptions.map((item) => item.toJson()).toList(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/SetLog.dart b/frontend/lib/Grounded/about/external/data/SetLog.dart new file mode 100644 index 0000000..7f4a8c8 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/SetLog.dart @@ -0,0 +1,76 @@ +class SetLog { + String? id; + + String sessionLogId; + + String exerciseId; + + String exerciseName; + + String muscleGroup; + + int setNo; + + int reps; + + double loadKg; + + int timeSeconds; + + double rpe; + + /// False when the set was improvised rather than prescribed — this is what + /// separates doing the session from doing something adjacent to it. + bool isPrescribed; + + int contacts; + + SetLog({ + this.id, + this.sessionLogId = "", + this.exerciseId = "", + this.exerciseName = "", + this.muscleGroup = "", + this.setNo = 0, + this.reps = 0, + this.loadKg = 0, + this.timeSeconds = 0, + this.rpe = 0, + this.isPrescribed = true, + this.contacts = 0, + }); + + factory SetLog.fromJson(Map json) { + return SetLog( + id: json['id'], + sessionLogId: json['sessionLogId'] ?? "", + exerciseId: json['exerciseId'] ?? "", + exerciseName: json['exerciseName'] ?? "", + muscleGroup: json['muscleGroup'] ?? "", + setNo: json['setNo'] ?? 0, + reps: json['reps'] ?? 0, + loadKg: (json['loadKg'] ?? 0).toDouble(), + timeSeconds: json['timeSeconds'] ?? 0, + rpe: (json['rpe'] ?? 0).toDouble(), + isPrescribed: json['isPrescribed'] ?? true, + contacts: json['contacts'] ?? 0, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['sessionLogId'] = sessionLogId; + data['exerciseId'] = exerciseId; + data['exerciseName'] = exerciseName; + data['muscleGroup'] = muscleGroup; + data['setNo'] = setNo; + data['reps'] = reps; + data['loadKg'] = loadKg; + data['timeSeconds'] = timeSeconds; + data['rpe'] = rpe; + data['isPrescribed'] = isPrescribed; + data['contacts'] = contacts; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/Severity.dart b/frontend/lib/Grounded/about/external/data/Severity.dart new file mode 100644 index 0000000..07d0890 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Severity.dart @@ -0,0 +1 @@ +enum Severity { warning, alert, message, error } diff --git a/frontend/lib/Grounded/about/external/data/StandingChange.dart b/frontend/lib/Grounded/about/external/data/StandingChange.dart new file mode 100644 index 0000000..81f7100 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/StandingChange.dart @@ -0,0 +1,41 @@ +import '../../internal/application/Standing.dart'; + +class StandingChange { + String? id; + + Standing from; + + Standing to; + + DateTime? at; + + String trigger; + + StandingChange({ + this.id, + this.from = Standing.Good, + this.to = Standing.Good, + this.at, + this.trigger = "", + }); + + factory StandingChange.fromJson(Map json) { + return StandingChange( + id: json['id'], + from: getStanding(json['from']), + to: getStanding(json['to']), + at: DateTime.tryParse(json['at'] ?? ""), + trigger: json['trigger'] ?? "", + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['from'] = from.name; + data['to'] = to.name; + data['at'] = at?.toIso8601String(); + data['trigger'] = trigger; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/SystemResponse.dart b/frontend/lib/Grounded/about/external/data/SystemResponse.dart new file mode 100644 index 0000000..dabd57e --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/SystemResponse.dart @@ -0,0 +1,40 @@ +import 'ResponseState.dart'; + +class SystemResponse { + String key; + + String value; + + String description; + + ResponseState state; + + SystemResponse(this.key, this.value, this.description, this.state); + + factory SystemResponse.fromJsonMap(Map json) { + return SystemResponse( + json['key'] ?? "", + json['value'] ?? "", + json['description'] ?? "", + _state(json['state']), + ); + } + + static ResponseState _state(String? name) { + for (ResponseState value in ResponseState.values) { + if (value.name == name) { + return value; + } + } + return ResponseState.Success; + } + + Map toJson() { + final Map data = {}; + data['key'] = key; + data['value'] = value; + data['description'] = description; + data['state'] = state.name; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/request/CommitmentsRequest.dart b/frontend/lib/Grounded/about/external/data/pages/request/CommitmentsRequest.dart new file mode 100644 index 0000000..2e5b1bb --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/request/CommitmentsRequest.dart @@ -0,0 +1,21 @@ +import 'PageAndSort.dart'; + +class CommitmentsRequest { + PageAndSort? query; + + /// ISO day the plan is being requested for; empty means today. + String day; + + /// Filter by status name; empty means all. + String status; + + CommitmentsRequest({this.query, this.day = "", this.status = ""}); + + Map toJson() { + final Map data = {}; + data['query'] = query?.toJson(); + data['day'] = day; + data['status'] = status; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/request/HistoryRequest.dart b/frontend/lib/Grounded/about/external/data/pages/request/HistoryRequest.dart new file mode 100644 index 0000000..3269daa --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/request/HistoryRequest.dart @@ -0,0 +1,14 @@ +import 'PageAndSort.dart'; + +/// Every list endpoint takes this shape. Never inline flat sort/page fields. +class HistoryRequest { + PageAndSort? query; + + HistoryRequest({this.query}); + + Map toJson() { + final Map data = {}; + data['query'] = query?.toJson(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/request/PageAndSort.dart b/frontend/lib/Grounded/about/external/data/pages/request/PageAndSort.dart new file mode 100644 index 0000000..b718485 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/request/PageAndSort.dart @@ -0,0 +1,17 @@ +import 'Pageable.dart'; +import 'Sort.dart'; + +class PageAndSort { + Sort? sort; + + Pageable? page; + + PageAndSort({this.sort, this.page}); + + Map toJson() { + final Map data = {}; + data['sort'] = sort?.toJson(); + data['page'] = page?.toJson(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/request/Pageable.dart b/frontend/lib/Grounded/about/external/data/pages/request/Pageable.dart new file mode 100644 index 0000000..24e5976 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/request/Pageable.dart @@ -0,0 +1,20 @@ +class Pageable { + int offset; + + int pageNumber; + + int pageSize; + + int paged; + + Pageable(this.offset, this.pageNumber, this.pageSize, this.paged); + + Map toJson() { + final Map data = {}; + data['offset'] = offset; + data['pageNumber'] = pageNumber; + data['pageSize'] = pageSize; + data['paged'] = paged; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/request/Sort.dart b/frontend/lib/Grounded/about/external/data/pages/request/Sort.dart new file mode 100644 index 0000000..9af5b4d --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/request/Sort.dart @@ -0,0 +1,14 @@ +class Sort { + String direction; + + String field; + + Sort(this.direction, this.field); + + Map toJson() { + final Map data = {}; + data['direction'] = direction; + data['field'] = field; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/response/CommitmentEventPage.dart b/frontend/lib/Grounded/about/external/data/pages/response/CommitmentEventPage.dart new file mode 100644 index 0000000..e4fa937 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/response/CommitmentEventPage.dart @@ -0,0 +1,47 @@ +import '../../CommitmentEvent.dart'; + +class CommitmentEventPage { + int number; + + int size; + + int totalElements; + + int totalPages; + + int numberOfElements; + + bool first; + + bool last; + + List content; + + CommitmentEventPage({ + this.number = 0, + this.size = 0, + this.totalElements = 0, + this.totalPages = 0, + this.numberOfElements = 0, + this.first = true, + this.last = true, + List? content, + }) : content = content ?? []; + + factory CommitmentEventPage.fromJson(Map json) { + return CommitmentEventPage( + number: json['number'] ?? 0, + size: json['size'] ?? 0, + totalElements: json['totalElements'] ?? 0, + totalPages: json['totalPages'] ?? 0, + numberOfElements: json['numberOfElements'] ?? 0, + first: json['first'] ?? true, + last: json['last'] ?? true, + content: json['content'] == null + ? [] + : (json['content'] as List) + .map((item) => CommitmentEvent.fromJson(item)) + .toList(), + ); + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/response/CommitmentPage.dart b/frontend/lib/Grounded/about/external/data/pages/response/CommitmentPage.dart new file mode 100644 index 0000000..b30af0d --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/response/CommitmentPage.dart @@ -0,0 +1,47 @@ +import '../../Commitment.dart'; + +class CommitmentPage { + int number; + + int size; + + int totalElements; + + int totalPages; + + int numberOfElements; + + bool first; + + bool last; + + List content; + + CommitmentPage({ + this.number = 0, + this.size = 0, + this.totalElements = 0, + this.totalPages = 0, + this.numberOfElements = 0, + this.first = true, + this.last = true, + List? content, + }) : content = content ?? []; + + factory CommitmentPage.fromJson(Map json) { + return CommitmentPage( + number: json['number'] ?? 0, + size: json['size'] ?? 0, + totalElements: json['totalElements'] ?? 0, + totalPages: json['totalPages'] ?? 0, + numberOfElements: json['numberOfElements'] ?? 0, + first: json['first'] ?? true, + last: json['last'] ?? true, + content: json['content'] == null + ? [] + : (json['content'] as List) + .map((item) => Commitment.fromJson(item)) + .toList(), + ); + } +} diff --git a/frontend/lib/Grounded/about/external/data/pages/response/SessionLogPage.dart b/frontend/lib/Grounded/about/external/data/pages/response/SessionLogPage.dart new file mode 100644 index 0000000..757dd22 --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/pages/response/SessionLogPage.dart @@ -0,0 +1,47 @@ +import '../../SessionLog.dart'; + +class SessionLogPage { + int number; + + int size; + + int totalElements; + + int totalPages; + + int numberOfElements; + + bool first; + + bool last; + + List content; + + SessionLogPage({ + this.number = 0, + this.size = 0, + this.totalElements = 0, + this.totalPages = 0, + this.numberOfElements = 0, + this.first = true, + this.last = true, + List? content, + }) : content = content ?? []; + + factory SessionLogPage.fromJson(Map json) { + return SessionLogPage( + number: json['number'] ?? 0, + size: json['size'] ?? 0, + totalElements: json['totalElements'] ?? 0, + totalPages: json['totalPages'] ?? 0, + numberOfElements: json['numberOfElements'] ?? 0, + first: json['first'] ?? true, + last: json['last'] ?? true, + content: json['content'] == null + ? [] + : (json['content'] as List) + .map((item) => SessionLog.fromJson(item)) + .toList(), + ); + } +} diff --git a/frontend/lib/Grounded/about/external/initial/AbandonRequest.dart b/frontend/lib/Grounded/about/external/initial/AbandonRequest.dart new file mode 100644 index 0000000..01aad94 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/AbandonRequest.dart @@ -0,0 +1,16 @@ +/// Abandoning costs the most debt of all, so it is always explicit and always +/// carries a reason. +class AbandonRequest { + String commitmentId; + + String reason; + + AbandonRequest({this.commitmentId = "", this.reason = ""}); + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + data['reason'] = reason; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/AmnestyRequest.dart b/frontend/lib/Grounded/about/external/initial/AmnestyRequest.dart new file mode 100644 index 0000000..7e51f36 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/AmnestyRequest.dart @@ -0,0 +1,13 @@ +/// Spend one of the rationed monthly tokens to wipe an item debt, no questions +/// asked. +class AmnestyRequest { + String commitmentId; + + AmnestyRequest({this.commitmentId = ""}); + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart b/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart new file mode 100644 index 0000000..d112505 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart @@ -0,0 +1,58 @@ +/// Create or update a commitment. +class CommitmentRequest { + String? id; + + String type; + + String commitmentClass; + + String title; + + String category; + + String dueStart; + + String dueEnd; + + int estMinutes; + + String energy; + + String proofType; + + int proofTimerMinutes; + + String rrule; + + CommitmentRequest({ + this.id, + this.type = "TASK", + this.commitmentClass = "Standard", + this.title = "", + this.category = "", + this.dueStart = "", + this.dueEnd = "", + this.estMinutes = 0, + this.energy = "Medium", + this.proofType = "Honour", + this.proofTimerMinutes = 0, + this.rrule = "", + }); + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['type'] = type; + data['commitmentClass'] = commitmentClass; + data['title'] = title; + data['category'] = category; + data['dueStart'] = dueStart; + data['dueEnd'] = dueEnd; + data['estMinutes'] = estMinutes; + data['energy'] = energy; + data['proofType'] = proofType; + data['proofTimerMinutes'] = proofTimerMinutes; + data['rrule'] = rrule; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/CompletionRequest.dart b/frontend/lib/Grounded/about/external/initial/CompletionRequest.dart new file mode 100644 index 0000000..3ab630f --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/CompletionRequest.dart @@ -0,0 +1,38 @@ +/// Completion always carries its proof. The server decides Complete vs Late +/// Complete from the window, never the client. +class CompletionRequest { + String commitmentId; + + String proofType; + + /// Reference to the uploaded artefact — photo id, timer session id, geofence + /// dwell id or witness confirmation id. + String proofRef; + + /// Foreground seconds actually run, for Timer proof. + int timerSeconds; + + double latitude; + + double longitude; + + CompletionRequest({ + this.commitmentId = "", + this.proofType = "Honour", + this.proofRef = "", + this.timerSeconds = 0, + this.latitude = 0, + this.longitude = 0, + }); + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + data['proofType'] = proofType; + data['proofRef'] = proofRef; + data['timerSeconds'] = timerSeconds; + data['latitude'] = latitude; + data['longitude'] = longitude; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/DeferralRequest.dart b/frontend/lib/Grounded/about/external/initial/DeferralRequest.dart new file mode 100644 index 0000000..e354d14 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/DeferralRequest.dart @@ -0,0 +1,27 @@ +/// Deferral always carries an excuse. Free text, minimum length enforced, no +/// template buttons — the friction is the point. +class DeferralRequest { + String commitmentId; + + String excuseText; + + String newDueStart; + + String newDueEnd; + + DeferralRequest({ + this.commitmentId = "", + this.excuseText = "", + this.newDueStart = "", + this.newDueEnd = "", + }); + + Map toJson() { + final Map data = {}; + data['commitmentId'] = commitmentId; + data['excuseText'] = excuseText; + data['newDueStart'] = newDueStart; + data['newDueEnd'] = newDueEnd; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/DeviceRequest.dart b/frontend/lib/Grounded/about/external/initial/DeviceRequest.dart new file mode 100644 index 0000000..2e68a7e --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/DeviceRequest.dart @@ -0,0 +1,25 @@ +class DeviceRequest { + String identifier; + + String model; + + String platform; + + String version; + + DeviceRequest({ + this.identifier = "", + this.model = "", + this.platform = "", + this.version = "", + }); + + Map toJson() { + final Map data = {}; + data['identifier'] = identifier; + data['model'] = model; + data['platform'] = platform; + data['version'] = version; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/GoalRequest.dart b/frontend/lib/Grounded/about/external/initial/GoalRequest.dart new file mode 100644 index 0000000..686029c --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/GoalRequest.dart @@ -0,0 +1,41 @@ +class GoalRequest { + String? id; + + String title; + + String description; + + String category; + + String defaultClass; + + String startDate; + + String targetDate; + + String colourHex; + + GoalRequest({ + this.id, + this.title = "", + this.description = "", + this.category = "", + this.defaultClass = "Standard", + this.startDate = "", + this.targetDate = "", + this.colourHex = "", + }); + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['description'] = description; + data['category'] = category; + data['defaultClass'] = defaultClass; + data['startDate'] = startDate; + data['targetDate'] = targetDate; + data['colourHex'] = colourHex; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/IdRequest.dart b/frontend/lib/Grounded/about/external/initial/IdRequest.dart new file mode 100644 index 0000000..66d9beb --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/IdRequest.dart @@ -0,0 +1,11 @@ +class IdRequest { + String id; + + IdRequest({this.id = ""}); + + Map toJson() { + final Map data = {}; + data['id'] = id; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/LoginData.dart b/frontend/lib/Grounded/about/external/initial/LoginData.dart new file mode 100644 index 0000000..3014d3d --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/LoginData.dart @@ -0,0 +1,14 @@ +class LoginData { + String username; + + String password; + + LoginData({this.username = "", this.password = ""}); + + Map toJson() { + final Map data = {}; + data['username'] = username; + data['password'] = password; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/ReportCardRequest.dart b/frontend/lib/Grounded/about/external/initial/ReportCardRequest.dart new file mode 100644 index 0000000..c793b14 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/ReportCardRequest.dart @@ -0,0 +1,14 @@ +class ReportCardRequest { + String periodStart; + + String periodEnd; + + ReportCardRequest({this.periodStart = "", this.periodEnd = ""}); + + Map toJson() { + final Map data = {}; + data['periodStart'] = periodStart; + data['periodEnd'] = periodEnd; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/SessionLogRequest.dart b/frontend/lib/Grounded/about/external/initial/SessionLogRequest.dart new file mode 100644 index 0000000..00c19d0 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/SessionLogRequest.dart @@ -0,0 +1,45 @@ +class SessionLogRequest { + String? id; + + String templateId; + + String startedAt; + + String endedAt; + + double sessionRpe; + + int sleepScore; + + int sorenessScore; + + int motivationScore; + + List> sets; + + SessionLogRequest({ + this.id, + this.templateId = "", + this.startedAt = "", + this.endedAt = "", + this.sessionRpe = 0, + this.sleepScore = 0, + this.sorenessScore = 0, + this.motivationScore = 0, + List>? sets, + }) : sets = sets ?? >[]; + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['templateId'] = templateId; + data['startedAt'] = startedAt; + data['endedAt'] = endedAt; + data['sessionRpe'] = sessionRpe; + data['sleepScore'] = sleepScore; + data['sorenessScore'] = sorenessScore; + data['motivationScore'] = motivationScore; + data['sets'] = sets; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/SickModeRequest.dart b/frontend/lib/Grounded/about/external/initial/SickModeRequest.dart new file mode 100644 index 0000000..ad54e8c --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/SickModeRequest.dart @@ -0,0 +1,18 @@ +/// Pauses debt accrual entirely. Requires a reason and is logged in history. +class SickModeRequest { + bool enabled; + + String reason; + + String until; + + SickModeRequest({this.enabled = false, this.reason = "", this.until = ""}); + + Map toJson() { + final Map data = {}; + data['enabled'] = enabled; + data['reason'] = reason; + data['until'] = until; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/ToneRequest.dart b/frontend/lib/Grounded/about/external/initial/ToneRequest.dart new file mode 100644 index 0000000..edb6c95 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/ToneRequest.dart @@ -0,0 +1,11 @@ +class ToneRequest { + String tone; + + ToneRequest({this.tone = "Strict"}); + + Map toJson() { + final Map data = {}; + data['tone'] = tone; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/CapacityProfile.dart b/frontend/lib/Grounded/about/internal/application/CapacityProfile.dart new file mode 100644 index 0000000..732b59a --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CapacityProfile.dart @@ -0,0 +1,57 @@ +/// What the user can realistically absorb, learned from history rather than +/// asserted. Drives the capacity block at plan time. +class CapacityProfile { + /// p50 of historically completed minutes, keyed by weekday (1 = Monday). + Map completedMinutesByWeekday; + + /// Per-category estimation multiplier — you say 30min, you take 70min -> 2.3. + Map estimationMultipliers; + + CapacityProfile({ + Map? completedMinutesByWeekday, + Map? estimationMultipliers, + }) : completedMinutesByWeekday = completedMinutesByWeekday ?? {}, + estimationMultipliers = estimationMultipliers ?? {}; + + factory CapacityProfile.fromJson(Map json) { + final Map minutes = {}; + if (json['completedMinutesByWeekday'] != null) { + (json['completedMinutesByWeekday'] as Map) + .forEach((key, value) { + minutes[int.tryParse(key) ?? 1] = (value ?? 0).toDouble(); + }); + } + + final Map multipliers = {}; + if (json['estimationMultipliers'] != null) { + (json['estimationMultipliers'] as Map) + .forEach((key, value) { + multipliers[key] = (value ?? 1).toDouble(); + }); + } + + return CapacityProfile( + completedMinutesByWeekday: minutes, + estimationMultipliers: multipliers, + ); + } + + Map toJson() { + final Map data = {}; + data['completedMinutesByWeekday'] = completedMinutesByWeekday + .map((key, value) => MapEntry(key.toString(), value)); + data['estimationMultipliers'] = estimationMultipliers; + return data; + } + + /// The learned multiplier for a category, defaulting to honest 1.0 until + /// there is enough history to say otherwise. + double multiplierFor(String category) { + return estimationMultipliers[category] ?? 1.0; + } + + /// The p50 of what actually gets done on this weekday. + double capacityFor(int weekday) { + return completedMinutesByWeekday[weekday] ?? 0; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/CommitmentClass.dart b/frontend/lib/Grounded/about/internal/application/CommitmentClass.dart new file mode 100644 index 0000000..d62b3cf --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CommitmentClass.dart @@ -0,0 +1,44 @@ +/// The disciplinary weight class of a commitment. Central, not cosmetic — the +/// class decides debt weight, deferability and escalation harshness. +enum CommitmentClass { + /// Never auto-deferrable, heaviest debt weight, hardest escalation. + NonNegotiable, + + /// Normal weight, limited deferrals. + Standard, + + /// Nice-to-have, no debt on miss, auto-archives. + Elective, +} + +/// Debt weight w(class) from the debt formula. +double classWeight(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return 5.0; + case CommitmentClass.Standard: + return 2.0; + case CommitmentClass.Elective: + return 0.0; + } +} + +String classLabel(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return "Non-negotiable"; + case CommitmentClass.Standard: + return "Standard"; + case CommitmentClass.Elective: + return "Elective"; + } +} + +CommitmentClass getCommitmentClass(String? name) { + for (CommitmentClass value in CommitmentClass.values) { + if (value.name == name) { + return value; + } + } + return CommitmentClass.Standard; +} diff --git a/frontend/lib/Grounded/about/internal/application/CommitmentStatus.dart b/frontend/lib/Grounded/about/internal/application/CommitmentStatus.dart new file mode 100644 index 0000000..d1aa216 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CommitmentStatus.dart @@ -0,0 +1,18 @@ +enum CommitmentStatus { + Open, + Completed, + LateCompleted, + Overdue, + Deferred, + Abandoned, + Archived, +} + +CommitmentStatus getCommitmentStatus(String? name) { + for (CommitmentStatus value in CommitmentStatus.values) { + if (value.name == name) { + return value; + } + } + return CommitmentStatus.Open; +} diff --git a/frontend/lib/Grounded/about/internal/application/CommitmentType.dart b/frontend/lib/Grounded/about/internal/application/CommitmentType.dart new file mode 100644 index 0000000..5531f8d --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CommitmentType.dart @@ -0,0 +1,10 @@ +enum CommitmentType { TASK, SESSION, HABIT } + +CommitmentType getCommitmentType(String? name) { + for (CommitmentType value in CommitmentType.values) { + if (value.name == name) { + return value; + } + } + return CommitmentType.TASK; +} diff --git a/frontend/lib/Grounded/about/internal/application/DialogData.dart b/frontend/lib/Grounded/about/internal/application/DialogData.dart new file mode 100644 index 0000000..84cf0ec --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/DialogData.dart @@ -0,0 +1,7 @@ +class DialogData { + String title; + + String description; + + DialogData(this.title, this.description); +} diff --git a/frontend/lib/Grounded/about/internal/application/EnergyCost.dart b/frontend/lib/Grounded/about/internal/application/EnergyCost.dart new file mode 100644 index 0000000..095f087 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/EnergyCost.dart @@ -0,0 +1,10 @@ +enum EnergyCost { Low, Medium, High } + +EnergyCost getEnergyCost(String? name) { + for (EnergyCost value in EnergyCost.values) { + if (value.name == name) { + return value; + } + } + return EnergyCost.Medium; +} diff --git a/frontend/lib/Grounded/about/internal/application/EscalationTier.dart b/frontend/lib/Grounded/about/internal/application/EscalationTier.dart new file mode 100644 index 0000000..00e452f --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/EscalationTier.dart @@ -0,0 +1,11 @@ +/// Notification escalation ladder. Disappointment outperforms anger. +enum EscalationTier { Reminder, Nudge, Nag, Disappointed, Cold } + +EscalationTier getEscalationTier(String? name) { + for (EscalationTier value in EscalationTier.values) { + if (value.name == name) { + return value; + } + } + return EscalationTier.Reminder; +} diff --git a/frontend/lib/Grounded/about/internal/application/EventType.dart b/frontend/lib/Grounded/about/internal/application/EventType.dart new file mode 100644 index 0000000..bf78648 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/EventType.dart @@ -0,0 +1,20 @@ +/// Append-only commitment event log. The status field is never the source of +/// truth — this log is. +enum EventType { + CREATED, + COMPLETED, + LATE, + DEFERRED, + MISSED, + ABANDONED, + AMNESTY, +} + +EventType getEventType(String? name) { + for (EventType value in EventType.values) { + if (value.name == name) { + return value; + } + } + return EventType.CREATED; +} diff --git a/frontend/lib/Grounded/about/internal/application/MeDescription.dart b/frontend/lib/Grounded/about/internal/application/MeDescription.dart new file mode 100644 index 0000000..38c8ebd --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/MeDescription.dart @@ -0,0 +1,27 @@ +/// The device + session identity carried on every request. `name` and `id` are +/// stored pre-encrypted and become the `what` / `whom` headers. +class MeDescription { + String id; + + String name; + + String token; + + MeDescription({required this.id, required this.name, required this.token}); + + factory MeDescription.fromJson(Map json) { + return MeDescription( + id: json['id'] ?? "", + name: json['name'] ?? "", + token: json['token'] ?? "", + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['name'] = name; + data['token'] = token; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/NavigatorType.dart b/frontend/lib/Grounded/about/internal/application/NavigatorType.dart new file mode 100644 index 0000000..97556ab --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/NavigatorType.dart @@ -0,0 +1 @@ +enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain } diff --git a/frontend/lib/Grounded/about/internal/application/NotificationType.dart b/frontend/lib/Grounded/about/internal/application/NotificationType.dart new file mode 100644 index 0000000..a7daf1a --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/NotificationType.dart @@ -0,0 +1 @@ +enum NotificationType { info, success, error, warning } diff --git a/frontend/lib/Grounded/about/internal/application/Pair.dart b/frontend/lib/Grounded/about/internal/application/Pair.dart new file mode 100644 index 0000000..71d6d40 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/Pair.dart @@ -0,0 +1,7 @@ +class Pair { + String key; + + dynamic value; + + Pair(this.key, this.value); +} diff --git a/frontend/lib/Grounded/about/internal/application/ProgressionRule.dart b/frontend/lib/Grounded/about/internal/application/ProgressionRule.dart new file mode 100644 index 0000000..f755874 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/ProgressionRule.dart @@ -0,0 +1,42 @@ +/// Progression rules that actually apply to bodyweight training, where adding +/// plates is not the lever. +enum ProgressionRule { + /// 3x8 -> 3x12, then a harder variation. + Reps, + + /// Incline push-up -> flat -> decline -> archer -> one-arm. + Leverage, + + /// Slower tempo, longer holds, same reps. + TimeUnderTension, + + /// Same work, less rest. + Density, + + /// Added external load. + Load, +} + +String progressionLabel(ProgressionRule value) { + switch (value) { + case ProgressionRule.Reps: + return "Rep progression"; + case ProgressionRule.Leverage: + return "Leverage progression"; + case ProgressionRule.TimeUnderTension: + return "Time under tension"; + case ProgressionRule.Density: + return "Density"; + case ProgressionRule.Load: + return "Load"; + } +} + +ProgressionRule getProgressionRule(String? name) { + for (ProgressionRule value in ProgressionRule.values) { + if (value.name == name) { + return value; + } + } + return ProgressionRule.Reps; +} diff --git a/frontend/lib/Grounded/about/internal/application/ProofType.dart b/frontend/lib/Grounded/about/internal/application/ProofType.dart new file mode 100644 index 0000000..a4f943a --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/ProofType.dart @@ -0,0 +1,46 @@ +/// The anti-cheat layer. Chosen per commitment at creation time. +enum ProofType { + /// Plain checkbox — for trivia only. + Honour, + + /// Camera-only, no gallery import, timestamp + optional GPS embedded. + Photo, + + /// Foreground session of >= X minutes; backgrounding pauses it. + Timer, + + /// Geofence dwell via passive location. + Location, + + /// Health platform confirms a workout occurred in the window. + Health, + + /// An accountability partner taps to confirm. + Witness, +} + +String proofLabel(ProofType value) { + switch (value) { + case ProofType.Honour: + return "Honour"; + case ProofType.Photo: + return "Photo"; + case ProofType.Timer: + return "Timer"; + case ProofType.Location: + return "Location"; + case ProofType.Health: + return "Health"; + case ProofType.Witness: + return "Witness"; + } +} + +ProofType getProofType(String? name) { + for (ProofType value in ProofType.values) { + if (value.name == name) { + return value; + } + } + return ProofType.Honour; +} diff --git a/frontend/lib/Grounded/about/internal/application/Standing.dart b/frontend/lib/Grounded/about/internal/application/Standing.dart new file mode 100644 index 0000000..79c284a --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/Standing.dart @@ -0,0 +1,35 @@ +/// The current disciplinary state. Derived from debt, drives what the app +/// permits: Good -> Warned -> Grounded -> Lockdown. +enum Standing { Good, Warned, Grounded, Lockdown } + +String standingLabel(Standing value) { + switch (value) { + case Standing.Good: + return "Good standing"; + case Standing.Warned: + return "Warned"; + case Standing.Grounded: + return "Grounded"; + case Standing.Lockdown: + return "Lockdown"; + } +} + +/// Whether the user is allowed to create new commitments in this standing. +bool canAddCommitments(Standing value) { + return value == Standing.Good || value == Standing.Warned; +} + +/// Whether elective commitments are permitted in this standing. +bool canAddElectives(Standing value) { + return value == Standing.Good; +} + +Standing getStanding(String? name) { + for (Standing value in Standing.values) { + if (value.name == name) { + return value; + } + } + return Standing.Good; +} diff --git a/frontend/lib/Grounded/about/internal/application/TextType.dart b/frontend/lib/Grounded/about/internal/application/TextType.dart new file mode 100644 index 0000000..3067b56 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/TextType.dart @@ -0,0 +1,6 @@ +enum TextType { + Bold, + Light, + Regular, + Medium, +} diff --git a/frontend/lib/Grounded/about/internal/application/Token.dart b/frontend/lib/Grounded/about/internal/application/Token.dart new file mode 100644 index 0000000..c845ac6 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/Token.dart @@ -0,0 +1,34 @@ +class Token { + String accessToken; + + String refreshToken; + + String tokenType; + + int expiresIn; + + String scope; + + Token(this.accessToken, this.refreshToken, this.tokenType, this.expiresIn, + this.scope); + + factory Token.fromJsonMap(Map json) { + return Token( + json['access_token'] ?? "", + json['refresh_token'] ?? "", + json['token_type'] ?? "", + json['expires_in'] ?? 0, + json['scope'] ?? "", + ); + } + + Map toJson() { + final Map data = {}; + data['access_token'] = accessToken; + data['refresh_token'] = refreshToken; + data['token_type'] = tokenType; + data['expires_in'] = expiresIn; + data['scope'] = scope; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/ToneLevel.dart b/frontend/lib/Grounded/about/internal/application/ToneLevel.dart new file mode 100644 index 0000000..b3507a5 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/ToneLevel.dart @@ -0,0 +1,23 @@ +/// The tone slider. Hard-capped: no copy ever attacks the user's worth, only +/// their behaviour. +enum ToneLevel { Firm, Strict, DrillSergeant } + +String toneLabel(ToneLevel value) { + switch (value) { + case ToneLevel.Firm: + return "Firm"; + case ToneLevel.Strict: + return "Strict"; + case ToneLevel.DrillSergeant: + return "Drill Sergeant"; + } +} + +ToneLevel getToneLevel(String? name) { + for (ToneLevel value in ToneLevel.values) { + if (value.name == name) { + return value; + } + } + return ToneLevel.Strict; +} diff --git a/frontend/lib/Grounded/about/internal/application/UserDetails.dart b/frontend/lib/Grounded/about/internal/application/UserDetails.dart new file mode 100644 index 0000000..1eaec69 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/UserDetails.dart @@ -0,0 +1,57 @@ +import 'Standing.dart'; +import 'ToneLevel.dart'; + +class UserDetails { + String pic; + + String name; + + String timezone; + + ToneLevel tone; + + Standing standing; + + double debtScore; + + int amnestyTokens; + + bool sickMode; + + UserDetails({ + required this.pic, + required this.name, + this.timezone = "Africa/Nairobi", + this.tone = ToneLevel.Strict, + this.standing = Standing.Good, + this.debtScore = 0, + this.amnestyTokens = 0, + this.sickMode = false, + }); + + factory UserDetails.fromJson(Map json) { + return UserDetails( + pic: json['pic'] ?? "", + name: json['name'] ?? "", + timezone: json['timezone'] ?? "Africa/Nairobi", + tone: getToneLevel(json['tone']), + standing: getStanding(json['standing']), + debtScore: (json['debtScore'] ?? 0).toDouble(), + amnestyTokens: json['amnestyTokens'] ?? 0, + sickMode: json['sickMode'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['pic'] = pic; + data['name'] = name; + data['timezone'] = timezone; + data['tone'] = tone.name; + data['standing'] = standing.name; + data['debtScore'] = debtScore; + data['amnestyTokens'] = amnestyTokens; + data['sickMode'] = sickMode; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/file/ConnectFileStorage.dart b/frontend/lib/Grounded/about/internal/file/ConnectFileStorage.dart new file mode 100644 index 0000000..dedea01 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/file/ConnectFileStorage.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +abstract class ConnectFileStorage { + /// Persists proof bytes locally and returns the reference the completion + /// request carries. + Future saveProof(String name, Uint8List bytes); + + Future readProof(String reference); + + Future deleteProof(String reference); + + Future proofDirectory(); +} diff --git a/frontend/lib/Grounded/about/internal/file/FileStorage.dart b/frontend/lib/Grounded/about/internal/file/FileStorage.dart new file mode 100644 index 0000000..6c0882d --- /dev/null +++ b/frontend/lib/Grounded/about/internal/file/FileStorage.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path_provider/path_provider.dart'; + +import 'ConnectFileStorage.dart'; + +class FileStorage implements ConnectFileStorage { + static const String proofFolder = "proof"; + + @override + Future proofDirectory() async { + final Directory base = await getApplicationDocumentsDirectory(); + final Directory folder = Directory("${base.path}/$proofFolder"); + + if (!await folder.exists()) { + await folder.create(recursive: true); + } + + return folder.path; + } + + @override + Future saveProof(String name, Uint8List bytes) async { + final String folder = await proofDirectory(); + final File file = File("$folder/$name"); + await file.writeAsBytes(bytes); + return file.path; + } + + @override + Future readProof(String reference) async { + final File file = File(reference); + + if (!await file.exists()) { + return null; + } + + return await file.readAsBytes(); + } + + @override + Future deleteProof(String reference) async { + final File file = File(reference); + + if (!await file.exists()) { + return false; + } + + await file.delete(); + return true; + } +} diff --git a/frontend/lib/Grounded/comms/Comms.dart b/frontend/lib/Grounded/comms/Comms.dart new file mode 100644 index 0000000..17d3775 --- /dev/null +++ b/frontend/lib/Grounded/comms/Comms.dart @@ -0,0 +1,508 @@ +import 'package:dio/dio.dart'; + +import '../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../about/external/data/pages/request/HistoryRequest.dart'; +import '../about/external/initial/AbandonRequest.dart'; +import '../about/external/initial/AmnestyRequest.dart'; +import '../about/external/initial/CommitmentRequest.dart'; +import '../about/external/initial/CompletionRequest.dart'; +import '../about/external/initial/DeferralRequest.dart'; +import '../about/external/initial/DeviceRequest.dart'; +import '../about/external/initial/GoalRequest.dart'; +import '../about/external/initial/IdRequest.dart'; +import '../about/external/initial/LoginData.dart'; +import '../about/external/initial/ReportCardRequest.dart'; +import '../about/external/initial/SessionLogRequest.dart'; +import '../about/external/initial/SickModeRequest.dart'; +import '../about/external/initial/ToneRequest.dart'; +import '../about/internal/application/MeDescription.dart'; +import '../about/internal/application/Pair.dart'; +import '../configs/Env.dart'; +import '../memory/ConnectInternalMemory.dart'; +import 'CommsDirections.dart'; +import 'ConnectComms.dart'; + +class Comms implements ConnectComms { + Dio dio = Dio(); + + ConnectInternalMemory helper; + + Comms(this.helper); + + /// Builds the identity headers and resolves the URL. Headers are reset on + /// every call so a stale Authorization can never leak into an auth-flow + /// request. + Future getRequestHeaders(String url, String urlData) async { + dio.options.headers = {}; + + MeDescription data = await helper.getMyDescription(); + + if (data.token.isNotEmpty && url != deviceToken) { + dio.options.headers["Authorization"] = "Bearer ${data.token}"; + } + + if (url == logoutRequest) { + dio.options.headers.remove("Authorization"); + } + + dio.options.contentType = Headers.jsonContentType; + + dio.options.responseType = ResponseType.json; + + // The backend identity contract: what = encrypted access code, + // whom = encrypted device id, version = the app build. + dio.options.headers["what"] = data.name; + + dio.options.headers["whom"] = data.id; + + dio.options.headers["version"] = localisedAppVersion; + + return Pair("${_routeFor(url)}$url$urlData", dio.options.headers); + } + + /// Auth-flow paths go to Prospect, training paths to Training, everything + /// else to the Discipline service. + String _routeFor(String url) { + if (url == deviceReg || + url == deviceToken || + url == loginUser || + url == logoutRequest || + url == aboutMe || + url == accountRecovery || + url.startsWith('InAugurate/')) { + return prospectRoute; + } + + if (url.startsWith('Program/') || + url.startsWith('Session/') || + url.startsWith('Metrics/')) { + return trainingRoute; + } + + return disciplineRoute; + } + + // ── Auth ────────────────────────────────────────────────────────────────── + + @override + Future registerDevice(DeviceRequest request) async { + Pair navigation = await getRequestHeaders(deviceReg, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future registerDeviceToken(String token) async { + Pair navigation = await getRequestHeaders(deviceToken, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: {"token": token}); + } + + @override + Future login(LoginData request) async { + Pair navigation = await getRequestHeaders(loginUser, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future logout() async { + Pair navigation = await getRequestHeaders(logoutRequest, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future me() async { + Pair navigation = await getRequestHeaders(aboutMe, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + // ── Goals ───────────────────────────────────────────────────────────────── + + @override + Future getMyGoals(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myGoals, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveGoalEntry(GoalRequest request) async { + Pair navigation = await getRequestHeaders(saveGoal, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getGoalTasks(IdRequest request) async { + Pair navigation = await getRequestHeaders(goalTasks, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future archiveGoalEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(archiveGoal, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Commitments ─────────────────────────────────────────────────────────── + + @override + Future getTodayPlan(CommitmentsRequest request) async { + Pair navigation = await getRequestHeaders(todayPlan, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getMyCommitments(CommitmentsRequest request) async { + Pair navigation = await getRequestHeaders(myCommitments, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getOverdueQueue(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(overdueQueue, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveCommitmentEntry(CommitmentRequest request) async { + Pair navigation = await getRequestHeaders(saveCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future updateCommitmentEntry(CommitmentRequest request) async { + Pair navigation = await getRequestHeaders(updateCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future deleteCommitmentEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(deleteCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future completeCommitmentEntry(CompletionRequest request) async { + Pair navigation = await getRequestHeaders(completeCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future deferCommitmentEntry(DeferralRequest request) async { + Pair navigation = await getRequestHeaders(deferCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future abandonCommitmentEntry(AbandonRequest request) async { + Pair navigation = await getRequestHeaders(abandonCommitment, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getCommitmentHistory(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(commitmentHistory, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getCommitmentEvents(IdRequest request) async { + Pair navigation = await getRequestHeaders(commitmentEvents, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Capacity ────────────────────────────────────────────────────────────── + + @override + Future checkCapacity(CommitmentsRequest request) async { + Pair navigation = await getRequestHeaders(capacityCheck, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getCapacityProfileEntry() async { + Pair navigation = await getRequestHeaders(capacityProfilePath, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + // ── Debt & standing ─────────────────────────────────────────────────────── + + @override + Future getDebtSummary() async { + Pair navigation = await getRequestHeaders(debtSummary, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future getDebtLedger(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(debtLedger, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getDebtTrend(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(debtTrendPath, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getStanding() async { + Pair navigation = await getRequestHeaders(standingPath, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future getStandingHistory(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(standingHistory, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Excuses ─────────────────────────────────────────────────────────────── + + @override + Future getExcuseClusters(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(excuseClusters, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Proof ───────────────────────────────────────────────────────────────── + + @override + Future uploadPhotoProof(FormData request) async { + Pair navigation = await getRequestHeaders(uploadProofPhoto, ""); + dio.options.headers = navigation.value; + dio.options.contentType = Headers.multipartFormDataContentType; + return await dio.post(navigation.key, data: request); + } + + @override + Future submitTimerProofEntry(CompletionRequest request) async { + Pair navigation = await getRequestHeaders(submitTimerProof, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future submitLocationProofEntry(CompletionRequest request) async { + Pair navigation = await getRequestHeaders(submitLocationProof, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Guardrails ──────────────────────────────────────────────────────────── + + @override + Future spendAmnestyToken(AmnestyRequest request) async { + Pair navigation = await getRequestHeaders(spendAmnesty, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getAmnestyBalance() async { + Pair navigation = await getRequestHeaders(amnestyBalance, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future updateSickMode(SickModeRequest request) async { + Pair navigation = await getRequestHeaders(setSickMode, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future checkDistress() async { + Pair navigation = await getRequestHeaders(distressCheck, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future updateTone(ToneRequest request) async { + Pair navigation = await getRequestHeaders(setTone, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Habits & routines ───────────────────────────────────────────────────── + + @override + Future getMyHabits(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myHabits, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveHabitEntry(Map request) async { + Pair navigation = await getRequestHeaders(saveHabit, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future logHabitEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(logHabit, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getKeystoneHabits() async { + Pair navigation = await getRequestHeaders(keystoneHabits, ""); + dio.options.headers = navigation.value; + return await dio.get(navigation.key); + } + + @override + Future getMyRoutines(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myRoutines, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future logRoutineChainEntry(Map request) async { + Pair navigation = await getRequestHeaders(logRoutineChain, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Training ────────────────────────────────────────────────────────────── + + @override + Future getMyPrograms(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myPrograms, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveProgramEntry(Map request) async { + Pair navigation = await getRequestHeaders(saveProgram, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future activateProgramEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(activateProgram, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getProgramSessions(IdRequest request) async { + Pair navigation = await getRequestHeaders(programSessions, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future startSessionEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(startSession, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveSessionLogEntry(SessionLogRequest request) async { + Pair navigation = await getRequestHeaders(saveSessionLog, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getSessionHistory(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(sessionHistory, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getLastSessionFor(IdRequest request) async { + Pair navigation = await getRequestHeaders(lastSessionFor, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getWeeklyVolume(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(weeklyVolume, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getContactVolume(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(contactVolume, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getPersonalRecords(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(personalRecords, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Report card ─────────────────────────────────────────────────────────── + + @override + Future getWeeklyReportCard(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(weeklyReportCard, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getMonthlyReportCard(ReportCardRequest request) async { + Pair navigation = await getRequestHeaders(monthlyReportCard, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + // ── Notifications ───────────────────────────────────────────────────────── + + @override + Future getMyNotifications(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myNotifications, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future acknowledgeNudgeEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(acknowledgeNudge, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } +} diff --git a/frontend/lib/Grounded/comms/CommsDirections.dart b/frontend/lib/Grounded/comms/CommsDirections.dart new file mode 100644 index 0000000..fd618c3 --- /dev/null +++ b/frontend/lib/Grounded/comms/CommsDirections.dart @@ -0,0 +1,174 @@ +import '../configs/Env.dart'; + +/// The single switch between local and production. +bool isProd = false; + +// ── Service routes ──────────────────────────────────────────────────────────── +String prospectRoute = isProd + ? "$groundedRouteProd/Prospect/" + : "$groundedRouteLocal:40003/Prospect/"; + +String disciplineRoute = isProd + ? "$groundedRouteProd/Discipline/" + : "$groundedRouteLocal:40005/Discipline/"; + +String trainingRoute = isProd + ? "$groundedRouteProd/Training/" + : "$groundedRouteLocal:40004/Training/"; + +// ── Auth flow (Prospect) ────────────────────────────────────────────────────── +String deviceReg = "Device/NewDevice"; + +String deviceToken = 'Device/Note'; + +String loginUser = 'User/Login'; + +String logoutRequest = 'User/Logout'; + +String aboutMe = 'User/Me'; + +String accountRecovery = "User/RecoverRequest"; + +String inaugurateProspect = 'InAugurate/Prospect'; + +String inaugurateTerms = 'InAugurate/Terms'; + +String registerCredentialsLocation = 'InAugurate/UserAndPass'; + +// ── Goals ───────────────────────────────────────────────────────────────────── +String myGoals = 'Goal/Mine'; + +String saveGoal = 'Goal/Save'; + +String goalTasks = 'Goal/Tasks'; + +String archiveGoal = 'Goal/Archive'; + +// ── Commitments ─────────────────────────────────────────────────────────────── +String myCommitments = 'Commitment/Mine'; + +String todayPlan = 'Commitment/Today'; + +String overdueQueue = 'Commitment/Overdue'; + +String saveCommitment = 'Commitment/Save'; + +String updateCommitment = 'Commitment/Update'; + +String deleteCommitment = 'Commitment/Delete'; + +String completeCommitment = 'Commitment/Complete'; + +String deferCommitment = 'Commitment/Defer'; + +String abandonCommitment = 'Commitment/Abandon'; + +String commitmentHistory = 'Commitment/History'; + +String commitmentEvents = 'Commitment/Events'; + +// ── Capacity ────────────────────────────────────────────────────────────────── +String capacityCheck = 'Capacity/Check'; + +String capacityProfilePath = 'Capacity/Profile'; + +// ── Debt & standing ─────────────────────────────────────────────────────────── +String debtSummary = 'Debt/Summary'; + +String debtLedger = 'Debt/Ledger'; + +String debtTrendPath = 'Debt/Trend'; + +String standingPath = 'Standing/Current'; + +String standingHistory = 'Standing/History'; + +// ── Excuses ─────────────────────────────────────────────────────────────────── +String excuseClusters = 'Excuse/Clusters'; + +String excuseInsights = 'Excuse/Insights'; + +// ── Proof ───────────────────────────────────────────────────────────────────── +String uploadProofPhoto = 'Proof/PhotoUpload'; + +String submitTimerProof = 'Proof/Timer'; + +String submitLocationProof = 'Proof/Location'; + +String requestWitnessProof = 'Proof/Witness'; + +// ── Guardrails ──────────────────────────────────────────────────────────────── +String spendAmnesty = 'Guardrail/Amnesty'; + +String amnestyBalance = 'Guardrail/AmnestyBalance'; + +String setSickMode = 'Guardrail/SickMode'; + +String distressCheck = 'Guardrail/Distress'; + +String setTone = 'Guardrail/Tone'; + +// ── Habits & routines ───────────────────────────────────────────────────────── +String myHabits = 'Habit/Mine'; + +String saveHabit = 'Habit/Save'; + +String logHabit = 'Habit/Log'; + +String keystoneHabits = 'Habit/Keystone'; + +String myRoutines = 'Routine/Mine'; + +String saveRoutine = 'Routine/Save'; + +String logRoutineChain = 'Routine/Log'; + +// ── Training ────────────────────────────────────────────────────────────────── +String myPrograms = 'Program/Mine'; + +String saveProgram = 'Program/Save'; + +String activateProgram = 'Program/Activate'; + +String programSessions = 'Program/Sessions'; + +String saveSessionTemplate = 'Program/SaveTemplate'; + +String startSession = 'Session/Start'; + +String saveSessionLog = 'Session/Save'; + +String sessionHistory = 'Session/History'; + +String lastSessionFor = 'Session/Last'; + +String weeklyVolume = 'Session/WeeklyVolume'; + +String contactVolume = 'Session/Contacts'; + +String personalRecords = 'Session/Records'; + +String bodyMetrics = 'Metrics/Body'; + +String saveBodyMetric = 'Metrics/SaveBody'; + +// ── Report card ─────────────────────────────────────────────────────────────── +String weeklyReportCard = 'Report/Weekly'; + +String monthlyReportCard = 'Report/Monthly'; + +String reportHistory = 'Report/History'; + +// ── Notifications ───────────────────────────────────────────────────────────── +String myNotifications = 'Notifications/Mine'; + +String readNotification = 'Notifications/Read'; + +String acknowledgeNudge = 'Notifications/Acknowledge'; + +// ── Partners ────────────────────────────────────────────────────────────────── +String myPartners = 'Partner/Mine'; + +String invitePartner = 'Partner/Invite'; + +String partnerPolicy = 'Partner/Policy'; diff --git a/frontend/lib/Grounded/comms/ConnectComms.dart b/frontend/lib/Grounded/comms/ConnectComms.dart new file mode 100644 index 0000000..950d4ad --- /dev/null +++ b/frontend/lib/Grounded/comms/ConnectComms.dart @@ -0,0 +1,145 @@ +import 'package:dio/dio.dart'; + +import '../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../about/external/data/pages/request/HistoryRequest.dart'; +import '../about/external/initial/AbandonRequest.dart'; +import '../about/external/initial/AmnestyRequest.dart'; +import '../about/external/initial/CommitmentRequest.dart'; +import '../about/external/initial/CompletionRequest.dart'; +import '../about/external/initial/DeferralRequest.dart'; +import '../about/external/initial/DeviceRequest.dart'; +import '../about/external/initial/GoalRequest.dart'; +import '../about/external/initial/IdRequest.dart'; +import '../about/external/initial/LoginData.dart'; +import '../about/external/initial/ReportCardRequest.dart'; +import '../about/external/initial/SessionLogRequest.dart'; +import '../about/external/initial/SickModeRequest.dart'; +import '../about/external/initial/ToneRequest.dart'; + +abstract class ConnectComms { + // ── Auth ──────────────────────────────────────────────────────────────── + Future registerDevice(DeviceRequest request); + + Future registerDeviceToken(String token); + + Future login(LoginData request); + + Future logout(); + + Future me(); + + // ── Goals ─────────────────────────────────────────────────────────────── + Future getMyGoals(HistoryRequest request); + + Future saveGoalEntry(GoalRequest request); + + Future getGoalTasks(IdRequest request); + + Future archiveGoalEntry(IdRequest request); + + // ── Commitments ───────────────────────────────────────────────────────── + Future getTodayPlan(CommitmentsRequest request); + + Future getMyCommitments(CommitmentsRequest request); + + Future getOverdueQueue(HistoryRequest request); + + Future saveCommitmentEntry(CommitmentRequest request); + + Future updateCommitmentEntry(CommitmentRequest request); + + Future deleteCommitmentEntry(IdRequest request); + + Future completeCommitmentEntry(CompletionRequest request); + + Future deferCommitmentEntry(DeferralRequest request); + + Future abandonCommitmentEntry(AbandonRequest request); + + Future getCommitmentHistory(HistoryRequest request); + + Future getCommitmentEvents(IdRequest request); + + // ── Capacity ──────────────────────────────────────────────────────────── + Future checkCapacity(CommitmentsRequest request); + + Future getCapacityProfileEntry(); + + // ── Debt & standing ───────────────────────────────────────────────────── + Future getDebtSummary(); + + Future getDebtLedger(HistoryRequest request); + + Future getDebtTrend(ReportCardRequest request); + + Future getStanding(); + + Future getStandingHistory(HistoryRequest request); + + // ── Excuses ───────────────────────────────────────────────────────────── + Future getExcuseClusters(ReportCardRequest request); + + // ── Proof ─────────────────────────────────────────────────────────────── + Future uploadPhotoProof(FormData request); + + Future submitTimerProofEntry(CompletionRequest request); + + Future submitLocationProofEntry(CompletionRequest request); + + // ── Guardrails ────────────────────────────────────────────────────────── + Future spendAmnestyToken(AmnestyRequest request); + + Future getAmnestyBalance(); + + Future updateSickMode(SickModeRequest request); + + Future checkDistress(); + + Future updateTone(ToneRequest request); + + // ── Habits & routines ─────────────────────────────────────────────────── + Future getMyHabits(HistoryRequest request); + + Future saveHabitEntry(Map request); + + Future logHabitEntry(IdRequest request); + + Future getKeystoneHabits(); + + Future getMyRoutines(HistoryRequest request); + + Future logRoutineChainEntry(Map request); + + // ── Training ──────────────────────────────────────────────────────────── + Future getMyPrograms(HistoryRequest request); + + Future saveProgramEntry(Map request); + + Future activateProgramEntry(IdRequest request); + + Future getProgramSessions(IdRequest request); + + Future startSessionEntry(IdRequest request); + + Future saveSessionLogEntry(SessionLogRequest request); + + Future getSessionHistory(HistoryRequest request); + + Future getLastSessionFor(IdRequest request); + + Future getWeeklyVolume(ReportCardRequest request); + + Future getContactVolume(ReportCardRequest request); + + Future getPersonalRecords(HistoryRequest request); + + // ── Report card ───────────────────────────────────────────────────────── + Future getWeeklyReportCard(ReportCardRequest request); + + Future getMonthlyReportCard(ReportCardRequest request); + + // ── Notifications ─────────────────────────────────────────────────────── + Future getMyNotifications(HistoryRequest request); + + Future acknowledgeNudgeEntry(IdRequest request); +} diff --git a/frontend/lib/Grounded/configs/Env.dart b/frontend/lib/Grounded/configs/Env.dart new file mode 100644 index 0000000..1e685ef --- /dev/null +++ b/frontend/lib/Grounded/configs/Env.dart @@ -0,0 +1,7 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +String groundedRouteProd = dotenv.get('GROUNDED_PRODUCTION_PATH', fallback: ''); + +String groundedRouteLocal = dotenv.get('GROUNDED_LOCAL_PATH', fallback: ''); + +String localisedAppVersion = dotenv.get('LOCALISED_APP_VERSION', fallback: ''); diff --git a/frontend/lib/Grounded/configs/Navigator.dart b/frontend/lib/Grounded/configs/Navigator.dart new file mode 100644 index 0000000..abb9082 --- /dev/null +++ b/frontend/lib/Grounded/configs/Navigator.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:page_transition/page_transition.dart'; + +import '../about/internal/application/NavigatorType.dart'; + +/// Every transition in the app funnels through here. Destinations are widget +/// instances, not named routes, and the single choke point is what lets a +/// device-integrity check gate all routing. +class GroundedNavigation { + void navigateToPage(NavigatorType type, dynamic path, BuildContext context) { + _runSec(context, type, path); + } + + Future _runSec(BuildContext context, NavigatorType type, dynamic path) async { + switch (type) { + case NavigatorType.openFully: + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (BuildContext context) => path)); + break; + case NavigatorType.justOpen: + Navigator.push( + context, + PageTransition( + type: PageTransitionType.size, + alignment: Alignment.center, + child: path)); + break; + case NavigatorType.replaceCurrent: + Navigator.pushReplacement( + context, + PageTransition( + type: PageTransitionType.scale, + alignment: Alignment.center, + curve: Curves.ease, + duration: const Duration(microseconds: 9000), + child: path)); + break; + case NavigatorType.makeNewMain: + Navigator.pushAndRemoveUntil( + context, + PageTransition( + type: PageTransitionType.fade, + alignment: Alignment.center, + child: path), + ModalRoute.withName('/')); + break; + } + } + + /// The reload-on-return channel: the child pops with a result and the parent + /// refreshes on it. + Future navigateToPageWithData( + dynamic path, BuildContext context) async { + return await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => path, + ), + ); + } +} diff --git a/frontend/lib/Grounded/configs/NotificationServiceConfig.dart b/frontend/lib/Grounded/configs/NotificationServiceConfig.dart new file mode 100644 index 0000000..6ee72c4 --- /dev/null +++ b/frontend/lib/Grounded/configs/NotificationServiceConfig.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../about/external/data/Commitment.dart'; +import '../about/external/data/LiveSession.dart'; +import '../about/internal/application/CommitmentClass.dart'; +import '../about/internal/application/EscalationTier.dart'; +import '../about/internal/application/ToneLevel.dart'; +import '../utils/CommonUtils.dart'; +import '../utils/ToneEngine.dart'; + +/// Notification channels, the ongoing "task running" notification, and the +/// full-screen alarm used for non-negotiables. +class LocalNotificationEngine { + static final FlutterLocalNotificationsPlugin plugin = + FlutterLocalNotificationsPlugin(); + + /// Ordinary reminders — respects quiet hours. + static const String reminderChannel = "grounded_reminders"; + + /// The ongoing notification attached to a running task. Not dismissable, so + /// a task in progress is always one tap away from the lock screen. + static const String sessionChannel = "grounded_session"; + + /// Alarm-class, full-screen. Reserved for non-negotiables, and the only + /// channel that overrides quiet hours. + static const String alarmChannel = "grounded_alarm"; + + static const int sessionNotificationId = 9000; + + static bool _ready = false; + + static Future init() async { + if (_ready) { + return; + } + + const AndroidInitializationSettings android = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + const DarwinInitializationSettings apple = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + // Critical alerts need Apple entitlement approval; requesting without it + // is simply ignored rather than failing. + requestCriticalPermission: true, + ); + + await plugin.initialize( + settings: const InitializationSettings( + android: android, iOS: apple, macOS: apple), + ); + + if (Platform.isAndroid) { + await _createAndroidChannels(); + } + + _ready = true; + } + + static Future _createAndroidChannels() async { + final AndroidFlutterLocalNotificationsPlugin? android = + plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + + if (android == null) { + return; + } + + await android.createNotificationChannel(const AndroidNotificationChannel( + reminderChannel, + 'Reminders', + description: 'Nudges about commitments that are due.', + importance: Importance.defaultImportance, + )); + + await android.createNotificationChannel(const AndroidNotificationChannel( + sessionChannel, + 'Task in progress', + description: 'The ongoing notification for a task you are running.', + importance: Importance.low, + playSound: false, + enableVibration: false, + )); + + await android.createNotificationChannel(const AndroidNotificationChannel( + alarmChannel, + 'Non-negotiables', + description: + 'Full-screen alarms for non-negotiable commitments. These ignore quiet hours.', + importance: Importance.max, + playSound: true, + enableVibration: true, + )); + + await android.requestNotificationsPermission(); + + // Exact alarms are what let a window actually close on time rather than + // whenever the OS feels like it. + await android.requestExactAlarmsPermission(); + } + + /// The ongoing notification for a running task. Shows the live remaining + /// time so it is useful from the lock screen without unlocking. + static Future showSessionNotification(LiveSession session) async { + await init(); + + final String remaining = session.requiredSeconds > 0 + ? "${formatClock(session.remainingSeconds())} remaining" + : "${formatClock(session.elapsedSeconds())} elapsed"; + + final AndroidNotificationDetails android = AndroidNotificationDetails( + sessionChannel, + 'Task in progress', + channelDescription: 'The ongoing notification for a running task.', + importance: Importance.low, + priority: Priority.low, + ongoing: true, + autoCancel: false, + onlyAlertOnce: true, + showWhen: true, + usesChronometer: session.requiredSeconds <= 0, + category: AndroidNotificationCategory.workout, + actions: const [ + AndroidNotificationAction('pause', 'Pause'), + AndroidNotificationAction('finish', 'Finish'), + ], + ); + + await plugin.show( + id: sessionNotificationId, + title: session.title, + body: session.goalTitle.isEmpty + ? remaining + : "${session.goalTitle} · $remaining", + notificationDetails: NotificationDetails( + android: android, + iOS: const DarwinNotificationDetails(presentBanner: false), + ), + payload: session.commitmentId, + ); + } + + static Future cancelSessionNotification() async { + await init(); + await plugin.cancel(id: sessionNotificationId); + } + + /// A due-window nudge. Non-negotiables go out full-screen and alarm-class so + /// they surface over the lock screen; everything else is an ordinary + /// notification. + static Future showCommitmentNudge( + Commitment commitment, + EscalationTier tier, + ToneLevel tone, + ) async { + await init(); + + final bool nonNegotiable = + commitment.commitmentClass == CommitmentClass.NonNegotiable; + + final AndroidNotificationDetails android = AndroidNotificationDetails( + nonNegotiable ? alarmChannel : reminderChannel, + nonNegotiable ? 'Non-negotiables' : 'Reminders', + importance: nonNegotiable ? Importance.max : Importance.defaultImportance, + priority: nonNegotiable ? Priority.max : Priority.defaultPriority, + // The full-screen intent is what turns this into a lock-screen takeover + // rather than a banner that can be swiped past. + fullScreenIntent: nonNegotiable, + category: nonNegotiable + ? AndroidNotificationCategory.alarm + : AndroidNotificationCategory.reminder, + actions: [ + const AndroidNotificationAction('start', 'Start now'), + if (!nonNegotiable) + const AndroidNotificationAction('snooze', 'Snooze'), + ], + ); + + await plugin.show( + id: commitment.id.hashCode, + title: commitment.title, + body: ToneEngine.nudge(tier, tone, commitment.title), + notificationDetails: NotificationDetails( + android: android, + iOS: DarwinNotificationDetails( + // Critical alerts bypass Do Not Disturb, and are the iOS equivalent + // of the Android full-screen intent. Requires Apple approval. + interruptionLevel: nonNegotiable + ? InterruptionLevel.critical + : InterruptionLevel.active, + ), + ), + payload: commitment.id, + ); + } + + static Future cancelAll() async { + await init(); + await plugin.cancelAll(); + } +} diff --git a/frontend/lib/Grounded/designs/Component.dart b/frontend/lib/Grounded/designs/Component.dart new file mode 100644 index 0000000..c553c99 --- /dev/null +++ b/frontend/lib/Grounded/designs/Component.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// The font families, wrapped so no screen ever names a family directly. +/// General Sans — see fonts/LICENSE-GeneralSans.txt. +String getTextType(TextType type) { + switch (type) { + case TextType.Bold: + return "GroundedBold"; + case TextType.Light: + return "GroundedLight"; + case TextType.Regular: + return "GroundedRegular"; + case TextType.Medium: + return "GroundedMedium"; + } +} + +/// A labelled pill — the standing chip, the class chip, the proof chip. One +/// implementation so they stay visually identical everywhere. +Widget pill( + String label, + Color foreground, + Color background, { + IconData? icon, + double textSize = 10, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: textSize + 2, color: foreground), + const SizedBox(width: 5), + ], + text( + label.toUpperCase(), + textSize, + TextType.Bold, + color: foreground, + letterSpacing: 0.7, + ), + ], + ), + ); +} + +/// The standard card surface. +Widget card({ + required Widget child, + EdgeInsets padding = const EdgeInsets.all(16), + EdgeInsets margin = EdgeInsets.zero, + Color? background, + Color? borderColor, + double radius = 16, + VoidCallback? onTap, +}) { + final Widget body = Container( + width: double.infinity, + padding: padding, + margin: margin, + decoration: BoxDecoration( + color: background ?? colorCard, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: borderColor ?? colorBorder, width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), + blurRadius: 18, + offset: Offset(0, 4), + ), + ], + ), + child: child, + ); + + if (onTap == null) { + return body; + } + + return GestureDetector(onTap: onTap, child: body); +} + +/// Section heading — small all-caps label over a large light title, the +/// house style used on every screen header. +Widget sectionHeader(String label, String title, {Color? titleColor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label.toUpperCase(), 11, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 8), + text(title, 32, TextType.Light, color: titleColor ?? colorPrimaryDark), + ], + ); +} + +/// A labelled statistic, used across the report card and the debt header. +Widget statTile( + String label, + String value, { + Color? valueColor, + String? caption, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label.toUpperCase(), 10, TextType.Bold, + color: colorGrey2, letterSpacing: 0.8), + const SizedBox(height: 6), + text(value, 26, TextType.Bold, color: valueColor ?? colorPrimaryDark), + if (caption != null) ...[ + const SizedBox(height: 2), + text(caption, 11, TextType.Regular, color: colorGrey2), + ], + ], + ); +} + +/// A hairline divider at the house opacity. +Widget hairline({EdgeInsets margin = EdgeInsets.zero}) { + return Container( + height: 1, + margin: margin, + color: colorDivider, + ); +} + +/// Empty state. Deliberately plain — an empty queue is good news and should +/// not be celebrated with confetti. +Widget emptyState( + IconData icon, + String title, + String description, { + Color? accent, +}) { + final Color tone = accent ?? colorGrey2; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 32, color: tone), + ), + const SizedBox(height: 20), + text(title, 18, TextType.Bold, + color: colorPrimaryDark, align: TextAlign.center), + const SizedBox(height: 8), + text(description, 13, TextType.Regular, + color: colorGrey2, align: TextAlign.center, height: 1.5), + ], + ), + ), + ); +} + +/// A progress bar with the house geometry. +Widget meter( + double fraction, { + Color? fill, + Color? track, + double height = 8, +}) { + final double clamped = fraction.isNaN + ? 0 + : fraction < 0 + ? 0 + : fraction > 1 + ? 1 + : fraction; + + return ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: clamped, + minHeight: height, + backgroundColor: track ?? colorMuted, + valueColor: AlwaysStoppedAnimation(fill ?? colorPrimary), + ), + ); +} diff --git a/frontend/lib/Grounded/designs/Responsive.dart b/frontend/lib/Grounded/designs/Responsive.dart new file mode 100644 index 0000000..0a99f4d --- /dev/null +++ b/frontend/lib/Grounded/designs/Responsive.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import 'text/Text.dart'; + +class Responsive extends StatelessWidget { + final Widget mobile; + final Widget tablet; + final Widget desktop; + + const Responsive({ + super.key, + required this.desktop, + required this.mobile, + required this.tablet, + }); + + /// mobile < 650 + static bool isMobile(BuildContext context) => + MediaQuery.sizeOf(context).width < 650; + + /// tablet >= 650 + static bool isTablet(BuildContext context) => + MediaQuery.sizeOf(context).width >= 650; + + /// desktop >= 1100 + static bool isDesktop(BuildContext context) => + MediaQuery.sizeOf(context).width >= 1100; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + if (isDesktop(context)) { + return desktop; + } else if (isTablet(context)) { + return tablet; + } else if (isMobile(context)) { + return mobile; + } else { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [text("NOT SUPPORTED", 12, TextType.Bold)], + ), + ); + } + }); + } +} diff --git a/frontend/lib/Grounded/designs/Shell.dart b/frontend/lib/Grounded/designs/Shell.dart new file mode 100644 index 0000000..510538a --- /dev/null +++ b/frontend/lib/Grounded/designs/Shell.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'Component.dart'; +import 'text/Text.dart'; + +/// The house layout: black chrome at the top, a white sheet rising into it with +/// a large corner radius. Every screen is built from this so the app reads as +/// one object rather than a stack of pages. +class Sheet extends StatelessWidget { + /// Small all-caps label rendered in the black chrome, above the title. + final String eyebrow; + + /// The chrome title — small and centred, not the display title. + final String title; + + final Widget child; + + final VoidCallback? onBack; + + /// Optional trailing control in the chrome. + final Widget? action; + + /// Chrome colour. Defaults to near-black; standing screens tint it. + final Color? chrome; + + /// Rendered inside the chrome beneath the title — the standing strip. + final Widget? banner; + + final bool scrollable; + + const Sheet({ + super.key, + required this.title, + required this.child, + this.eyebrow = "", + this.onBack, + this.action, + this.chrome, + this.banner, + this.scrollable = true, + }); + + @override + Widget build(BuildContext context) { + final Color chromeColor = chrome ?? colorPrimaryDark; + + return Scaffold( + backgroundColor: chromeColor, + body: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + SizedBox( + width: 44, + child: onBack == null + ? null + : _chromeButton( + Icons.arrow_back_ios_new_rounded, onBack!), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (eyebrow.isNotEmpty) ...[ + text( + eyebrow.toUpperCase(), + 9, + TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2, + align: TextAlign.center, + ), + const SizedBox(height: 3), + ], + text( + title, + 15, + TextType.Medium, + color: colorWhite, + align: TextAlign.center, + ), + ], + ), + ), + SizedBox( + width: 44, + child: action == null + ? null + : Align( + alignment: Alignment.centerRight, + child: action, + ), + ), + ], + ), + if (banner != null) ...[ + const SizedBox(height: 16), + banner!, + ], + ], + ), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: colorPrimaryLight, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + clipBehavior: Clip.antiAlias, + child: scrollable + ? SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 40), + child: child, + ) + : Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), + child: child, + ), + ), + ), + ], + ), + ); + } + + Widget _chromeButton(IconData icon, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 15, color: colorWhite), + ), + ); + } +} + +/// A circular control for the black chrome, with an optional unread dot. +Widget chromeAction( + IconData icon, + VoidCallback onTap, { + bool dotted = false, + Color? dotColor, +}) { + return GestureDetector( + onTap: onTap, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 17, color: colorWhite), + ), + if (dotted) + Positioned( + top: 1, + right: 1, + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: dotColor ?? colorAccent, + shape: BoxShape.circle, + border: Border.all(color: colorPrimaryDark, width: 1.5), + ), + ), + ), + ], + ), + ); +} + +/// The label/value pair — a tiny grey all-caps label sitting directly above a +/// value. The core unit of the whole interface. +Widget labelled( + String label, + String value, { + double valueSize = 15, + TextType valueType = TextType.Medium, + Color? valueColor, + Color? labelColor, + CrossAxisAlignment align = CrossAxisAlignment.start, +}) { + return Column( + crossAxisAlignment: align, + mainAxisSize: MainAxisSize.min, + children: [ + text( + label.toUpperCase(), + 9, + TextType.Bold, + color: labelColor ?? colorGrey2, + letterSpacing: 1.0, + ), + const SizedBox(height: 5), + text(value, valueSize, valueType, + color: valueColor ?? colorPrimaryDark), + ], + ); +} + +/// A row of metadata above a display title, as on the reference: small grey +/// pairs separated by generous space. +Widget metaRow(List items) { + final List spaced = []; + + for (int index = 0; index < items.length; index++) { + spaced.add(items[index]); + if (index != items.length - 1) { + spaced.add(const SizedBox(width: 28)); + } + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: spaced, + ); +} + +/// The oversized light display title that opens a section. +Widget displayTitle(String value, {Color? color, double size = 34}) { + return text(value, size, TextType.Light, + color: color ?? colorPrimaryDark, height: 1.15); +} + +/// A section break: hairline, then a small bold heading with an optional +/// trailing chip. +Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + hairline(margin: const EdgeInsets.only(bottom: 20)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + text(heading, 17, TextType.Bold, color: colorPrimaryDark), + if (caption.isNotEmpty) ...[ + const SizedBox(width: 10), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: text(caption, 11, TextType.Regular, + color: colorGrey2), + ), + ], + ], + ), + ), + if (trailing != null) trailing, + ], + ), + const SizedBox(height: 16), + ], + ); +} diff --git a/frontend/lib/Grounded/designs/buttons/Buttons.dart b/frontend/lib/Grounded/designs/buttons/Buttons.dart new file mode 100644 index 0000000..e5360c9 --- /dev/null +++ b/frontend/lib/Grounded/designs/buttons/Buttons.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; +import '../Component.dart'; +import '../text/Text.dart'; + +/// The primary action. One per screen — if a screen appears to need two, one +/// of them is secondary. +Widget roundedCornerButton( + String label, + VoidCallback onPressed, { + Color? background, + Color? foreground, + IconData? icon, + bool enabled = true, + double radius = 14, + double verticalPadding = 16, +}) { + final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey; + final Color fg = foreground ?? colorWhite; + + return ElevatedButton( + onPressed: enabled ? onPressed : null, + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: bg, + disabledBackgroundColor: colorGrey.withValues(alpha: 0.4), + padding: EdgeInsets.symmetric(vertical: verticalPadding), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: fg), + const SizedBox(width: 8), + ], + text(label, 14, TextType.Bold, color: fg), + ], + ), + ); +} + +/// The secondary action — outlined, never filled, so the hierarchy is never +/// ambiguous. +Widget outlinedActionButton( + String label, + VoidCallback onPressed, { + Color? foreground, + IconData? icon, + bool enabled = true, + double radius = 14, +}) { + final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey; + + return OutlinedButton( + onPressed: enabled ? onPressed : null, + style: OutlinedButton.styleFrom( + side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: fg), + const SizedBox(width: 8), + ], + text(label, 14, TextType.Bold, color: fg), + ], + ), + ); +} + +Widget textButton( + String label, + VoidCallback onPressed, { + Color? color, + double textSize = 13, + TextType type = TextType.Regular, +}) { + return TextButton( + onPressed: onPressed, + child: text(label, textSize, type, color: color ?? colorGrey2), + ); +} + +Widget iconButton( + Widget icon, + VoidCallback onPressed, { + bool bordered = false, + Color? borderColor, + Color? background, + double radius = 10, + double size = 38, +}) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: background ?? Colors.transparent, + borderRadius: BorderRadius.circular(radius), + border: bordered + ? Border.all(color: borderColor ?? colorBorder, width: 1) + : null, + ), + child: icon, + ), + ); +} + +/// The destructive action — abandoning, which costs the most debt of all and +/// so is always visually distinct from completing. +Widget destructiveButton( + String label, + VoidCallback onPressed, { + IconData? icon, + bool enabled = true, +}) { + return roundedCornerButton( + label, + onPressed, + background: colorDestructive, + foreground: colorWhite, + icon: icon, + enabled: enabled, + ); +} + +/// A segmented selector, used for class, energy, proof type and tone. +Widget segmentedSelector({ + required List options, + required T selected, + required String Function(T) label, + required void Function(T) onSelected, + Color? activeColor, +}) { + final Color active = activeColor ?? colorPrimaryDark; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((option) { + final bool isSelected = option == selected; + return GestureDetector( + onTap: () => onSelected(option), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? active : colorWhite, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? active : colorBorder, + width: 1, + ), + ), + child: text( + label(option), + 12, + isSelected ? TextType.Bold : TextType.Regular, + color: isSelected ? colorWhite : colorGrey2, + ), + ), + ); + }).toList(), + ); +} diff --git a/frontend/lib/Grounded/designs/input/InputFields.dart b/frontend/lib/Grounded/designs/input/InputFields.dart new file mode 100644 index 0000000..3602f7e --- /dev/null +++ b/frontend/lib/Grounded/designs/input/InputFields.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; +import '../Component.dart'; +import '../text/Text.dart'; + +/// The standard text field. +Widget inputField( + String label, + TextEditingController controller, { + String hint = "", + String? Function(String?)? validator, + TextInputType keyboard = TextInputType.text, + bool obscure = false, + int maxLines = 1, + List? formatters, + IconData? icon, + ValueChanged? onChanged, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label.toUpperCase(), 10, TextType.Bold, + color: colorGrey2, letterSpacing: 0.8), + const SizedBox(height: 8), + TextFormField( + controller: controller, + validator: validator, + keyboardType: keyboard, + obscureText: obscure, + maxLines: obscure ? 1 : maxLines, + inputFormatters: formatters, + onChanged: onChanged, + style: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 14, + color: colorPrimaryDark, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 13, + color: colorGrey, + ), + prefixIcon: icon == null + ? null + : Icon(icon, size: 18, color: colorGrey2), + filled: true, + fillColor: colorWhite, + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorBorder, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorPrimaryDark, width: 1.4), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorNegative, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorNegative, width: 1.4), + ), + errorStyle: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 11, + color: colorNegative, + ), + ), + ), + ], + ); +} + +/// The excuse field. Deliberately unadorned — no templates, no quick-picks, a +/// live character count that shows the minimum, because the friction is the +/// feature rather than an obstacle to route around. +Widget excuseField( + TextEditingController controller, + int minimumLength, { + String? Function(String?)? validator, + ValueChanged? onChanged, +}) { + final int length = controller.text.trim().length; + final bool satisfied = length >= minimumLength; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("WHY", 10, TextType.Bold, color: colorGrey2, letterSpacing: 0.8), + text( + satisfied ? "$length characters" : "$length / $minimumLength", + 10, + TextType.Bold, + color: satisfied ? colorPositive : colorGrey, + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + validator: validator, + onChanged: onChanged, + maxLines: 4, + style: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 14, + color: colorPrimaryDark, + height: 1.5, + ), + decoration: InputDecoration( + hintText: "In your own words. No shortcuts here.", + hintStyle: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 13, + color: colorGrey, + ), + filled: true, + fillColor: colorWhite, + contentPadding: const EdgeInsets.all(14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorBorder, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorPrimaryDark, width: 1.4), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorNegative, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorNegative, width: 1.4), + ), + errorStyle: TextStyle( + fontFamily: getTextType(TextType.Regular), + fontSize: 11, + color: colorNegative, + ), + ), + ), + ], + ); +} + +/// A read-only field that loads its options on tap rather than pre-loading +/// them — the ViewModel fetches, then calls back to open the picker. +Widget selectField( + String label, + String value, + VoidCallback onTap, { + String hint = "Select", + IconData icon = Icons.expand_more_rounded, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label.toUpperCase(), 10, TextType.Bold, + color: colorGrey2, letterSpacing: 0.8), + const SizedBox(height: 8), + GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), + decoration: BoxDecoration( + color: colorWhite, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorBorder, width: 1), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: text( + value.isEmpty ? hint : value, + 14, + value.isEmpty ? TextType.Regular : TextType.Bold, + color: value.isEmpty ? colorGrey : colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(icon, size: 20, color: colorGrey2), + ], + ), + ), + ), + ], + ); +} diff --git a/frontend/lib/Grounded/designs/text/Text.dart b/frontend/lib/Grounded/designs/text/Text.dart new file mode 100644 index 0000000..017fd0c --- /dev/null +++ b/frontend/lib/Grounded/designs/text/Text.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; +import '../Component.dart'; + +/// The only way display copy is rendered. Raw Text/TextStyle is never used for +/// user-facing copy. +Widget text( + String text, + double textSize, + TextType type, { + Color? color, + TextAlign? align, + int? maxLines, + TextOverflow? overflow, + double? letterSpacing, + double? height, + double? wordSpacing, + TextDecoration? decoration, + FontWeight? weight, +}) { + return Text( + text, + textAlign: align ?? TextAlign.left, + maxLines: maxLines, + overflow: overflow, + style: TextStyle( + decoration: decoration ?? TextDecoration.none, + color: color ?? colorPrimaryDark, + fontSize: textSize, + fontFamily: getTextType(type), + letterSpacing: letterSpacing, + height: height, + wordSpacing: wordSpacing, + fontWeight: weight, + ), + ); +} diff --git a/frontend/lib/Grounded/informatics/AppDataManager.dart b/frontend/lib/Grounded/informatics/AppDataManager.dart new file mode 100644 index 0000000..fdc9064 --- /dev/null +++ b/frontend/lib/Grounded/informatics/AppDataManager.dart @@ -0,0 +1,362 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +import '../about/external/data/Commitment.dart'; +import '../about/external/data/Program.dart'; +import '../about/external/data/SystemResponse.dart'; +import '../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../about/external/data/pages/request/HistoryRequest.dart'; +import '../about/external/initial/AbandonRequest.dart'; +import '../about/external/initial/AmnestyRequest.dart'; +import '../about/external/initial/CommitmentRequest.dart'; +import '../about/external/initial/CompletionRequest.dart'; +import '../about/external/initial/DeferralRequest.dart'; +import '../about/external/initial/DeviceRequest.dart'; +import '../about/external/initial/GoalRequest.dart'; +import '../about/external/initial/IdRequest.dart'; +import '../about/external/initial/LoginData.dart'; +import '../about/external/initial/ReportCardRequest.dart'; +import '../about/external/initial/SessionLogRequest.dart'; +import '../about/external/initial/SickModeRequest.dart'; +import '../about/external/initial/ToneRequest.dart'; +import '../about/internal/application/CapacityProfile.dart'; +import '../about/internal/application/MeDescription.dart'; +import '../about/internal/application/Token.dart'; +import '../about/internal/application/UserDetails.dart'; +import '../about/internal/file/ConnectFileStorage.dart'; +import '../comms/ConnectComms.dart'; +import '../memory/ConnectInternalMemory.dart'; +import 'DataManager.dart'; + +/// The single data gateway. Constructed once in [ParentViewModel] and shared by +/// every screen; it only delegates to its three collaborators. +class AppDataManager implements DataManager { + ConnectInternalMemory memory; + + ConnectComms comms; + + ConnectFileStorage files; + + AppDataManager(this.memory, this.comms, this.files); + + // ── Memory ──────────────────────────────────────────────────────────────── + + @override + Future getMyDescription() => memory.getMyDescription(); + + @override + Future setMyDescription(MeDescription description) => + memory.setMyDescription(description); + + @override + Future getNotToken() => memory.getNotToken(); + + @override + Future setNotToken(String token) => memory.setNotToken(token); + + @override + Future getUserCreationDetails() => + memory.getUserCreationDetails(); + + @override + Future setUserCreationDetails(SystemResponse response) => + memory.setUserCreationDetails(response); + + @override + Future getUserDetails() => memory.getUserDetails(); + + @override + Future setUserDetails(UserDetails details) => memory.setUserDetails(details); + + @override + Future getTokenEntry() => memory.getTokenEntry(); + + @override + Future setTokenEntry(Token token) => memory.setTokenEntry(token); + + @override + Future getRefreshAt() => memory.getRefreshAt(); + + @override + Future setRefreshAt(String refresher) => memory.setRefreshAt(refresher); + + @override + Future getActiveCommitment() => memory.getActiveCommitment(); + + @override + Future setActiveCommitment(Commitment commitment) => + memory.setActiveCommitment(commitment); + + @override + Future getActiveProgram() => memory.getActiveProgram(); + + @override + Future setActiveProgram(Program program) => memory.setActiveProgram(program); + + @override + Future getCapacityProfile() => memory.getCapacityProfile(); + + @override + Future setCapacityProfile(CapacityProfile profile) => + memory.setCapacityProfile(profile); + + @override + Future getCachedDebtScore() => memory.getCachedDebtScore(); + + @override + Future setCachedDebtScore(double score) => memory.setCachedDebtScore(score); + + @override + Future getEngagementCount() => memory.getEngagementCount(); + + @override + Future setEngagementCount(int count) => memory.setEngagementCount(count); + + @override + Future getAmnestySpent() => memory.getAmnestySpent(); + + @override + Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent); + + @override + Future showOnboarding() => memory.showOnboarding(); + + @override + Future setOnboardingOption(bool value) => memory.setOnboardingOption(value); + + // ── Files ───────────────────────────────────────────────────────────────── + + @override + Future saveProof(String name, Uint8List bytes) => + files.saveProof(name, bytes); + + @override + Future readProof(String reference) => files.readProof(reference); + + @override + Future deleteProof(String reference) => files.deleteProof(reference); + + @override + Future proofDirectory() => files.proofDirectory(); + + // ── Comms ───────────────────────────────────────────────────────────────── + + @override + Future registerDevice(DeviceRequest request) => + comms.registerDevice(request); + + @override + Future registerDeviceToken(String token) => + comms.registerDeviceToken(token); + + @override + Future login(LoginData request) => comms.login(request); + + @override + Future logout() => comms.logout(); + + @override + Future me() => comms.me(); + + @override + Future getMyGoals(HistoryRequest request) => + comms.getMyGoals(request); + + @override + Future saveGoalEntry(GoalRequest request) => + comms.saveGoalEntry(request); + + @override + Future getGoalTasks(IdRequest request) => + comms.getGoalTasks(request); + + @override + Future archiveGoalEntry(IdRequest request) => + comms.archiveGoalEntry(request); + + @override + Future getTodayPlan(CommitmentsRequest request) => + comms.getTodayPlan(request); + + @override + Future getMyCommitments(CommitmentsRequest request) => + comms.getMyCommitments(request); + + @override + Future getOverdueQueue(HistoryRequest request) => + comms.getOverdueQueue(request); + + @override + Future saveCommitmentEntry(CommitmentRequest request) => + comms.saveCommitmentEntry(request); + + @override + Future updateCommitmentEntry(CommitmentRequest request) => + comms.updateCommitmentEntry(request); + + @override + Future deleteCommitmentEntry(IdRequest request) => + comms.deleteCommitmentEntry(request); + + @override + Future completeCommitmentEntry(CompletionRequest request) => + comms.completeCommitmentEntry(request); + + @override + Future deferCommitmentEntry(DeferralRequest request) => + comms.deferCommitmentEntry(request); + + @override + Future abandonCommitmentEntry(AbandonRequest request) => + comms.abandonCommitmentEntry(request); + + @override + Future getCommitmentHistory(HistoryRequest request) => + comms.getCommitmentHistory(request); + + @override + Future getCommitmentEvents(IdRequest request) => + comms.getCommitmentEvents(request); + + @override + Future checkCapacity(CommitmentsRequest request) => + comms.checkCapacity(request); + + @override + Future getCapacityProfileEntry() => comms.getCapacityProfileEntry(); + + @override + Future getDebtSummary() => comms.getDebtSummary(); + + @override + Future getDebtLedger(HistoryRequest request) => + comms.getDebtLedger(request); + + @override + Future getDebtTrend(ReportCardRequest request) => + comms.getDebtTrend(request); + + @override + Future getStanding() => comms.getStanding(); + + @override + Future getStandingHistory(HistoryRequest request) => + comms.getStandingHistory(request); + + @override + Future getExcuseClusters(ReportCardRequest request) => + comms.getExcuseClusters(request); + + @override + Future uploadPhotoProof(FormData request) => + comms.uploadPhotoProof(request); + + @override + Future submitTimerProofEntry(CompletionRequest request) => + comms.submitTimerProofEntry(request); + + @override + Future submitLocationProofEntry(CompletionRequest request) => + comms.submitLocationProofEntry(request); + + @override + Future spendAmnestyToken(AmnestyRequest request) => + comms.spendAmnestyToken(request); + + @override + Future getAmnestyBalance() => comms.getAmnestyBalance(); + + @override + Future updateSickMode(SickModeRequest request) => + comms.updateSickMode(request); + + @override + Future checkDistress() => comms.checkDistress(); + + @override + Future updateTone(ToneRequest request) => comms.updateTone(request); + + @override + Future getMyHabits(HistoryRequest request) => + comms.getMyHabits(request); + + @override + Future saveHabitEntry(Map request) => + comms.saveHabitEntry(request); + + @override + Future logHabitEntry(IdRequest request) => + comms.logHabitEntry(request); + + @override + Future getKeystoneHabits() => comms.getKeystoneHabits(); + + @override + Future getMyRoutines(HistoryRequest request) => + comms.getMyRoutines(request); + + @override + Future logRoutineChainEntry(Map request) => + comms.logRoutineChainEntry(request); + + @override + Future getMyPrograms(HistoryRequest request) => + comms.getMyPrograms(request); + + @override + Future saveProgramEntry(Map request) => + comms.saveProgramEntry(request); + + @override + Future activateProgramEntry(IdRequest request) => + comms.activateProgramEntry(request); + + @override + Future getProgramSessions(IdRequest request) => + comms.getProgramSessions(request); + + @override + Future startSessionEntry(IdRequest request) => + comms.startSessionEntry(request); + + @override + Future saveSessionLogEntry(SessionLogRequest request) => + comms.saveSessionLogEntry(request); + + @override + Future getSessionHistory(HistoryRequest request) => + comms.getSessionHistory(request); + + @override + Future getLastSessionFor(IdRequest request) => + comms.getLastSessionFor(request); + + @override + Future getWeeklyVolume(ReportCardRequest request) => + comms.getWeeklyVolume(request); + + @override + Future getContactVolume(ReportCardRequest request) => + comms.getContactVolume(request); + + @override + Future getPersonalRecords(HistoryRequest request) => + comms.getPersonalRecords(request); + + @override + Future getWeeklyReportCard(ReportCardRequest request) => + comms.getWeeklyReportCard(request); + + @override + Future getMonthlyReportCard(ReportCardRequest request) => + comms.getMonthlyReportCard(request); + + @override + Future getMyNotifications(HistoryRequest request) => + comms.getMyNotifications(request); + + @override + Future acknowledgeNudgeEntry(IdRequest request) => + comms.acknowledgeNudgeEntry(request); +} diff --git a/frontend/lib/Grounded/informatics/DataManager.dart b/frontend/lib/Grounded/informatics/DataManager.dart new file mode 100644 index 0000000..4216097 --- /dev/null +++ b/frontend/lib/Grounded/informatics/DataManager.dart @@ -0,0 +1,6 @@ +import '../about/internal/file/ConnectFileStorage.dart'; +import '../comms/ConnectComms.dart'; +import '../memory/ConnectInternalMemory.dart'; + +abstract class DataManager + implements ConnectInternalMemory, ConnectComms, ConnectFileStorage {} diff --git a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart new file mode 100644 index 0000000..6428e4d --- /dev/null +++ b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart @@ -0,0 +1,65 @@ +import '../about/external/data/Commitment.dart'; +import '../about/external/data/Program.dart'; +import '../about/external/data/SystemResponse.dart'; +import '../about/internal/application/CapacityProfile.dart'; +import '../about/internal/application/MeDescription.dart'; +import '../about/internal/application/Token.dart'; +import '../about/internal/application/UserDetails.dart'; + +abstract class ConnectInternalMemory { + Future getMyDescription(); + + Future setMyDescription(MeDescription description); + + Future getNotToken(); + + Future setNotToken(String token); + + Future getUserCreationDetails(); + + Future setUserCreationDetails(SystemResponse response); + + Future getUserDetails(); + + Future setUserDetails(UserDetails details); + + Future getTokenEntry(); + + Future setTokenEntry(Token token); + + Future getRefreshAt(); + + Future setRefreshAt(String refresher); + + Future getActiveCommitment(); + + Future setActiveCommitment(Commitment commitment); + + Future getActiveProgram(); + + Future setActiveProgram(Program program); + + Future getCapacityProfile(); + + Future setCapacityProfile(CapacityProfile profile); + + /// The last computed debt score, so the app can open with a number rather + /// than a spinner. + Future getCachedDebtScore(); + + Future setCachedDebtScore(double score); + + /// App opens this week, for the distress conjunction. + Future getEngagementCount(); + + Future setEngagementCount(int count); + + /// Amnesty tokens spent this month. + Future getAmnestySpent(); + + Future setAmnestySpent(int spent); + + Future showOnboarding(); + + Future setOnboardingOption(bool value); +} diff --git a/frontend/lib/Grounded/memory/InternalMemory.dart b/frontend/lib/Grounded/memory/InternalMemory.dart new file mode 100644 index 0000000..67d3d39 --- /dev/null +++ b/frontend/lib/Grounded/memory/InternalMemory.dart @@ -0,0 +1,270 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../about/external/data/Commitment.dart'; +import '../about/external/data/Program.dart'; +import '../about/external/data/ResponseState.dart'; +import '../about/external/data/SystemResponse.dart'; +import '../about/internal/application/CapacityProfile.dart'; +import '../about/internal/application/MeDescription.dart'; +import '../about/internal/application/Token.dart'; +import '../about/internal/application/UserDetails.dart'; +import 'ConnectInternalMemory.dart'; + +/// Secure local storage. Keys carry a random suffix so they are not guessable +/// from the field name alone. +class InternalMemory implements ConnectInternalMemory { + static const String DESCRIBE_ME = "DESCRIBE_ME_Kq7fRp2XvN4mLd8T"; + + static const String NOT_TOKEN = "NOT_TOKEN_Zw3hYb9CsK6nQe1V"; + + static const String USER_DETAILS_NEW = "USER_DETAILS_NEW_Rt5jXm8PfA2wDc7L"; + + static const String ABOUT_ME = "ABOUT_ME_Hn4vTq7BdS9xGk3M"; + + static const String TOKEN_DETAILS = "TOKEN_DETAILS_Ly6pWc3NrE8zFj5Q"; + + static const String REFRESHER_ID = "REFRESHER_ID_Vb2sJd7MtX4qHu9K"; + + static const String ACTIVE_COMMITMENT = + "ACTIVE_COMMITMENT_Pf9kRn3WgY6tBz2S"; + + static const String ACTIVE_PROGRAM = "ACTIVE_PROGRAM_Dm5cQx8LvH1rTk4N"; + + static const String CAPACITY_PROFILE = "CAPACITY_PROFILE_Gs7bZp4JnW9dFy6X"; + + static const String DEBT_SCORE = "DEBT_SCORE_Ux3mKt6QcR8vNa5H"; + + static const String ENGAGEMENT_COUNT = "ENGAGEMENT_COUNT_Ct8nDw2FbP5jSq7Z"; + + static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B"; + + static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M"; + + final groundedStorage = const FlutterSecureStorage(); + + @override + Future getMyDescription() async { + String? value = await groundedStorage.read(key: DESCRIBE_ME); + + if (value != null) { + if (value != "") { + Map json = jsonDecode(value); + return MeDescription.fromJson(json); + } + } + + return MeDescription(id: "", name: "", token: ""); + } + + @override + Future setMyDescription(MeDescription description) async { + String value = jsonEncode(description.toJson()); + await groundedStorage.write(key: DESCRIBE_ME, value: value); + } + + @override + Future getNotToken() async { + return await groundedStorage.read(key: NOT_TOKEN) ?? ""; + } + + @override + Future setNotToken(String token) async { + await groundedStorage.write(key: NOT_TOKEN, value: token); + } + + @override + Future getUserCreationDetails() async { + String? value = await groundedStorage.read(key: USER_DETAILS_NEW); + + if (value != null) { + if (value != "") { + Map json = jsonDecode(value); + return SystemResponse.fromJsonMap(json); + } + } + + return SystemResponse("", "", "", ResponseState.Success); + } + + @override + Future setUserCreationDetails(SystemResponse response) async { + String value = jsonEncode(response.toJson()); + await groundedStorage.write(key: USER_DETAILS_NEW, value: value); + } + + @override + Future getUserDetails() async { + String? value = await groundedStorage.read(key: ABOUT_ME); + + if (value != null) { + if (value != "") { + Map json = jsonDecode(value); + return UserDetails.fromJson(json); + } + } + + return UserDetails(pic: '', name: ''); + } + + @override + Future setUserDetails(UserDetails details) async { + String value = jsonEncode(details.toJson()); + await groundedStorage.write(key: ABOUT_ME, value: value); + } + + @override + Future getTokenEntry() async { + String? value = await groundedStorage.read(key: TOKEN_DETAILS); + + if (value != null) { + if (value != "") { + Map json = jsonDecode(value); + return Token.fromJsonMap(json); + } + } + + return Token("", "", "", 0, ""); + } + + @override + Future setTokenEntry(Token token) async { + String value = jsonEncode(token.toJson()); + await groundedStorage.write(key: TOKEN_DETAILS, value: value); + } + + @override + Future getRefreshAt() async { + return await groundedStorage.read(key: REFRESHER_ID) ?? ""; + } + + @override + Future setRefreshAt(String refresher) async { + await groundedStorage.write(key: REFRESHER_ID, value: refresher); + } + + @override + Future getActiveCommitment() async { + String? value = await groundedStorage.read(key: ACTIVE_COMMITMENT); + + if (value != null) { + if (value.isNotEmpty) { + Map json = jsonDecode(value); + return Commitment.fromJson(json); + } + } + + return Commitment(); + } + + @override + Future setActiveCommitment(Commitment commitment) async { + String value = jsonEncode(commitment.toJson()); + await groundedStorage.write(key: ACTIVE_COMMITMENT, value: value); + } + + @override + Future getActiveProgram() async { + String? value = await groundedStorage.read(key: ACTIVE_PROGRAM); + + if (value != null) { + if (value.isNotEmpty) { + Map json = jsonDecode(value); + return Program.fromJson(json); + } + } + + return Program(); + } + + @override + Future setActiveProgram(Program program) async { + String value = jsonEncode(program.toJson()); + await groundedStorage.write(key: ACTIVE_PROGRAM, value: value); + } + + @override + Future getCapacityProfile() async { + String? value = await groundedStorage.read(key: CAPACITY_PROFILE); + + if (value != null) { + if (value.isNotEmpty) { + Map json = jsonDecode(value); + return CapacityProfile.fromJson(json); + } + } + + return CapacityProfile(); + } + + @override + Future setCapacityProfile(CapacityProfile profile) async { + String value = jsonEncode(profile.toJson()); + await groundedStorage.write(key: CAPACITY_PROFILE, value: value); + } + + @override + Future getCachedDebtScore() async { + String? value = await groundedStorage.read(key: DEBT_SCORE); + + if (value != null) { + return double.tryParse(value) ?? 0; + } + + return 0; + } + + @override + Future setCachedDebtScore(double score) async { + await groundedStorage.write(key: DEBT_SCORE, value: score.toString()); + } + + @override + Future getEngagementCount() async { + String? value = await groundedStorage.read(key: ENGAGEMENT_COUNT); + + if (value != null) { + return int.tryParse(value) ?? 0; + } + + return 0; + } + + @override + Future setEngagementCount(int count) async { + await groundedStorage.write(key: ENGAGEMENT_COUNT, value: count.toString()); + } + + @override + Future getAmnestySpent() async { + String? value = await groundedStorage.read(key: AMNESTY_SPENT); + + if (value != null) { + return int.tryParse(value) ?? 0; + } + + return 0; + } + + @override + Future setAmnestySpent(int spent) async { + await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString()); + } + + @override + Future showOnboarding() async { + String? value = await groundedStorage.read(key: SHOW_ONBOARDING); + + if (value != null) { + return value.toLowerCase() == 'true'; + } + + return true; + } + + @override + Future setOnboardingOption(bool value) async { + await groundedStorage.write(key: SHOW_ONBOARDING, value: value.toString()); + } +} diff --git a/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart b/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart new file mode 100644 index 0000000..5051cf6 --- /dev/null +++ b/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart @@ -0,0 +1,12 @@ +import '../../utils/CapacityEngine.dart'; + +abstract class ConnectNewCommitment { + void onSaved(); + + /// The plan is over capacity — the save is refused until something is cut. + void onCapacityBlocked(CapacityVerdict verdict); + + /// The learned multiplier for this category, so the estimate field can show + /// what the app actually expects the task to take. + void onMultiplierResolved(double multiplier); +} diff --git a/frontend/lib/Grounded/see/commitment/NewCommitment.dart b/frontend/lib/Grounded/see/commitment/NewCommitment.dart new file mode 100644 index 0000000..69da0c3 --- /dev/null +++ b/frontend/lib/Grounded/see/commitment/NewCommitment.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'NewCommitmentState.dart'; + +class NewCommitment extends StatefulWidget { + const NewCommitment({super.key}); + + @override + State createState() => NewCommitmentState(); +} diff --git a/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart b/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart new file mode 100644 index 0000000..c3cecdc --- /dev/null +++ b/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart @@ -0,0 +1,552 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Commitment.dart'; +import '../../about/external/initial/CommitmentRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/EnergyCost.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ProofType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/CapacityEngine.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'ConnectNewCommitment.dart'; +import 'NewCommitment.dart'; +import 'ViewNewCommitment.dart'; + +class NewCommitmentState extends State + implements ConnectNewCommitment { + ViewNewCommitment? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _title = TextEditingController(); + + final TextEditingController _category = TextEditingController(); + + final TextEditingController _estimate = TextEditingController(); + + CommitmentClass _class = CommitmentClass.Standard; + + EnergyCost _energy = EnergyCost.Medium; + + ProofType _proof = ProofType.Honour; + + DateTime? _windowStart; + + DateTime? _windowEnd; + + double _multiplier = 1.0; + + String _windowError = ""; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewNewCommitment(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + // A window, not a date: the default opens now and closes in two hours, so + // the field is never left as a bare day. + final DateTime now = DateTime.now(); + setState(() { + _windowStart = now; + _windowEnd = now.add(const Duration(hours: 2)); + }); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, false); + } + + void _onCategoryChanged(String value) { + _model?.resolveMultiplier(value.trim()); + } + + void _onClassSelected(CommitmentClass value) { + setState(() { + _class = value; + // Non-negotiables carry real consequences, so they default to real proof + // rather than the honour checkbox. + if (value == CommitmentClass.NonNegotiable && + _proof == ProofType.Honour) { + _proof = ProofType.Photo; + } + }); + } + + void _onEnergySelected(EnergyCost value) { + setState(() { + _energy = value; + }); + } + + void _onProofSelected(ProofType value) { + setState(() { + _proof = value; + }); + } + + void _onPickWindowStart() async { + final DateTime? picked = await _pickMoment(_windowStart); + if (picked == null) { + return; + } + + setState(() { + _windowStart = picked; + if (_windowEnd == null || !_windowEnd!.isAfter(picked)) { + _windowEnd = picked.add(const Duration(hours: 2)); + } + _windowError = ""; + }); + } + + void _onPickWindowEnd() async { + final DateTime? picked = await _pickMoment(_windowEnd); + if (picked == null) { + return; + } + + setState(() { + _windowEnd = picked; + _windowError = Validators.window(_windowStart, _windowEnd) ?? ""; + }); + } + + Future _pickMoment(DateTime? initial) async { + final DateTime base = initial ?? DateTime.now(); + + final DateTime? day = await showDatePicker( + context: context, + initialDate: base, + firstDate: DateTime.now().subtract(const Duration(days: 1)), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + + if (day == null || !mounted) { + return null; + } + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(base), + ); + + if (time == null) { + return null; + } + + return DateTime(day.year, day.month, day.day, time.hour, time.minute); + } + + void _onSave() { + final String? windowIssue = Validators.window(_windowStart, _windowEnd); + + if (windowIssue != null) { + setState(() { + _windowError = windowIssue; + }); + return; + } + + if (_formKey.currentState?.validate() != true) { + return; + } + + _model?.save(_buildRequest(), _buildCandidate()); + } + + CommitmentRequest _buildRequest() { + return CommitmentRequest( + commitmentClass: _class.name, + title: _title.text.trim(), + category: _category.text.trim(), + dueStart: _windowStart?.toIso8601String() ?? "", + dueEnd: _windowEnd?.toIso8601String() ?? "", + estMinutes: int.tryParse(_estimate.text.trim()) ?? 0, + energy: _energy.name, + proofType: _proof.name, + proofTimerMinutes: + _proof == ProofType.Timer ? int.tryParse(_estimate.text.trim()) ?? 0 : 0, + ); + } + + Commitment _buildCandidate() { + return Commitment( + commitmentClass: _class, + title: _title.text.trim(), + category: _category.text.trim(), + dueStart: _windowStart, + dueEnd: _windowEnd, + estMinutes: int.tryParse(_estimate.text.trim()) ?? 0, + energy: _energy, + proofType: _proof, + ); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + final int estimate = int.tryParse(_estimate.text.trim()) ?? 0; + + final bool multiplierWorthShowing = _multiplier > 1.15 && estimate > 0; + + return Sheet( + eyebrow: "New", + title: "Commitment", + onBack: _onBack, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("What are you\ncommitting to?"), + const SizedBox(height: 28), + inputField( + "Title", + _title, + hint: "Say it the way you would say it out loud", + validator: Validators.title, + ), + const SizedBox(height: 20), + inputField( + "Category", + _category, + hint: "Admin, gym, deep work…", + onChanged: _onCategoryChanged, + ), + const SizedBox(height: 28), + sectionBreak("Class", caption: "decides the weight"), + segmentedSelector( + options: CommitmentClass.values, + selected: _class, + label: classLabel, + onSelected: _onClassSelected, + activeColor: classColor(_class), + ), + const SizedBox(height: 12), + text( + _classDescription(_class), + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 28), + sectionBreak("Window", caption: "not just a day"), + if (_windowError.isNotEmpty) ...[ + text(_windowError, 11, TextType.Regular, color: colorNegative), + const SizedBox(height: 10), + ], + Row( + children: [ + Expanded( + child: selectField( + "Opens", + _windowStart == null ? "" : formatDateTime(_windowStart), + _onPickWindowStart, + icon: CupertinoIcons.calendar, + ), + ), + const SizedBox(width: 10), + Expanded( + child: selectField( + "Closes", + _windowEnd == null ? "" : formatDateTime(_windowEnd), + _onPickWindowEnd, + icon: CupertinoIcons.calendar, + ), + ), + ], + ), + const SizedBox(height: 10), + text( + "When this window closes, the item goes overdue. It does not roll over to tomorrow.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 28), + sectionBreak("Effort"), + inputField( + "Estimated minutes", + _estimate, + hint: "How long you think it takes", + validator: Validators.estimateMinutes, + keyboard: TextInputType.number, + onChanged: (value) => setState(() {}), + ), + if (multiplierWorthShowing) ...[ + const SizedBox(height: 12), + card( + background: colorStandingWarnedBg, + borderColor: colorStandingWarned.withValues(alpha: 0.20), + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(CupertinoIcons.info_circle_fill, + size: 15, color: colorStandingWarned), + const SizedBox(width: 10), + Expanded( + child: text( + "On ${_category.text.trim().isEmpty ? "this category" : _category.text.trim()} you historically take ${_multiplier.toStringAsFixed(1)}× your estimate. Your day is planned against ${formatMinutes(estimate * _multiplier)}.", + 12, + TextType.Regular, + color: colorPrimaryDark, + height: 1.5, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + text("ENERGY COST", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 10), + segmentedSelector( + options: EnergyCost.values, + selected: _energy, + label: (value) => value.name, + onSelected: _onEnergySelected, + ), + const SizedBox(height: 28), + sectionBreak("Proof", caption: "the checkbox is the enemy"), + segmentedSelector( + options: ProofType.values, + selected: _proof, + label: proofLabel, + onSelected: _onProofSelected, + ), + const SizedBox(height: 12), + text( + _proofDescription(_proof), + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 32), + roundedCornerButton( + "Commit", + _onSave, + icon: CupertinoIcons.checkmark, + ), + const SizedBox(height: 10), + Center( + child: text( + "You can change the details later. You cannot change the history.", + 11, + TextType.Regular, + color: colorGrey2, + align: TextAlign.center, + ), + ), + ], + ), + ), + ); + } + + String _classDescription(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return "Never deferrable, heaviest debt, hardest escalation. Meds, deadlines, rent."; + case CommitmentClass.Standard: + return "Normal weight, two deferrals, then it is complete or abandon."; + case CommitmentClass.Elective: + return "No debt if you miss it. Auto-archives if it sits untouched."; + } + } + + String _proofDescription(ProofType value) { + switch (value) { + case ProofType.Honour: + return "A plain checkbox. Fine for trivia, worthless for anything you actually lie to yourself about."; + case ProofType.Photo: + return "Camera only, no gallery. Timestamped, and near-duplicate photos get flagged."; + case ProofType.Timer: + return "A foreground session. Backgrounding the app pauses the clock."; + case ProofType.Location: + return "Geofence dwell. Being near it does not count as being there."; + case ProofType.Health: + return "Your health platform confirms the workout happened inside the window."; + case ProofType.Witness: + return "Someone else confirms it. The hardest one to talk your way around."; + } + } + + /// The capacity refusal. It shows the arithmetic rather than just saying no, + /// because the point is to make overcommitment visible. + void _openCapacitySheet(CapacityVerdict verdict) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("OVER CAPACITY", 9, TextType.Bold, + color: colorStandingWarned, letterSpacing: 1.2), + const SizedBox(height: 10), + text("This day is already full.", 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 14), + text(verdict.message, 14, TextType.Regular, + color: colorGrey2, height: 1.55), + const SizedBox(height: 24), + card( + child: Row( + children: [ + Expanded( + child: labelled( + "Planned", + formatMinutes(verdict.projectedMinutes), + valueSize: 20, + valueType: TextType.Light, + ), + ), + Expanded( + child: labelled( + "You do", + formatMinutes(verdict.historicalMinutes), + valueSize: 20, + valueType: TextType.Light, + ), + ), + Expanded( + child: labelled( + "Cut", + formatMinutes(verdict.excessMinutes), + valueSize: 20, + valueType: TextType.Light, + valueColor: colorStandingGrounded, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + text( + "Chronic overdue is usually an overcommitment problem wearing a laziness costume. Cutting something now is the cheapest fix available.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 24), + roundedCornerButton( + "Let me cut something", + () => Navigator.pop(sheetContext), + icon: CupertinoIcons.scissors, + ), + const SizedBox(height: 8), + Center( + child: textButton( + "Add it anyway", + () { + Navigator.pop(sheetContext); + _model?.saveAnyway(_buildRequest()); + }, + textSize: 12, + color: colorGrey2, + ), + ), + ], + ), + ), + ); + }, + ); + } + + // ── ConnectNewCommitment ────────────────────────────────────────────────── + + @override + void onSaved() { + _model?.showApplicationNotification( + NotificationType.success, + "Committed", + "It is on the record now. The window closes ${formatDateTime(_windowEnd)}.", + true, + true, + () { + Navigator.pop(context, true); + }, + ); + } + + @override + void onCapacityBlocked(CapacityVerdict verdict) { + _openCapacitySheet(verdict); + } + + @override + void onMultiplierResolved(double multiplier) { + setState(() { + _multiplier = multiplier; + }); + } + + @override + void dispose() { + _title.dispose(); + _category.dispose(); + _estimate.dispose(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart b/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart new file mode 100644 index 0000000..4e97324 --- /dev/null +++ b/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart @@ -0,0 +1,93 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../about/external/data/pages/response/CommitmentPage.dart'; +import '../../about/external/initial/CommitmentRequest.dart'; +import '../../about/internal/application/CapacityProfile.dart'; +import '../../utils/CapacityEngine.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectNewCommitment.dart'; + +class ViewNewCommitment extends ParentViewModel { + ConnectNewCommitment connection; + + ViewNewCommitment(super.context, this.connection); + + /// Surfaces the learned multiplier for the category so the estimate field + /// can show what the app actually expects, rather than silently overriding. + void resolveMultiplier(String category) async { + final CapacityProfile profile = + await getDataManager().getCapacityProfile(); + + connection.onMultiplierResolved(profile.multiplierFor(category)); + } + + /// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed + /// as laziness, so the plan is checked against what history says actually + /// gets done before anything is accepted. + void save(CommitmentRequest request, Commitment candidate) async { + if (!await hasNetwork(() => save(request, candidate))) return; + + showLoading("Checking your day"); + + try { + final DateTime day = candidate.dueStart ?? DateTime.now(); + + final response = await getDataManager().getTodayPlan(CommitmentsRequest( + day: day.toIso8601String(), + query: PageAndSort( + sort: Sort('asc', 'dueStart'), + page: Pageable(0, 0, 100, 0), + ), + )); + + final CommitmentPage page = CommitmentPage.fromJson(response.data); + + final CapacityProfile profile = + await getDataManager().getCapacityProfile(); + + final List proposed = [ + ...page.content, + candidate, + ]; + + final CapacityVerdict verdict = + CapacityEngine.check(proposed, profile, day.weekday); + + if (verdict.blocked) { + closeLoading(); + connection.onCapacityBlocked(verdict); + return; + } + + await getDataManager().saveCommitmentEntry(request); + + closeLoading(); + + connection.onSaved(); + } catch (e) { + handleError( + e, () => save(request, candidate), () => dismissError(), "Retry"); + } + } + + /// Saving past a capacity block, which is only reachable after the user has + /// seen exactly how much they are over by. + void saveAnyway(CommitmentRequest request) async { + if (!await hasNetwork(() => saveAnyway(request))) return; + + showLoading("Saving"); + + try { + await getDataManager().saveCommitmentEntry(request); + + closeLoading(); + + connection.onSaved(); + } catch (e) { + handleError(e, () => saveAnyway(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/excuse/ConnectExcuseReport.dart b/frontend/lib/Grounded/see/excuse/ConnectExcuseReport.dart new file mode 100644 index 0000000..782e22d --- /dev/null +++ b/frontend/lib/Grounded/see/excuse/ConnectExcuseReport.dart @@ -0,0 +1,5 @@ +import '../../about/external/data/ExcuseCluster.dart'; + +abstract class ConnectExcuseReport { + void onClustersLoaded(List clusters); +} diff --git a/frontend/lib/Grounded/see/excuse/ExcuseReport.dart b/frontend/lib/Grounded/see/excuse/ExcuseReport.dart new file mode 100644 index 0000000..50ca55e --- /dev/null +++ b/frontend/lib/Grounded/see/excuse/ExcuseReport.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'ExcuseReportState.dart'; + +class ExcuseReport extends StatefulWidget { + const ExcuseReport({super.key}); + + @override + State createState() => ExcuseReportState(); +} diff --git a/frontend/lib/Grounded/see/excuse/ExcuseReportState.dart b/frontend/lib/Grounded/see/excuse/ExcuseReportState.dart new file mode 100644 index 0000000..5e54cd1 --- /dev/null +++ b/frontend/lib/Grounded/see/excuse/ExcuseReportState.dart @@ -0,0 +1,216 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectExcuseReport.dart'; +import 'ExcuseReport.dart'; +import 'ViewExcuseReport.dart'; + +class ExcuseReportState extends State + implements ConnectExcuseReport { + ViewExcuseReport? _model; + + List _clusters = []; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewExcuseReport(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadClusters(); + } + + void _onBack() { + Navigator.pop(context); + } + + Widget _mobileView(BoxConstraints constraints) { + final int total = _clusters.fold(0, (sum, item) => sum + item.occurrences); + + return Sheet( + eyebrow: "Last 30 days", + title: "Excuses", + onBack: _onBack, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("What you tell\nyourself."), + const SizedBox(height: 14), + text( + "Every deferral you wrote, grouped. Read the concentrations rather than the totals — that is where the pattern is.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 28), + if (_clusters.isEmpty) + emptyState( + CupertinoIcons.text_quote, + "Nothing to confront yet", + "Excuses appear here once you have deferred a few things. There is no shame in an empty page.", + accent: colorPositive, + ) + else ...[ + card( + background: colorPrimaryDark, + borderColor: colorPrimaryDark, + child: Row( + children: [ + Expanded( + child: labelled( + "Total excuses", + "$total", + valueSize: 30, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + Expanded( + child: labelled( + "Distinct kinds", + "${_clusters.length}", + valueSize: 30, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + sectionBreak("The taxonomy", caption: "most frequent first"), + ..._clusters.map(_clusterCard), + ], + ], + ), + ); + } + + Widget _clusterCard(ExcuseCluster cluster) { + final MapEntry? peakDay = _peak(cluster.byWeekday); + final MapEntry? peakHour = _peak(cluster.byHour); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: text(cluster.label, 19, TextType.Light, + color: colorPrimaryDark), + ), + pill("${cluster.occurrences}×", colorPrimaryDark, colorMuted, + textSize: 10), + ], + ), + if (cluster.insight.isNotEmpty) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorInset, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorBorder, width: 1), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(CupertinoIcons.quote_bubble_fill, + size: 14, color: colorGrey), + const SizedBox(width: 10), + Expanded( + child: text(cluster.insight, 13, TextType.Regular, + color: colorPrimaryDark, height: 1.55), + ), + ], + ), + ), + ], + hairline(margin: const EdgeInsets.symmetric(vertical: 16)), + Row( + children: [ + Expanded( + child: labelled( + "Worst day", + peakDay == null ? "—" : weekdayName(peakDay.key), + valueSize: 13, + ), + ), + Expanded( + child: labelled( + "Worst hour", + peakHour == null ? "—" : hourLabel(peakHour.key), + valueSize: 13, + ), + ), + Expanded( + child: labelled( + "Category", + cluster.dominantCategory.isEmpty + ? "—" + : cluster.dominantCategory, + valueSize: 13, + ), + ), + ], + ), + ], + ), + ), + ); + } + + MapEntry? _peak(Map histogram) { + if (histogram.isEmpty) { + return null; + } + + MapEntry? peak; + for (MapEntry entry in histogram.entries) { + if (peak == null || entry.value > peak.value) { + peak = entry; + } + } + return peak; + } + + @override + void onClustersLoaded(List clusters) { + setState(() { + _clusters = clusters; + }); + } +} diff --git a/frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart b/frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart new file mode 100644 index 0000000..0cbc1bd --- /dev/null +++ b/frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart @@ -0,0 +1,37 @@ +import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/initial/ReportCardRequest.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectExcuseReport.dart'; + +class ViewExcuseReport extends ParentViewModel { + ConnectExcuseReport connection; + + ViewExcuseReport(super.context, this.connection); + + void loadClusters() async { + if (!await hasNetwork(() => loadClusters())) return; + + showLoading("Reading your excuses"); + + try { + final DateTime now = DateTime.now(); + final DateTime start = now.subtract(const Duration(days: 30)); + + final response = + await getDataManager().getExcuseClusters(ReportCardRequest( + periodStart: start.toIso8601String(), + periodEnd: now.toIso8601String(), + )); + + final List clusters = + getExcuseClusterList(response.data); + + closeLoading(); + + connection.onClustersLoaded(clusters); + } catch (e) { + handleError(e, () => loadClusters(), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart new file mode 100644 index 0000000..5b96e85 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart @@ -0,0 +1,9 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/Goal.dart'; + +abstract class ConnectGoalDetail { + void onGoalLoaded(Goal goal, List tasks); + + /// The task is ready to run — hand off to the full-screen runner. + void onTaskReady(Commitment task); +} diff --git a/frontend/lib/Grounded/see/goal/ConnectGoals.dart b/frontend/lib/Grounded/see/goal/ConnectGoals.dart new file mode 100644 index 0000000..a896a4f --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ConnectGoals.dart @@ -0,0 +1,7 @@ +import '../../about/external/data/Goal.dart'; + +abstract class ConnectGoals { + void onGoalsLoaded(List goals); + + void onGoalSaved(); +} diff --git a/frontend/lib/Grounded/see/goal/GoalDetail.dart b/frontend/lib/Grounded/see/goal/GoalDetail.dart new file mode 100644 index 0000000..a574b9c --- /dev/null +++ b/frontend/lib/Grounded/see/goal/GoalDetail.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Goal.dart'; +import 'GoalDetailState.dart'; + +class GoalDetail extends StatefulWidget { + final Goal goal; + + const GoalDetail({super.key, required this.goal}); + + @override + State createState() => GoalDetailState(); +} diff --git a/frontend/lib/Grounded/see/goal/GoalDetailState.dart b/frontend/lib/Grounded/see/goal/GoalDetailState.dart new file mode 100644 index 0000000..adb35a8 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/GoalDetailState.dart @@ -0,0 +1,310 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/Goal.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/CommitmentStatus.dart'; +import '../../about/internal/application/ProofType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/DebtEngine.dart'; +import '../commitment/NewCommitment.dart'; +import '../live/LiveTask.dart'; +import 'ConnectGoalDetail.dart'; +import 'GoalDetail.dart'; +import 'ViewGoalDetail.dart'; + +class GoalDetailState extends State + implements ConnectGoalDetail { + ViewGoalDetail? _model; + + Goal _goal = Goal(); + + List _tasks = []; + + bool _changed = false; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGoalDetail(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + setState(() { + _goal = widget.goal; + }); + _model?.loadTasks(widget.goal); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, _changed); + } + + void _onStartTask(Commitment task) { + _model?.startTask(task); + } + + void _onAddTask() async { + final result = await GroundedNavigation() + .navigateToPageWithData(const NewCommitment(), context); + + if (result == true) { + _changed = true; + _model?.loadTasks(_goal); + } + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + final List open = _tasks + .where((task) => + task.status != CommitmentStatus.Completed && + task.status != CommitmentStatus.LateCompleted && + task.status != CommitmentStatus.Abandoned) + .toList(); + + final List done = _tasks + .where((task) => + task.status == CommitmentStatus.Completed || + task.status == CommitmentStatus.LateCompleted) + .toList(); + + return Sheet( + eyebrow: "Goal", + title: _goal.title, + onBack: _onBack, + action: chromeAction(CupertinoIcons.add, _onAddTask), + banner: _progressBanner(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_goal.description.isNotEmpty) ...[ + text(_goal.description, 15, TextType.Regular, + color: colorGrey2, height: 1.6), + const SizedBox(height: 28), + ], + sectionBreak("To do", caption: "${open.length} open"), + if (open.isEmpty) + emptyState( + CupertinoIcons.square_list, + "Nothing scheduled", + "Add the actual sessions — Monday shoulders, Wednesday legs — and they start counting.", + ) + else + ...open.map(_taskRow), + if (done.isNotEmpty) ...[ + const SizedBox(height: 28), + sectionBreak("Done", caption: "${done.length}"), + ...done.map(_doneRow), + ], + const SizedBox(height: 24), + roundedCornerButton("Add a task", _onAddTask, + icon: CupertinoIcons.add), + ], + ), + ); + } + + Widget _progressBanner() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("PROGRESS", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2), + text("${(_goal.progress * 100).round()}%", 13, TextType.Bold, + color: colorWhite), + ], + ), + const SizedBox(height: 12), + meter( + _goal.progress, + fill: colorWhite, + track: colorWhite.withValues(alpha: 0.14), + height: 5, + ), + ], + ), + ); + } + + /// A task row leads with the action: the point of opening a goal is to start + /// something, not to admire the list. + Widget _taskRow(Commitment task) { + final Color accent = classColor(task.commitmentClass); + + final bool late = task.windowClosed; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 3, + height: 38, + margin: const EdgeInsets.only(right: 14, top: 2), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(4), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(task.title, 16, TextType.Medium, + color: colorPrimaryDark, + maxLines: 2, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 7), + Row( + children: [ + text(formatWindow(task), 11, TextType.Regular, + color: colorGrey2), + const SizedBox(width: 9), + Container( + width: 3, + height: 3, + decoration: BoxDecoration( + color: colorGrey, shape: BoxShape.circle), + ), + const SizedBox(width: 9), + text(formatMinutes(task.estMinutes), 11, + TextType.Regular, color: colorGrey2), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + pill(proofLabel(task.proofType), colorGrey2, colorMuted, + textSize: 9), + ], + ), + if (late) ...[ + const SizedBox(height: 12), + Row( + children: [ + pill(overdueLabel(task), colorStandingGrounded, + colorStandingGroundedBg, textSize: 9), + const SizedBox(width: 6), + pill("−${formatDebt(DebtEngine.commitmentDebt(task))}", + colorGrey2, colorMuted, textSize: 9), + ], + ), + ], + const SizedBox(height: 14), + roundedCornerButton( + "Start", + () => _onStartTask(task), + icon: CupertinoIcons.play_fill, + verticalPadding: 13, + ), + ], + ), + ), + ); + } + + Widget _doneRow(Commitment task) { + final bool late = task.status == CommitmentStatus.LateCompleted; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Icon( + late + ? CupertinoIcons.checkmark_circle + : CupertinoIcons.checkmark_circle_fill, + size: 17, + color: late ? colorStandingWarned : colorPositive, + ), + const SizedBox(width: 12), + Expanded( + child: text(task.title, 13, TextType.Regular, + color: colorGrey2, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + if (late) + pill("Late", colorStandingWarned, colorStandingWarnedBg, + textSize: 9), + ], + ), + ), + ); + } + + // ── ConnectGoalDetail ───────────────────────────────────────────────────── + + @override + void onGoalLoaded(Goal goal, List tasks) { + setState(() { + _goal = goal; + _tasks = tasks; + }); + } + + @override + void onTaskReady(Commitment task) async { + // The runner takes over the whole screen — a task you are running is the + // thing you are doing, not a row in a list. + final result = await GroundedNavigation().navigateToPageWithData( + LiveTask(commitment: task, goalTitle: _goal.title), + context, + ); + + if (result == true) { + _changed = true; + _model?.loadTasks(_goal); + } + } +} diff --git a/frontend/lib/Grounded/see/goal/Goals.dart b/frontend/lib/Grounded/see/goal/Goals.dart new file mode 100644 index 0000000..554e408 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/Goals.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'GoalsState.dart'; + +class Goals extends StatefulWidget { + const Goals({super.key}); + + @override + State createState() => GoalsState(); +} diff --git a/frontend/lib/Grounded/see/goal/GoalsState.dart b/frontend/lib/Grounded/see/goal/GoalsState.dart new file mode 100644 index 0000000..633b8b7 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/GoalsState.dart @@ -0,0 +1,311 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Goal.dart'; +import '../../about/external/initial/GoalRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'ConnectGoals.dart'; +import 'GoalDetail.dart'; +import 'Goals.dart'; +import 'ViewGoals.dart'; + +class GoalsState extends State implements ConnectGoals { + ViewGoals? _model; + + List _goals = []; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGoals(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadGoals(); + } + + void _onBack() { + Navigator.pop(context); + } + + void _onOpenGoal(Goal goal) async { + final result = await GroundedNavigation() + .navigateToPageWithData(GoalDetail(goal: goal), context); + + if (result == true) { + _model?.loadGoals(); + } + } + + void _onNewGoal() { + _openGoalSheet(); + } + + /// Goals are lightweight on purpose — a name and a default class. The + /// weight lives on the tasks inside them. + void _openGoalSheet() { + final TextEditingController title = TextEditingController(); + final TextEditingController description = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + CommitmentClass defaultClass = CommitmentClass.Standard; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return StatefulBuilder( + builder: (BuildContext sheetContext, StateSetter setSheetState) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("NEW GOAL", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + text("What are you\nworking toward?", 26, + TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 20), + inputField( + "Goal", + title, + hint: "Workout, thesis, get the flat sorted…", + validator: Validators.title, + ), + const SizedBox(height: 18), + inputField( + "Why it matters", + description, + hint: "Optional, but it helps on the bad days", + maxLines: 3, + ), + const SizedBox(height: 20), + text("TASKS DEFAULT TO", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 10), + segmentedSelector( + options: CommitmentClass.values, + selected: defaultClass, + label: classLabel, + onSelected: (value) => setSheetState(() { + defaultClass = value; + }), + activeColor: classColor(defaultClass), + ), + const SizedBox(height: 24), + roundedCornerButton( + "Create goal", + () { + if (formKey.currentState?.validate() != true) { + return; + } + Navigator.pop(sheetContext); + _model?.save(GoalRequest( + title: title.text.trim(), + description: description.text.trim(), + defaultClass: defaultClass.name, + startDate: DateTime.now().toIso8601String(), + )); + }, + icon: CupertinoIcons.add, + ), + const SizedBox(height: 8), + Center( + child: textButton("Cancel", + () => Navigator.pop(sheetContext), + textSize: 13), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + } + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: "Grounded", + title: "Goals", + onBack: _onBack, + action: chromeAction(CupertinoIcons.add, _onNewGoal), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("What you are\nworking toward."), + const SizedBox(height: 14), + text( + "A goal holds the tasks that get you there. The goal never carries debt — the tasks inside it do.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 28), + if (_goals.isEmpty) + emptyState( + CupertinoIcons.flag, + "No goals yet", + "Create one — Workout, say — then put the actual sessions inside it.", + ) + else + ..._goals.map(_goalCard), + const SizedBox(height: 24), + roundedCornerButton("New goal", _onNewGoal, + icon: CupertinoIcons.add), + ], + ), + ); + } + + Widget _goalCard(Goal goal) { + final Color accent = + goal.slipping ? colorStandingGrounded : colorPrimaryDark; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: card( + onTap: () => _onOpenGoal(goal), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(goal.title, 19, TextType.Light, + color: colorPrimaryDark, + maxLines: 2, + overflow: TextOverflow.ellipsis), + if (goal.description.isNotEmpty) ...[ + const SizedBox(height: 6), + text(goal.description, 12, TextType.Regular, + color: colorGrey2, + maxLines: 2, + overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + const SizedBox(width: 10), + if (goal.overdueTasks > 0) + pill("${goal.overdueTasks} late", colorStandingGrounded, + colorStandingGroundedBg, textSize: 9), + ], + ), + const SizedBox(height: 18), + meter(goal.progress, fill: accent), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: labelled( + "Done", + "${goal.completedTasks} of ${goal.totalTasks}", + valueSize: 13, + ), + ), + Expanded( + child: labelled( + "Remaining", + "${goal.remainingTasks}", + valueSize: 13, + ), + ), + Expanded( + child: labelled( + "Debt", + formatDebt(goal.debtContribution), + valueSize: 13, + valueColor: goal.debtContribution > 0 + ? colorStandingGrounded + : colorPrimaryDark, + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void onGoalsLoaded(List goals) { + setState(() { + _goals = goals; + }); + } + + @override + void onGoalSaved() { + _model?.loadGoals(); + } +} diff --git a/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart new file mode 100644 index 0000000..5369829 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart @@ -0,0 +1,37 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/Goal.dart'; +import '../../about/external/initial/IdRequest.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGoalDetail.dart'; + +class ViewGoalDetail extends ParentViewModel { + ConnectGoalDetail connection; + + ViewGoalDetail(super.context, this.connection); + + void loadTasks(Goal goal) async { + if (!await hasNetwork(() => loadTasks(goal))) return; + + showLoading("Loading ${goal.title}"); + + try { + final response = + await getDataManager().getGoalTasks(IdRequest(id: goal.id ?? "")); + + closeLoading(); + + connection.onGoalLoaded(goal, getCommitmentList(response.data)); + } catch (e) { + handleError(e, () => loadTasks(goal), () => dismissError(), "Retry"); + } + } + + /// Stashes the task as the active one before the runner opens, so the + /// ongoing notification and any relaunch land back on the right thing. + void startTask(Commitment task) async { + await getDataManager().setActiveCommitment(task); + + connection.onTaskReady(task); + } +} diff --git a/frontend/lib/Grounded/see/goal/ViewGoals.dart b/frontend/lib/Grounded/see/goal/ViewGoals.dart new file mode 100644 index 0000000..3418bab --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ViewGoals.dart @@ -0,0 +1,51 @@ +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../about/external/initial/GoalRequest.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGoals.dart'; + +class ViewGoals extends ParentViewModel { + ConnectGoals connection; + + ViewGoals(super.context, this.connection); + + void loadGoals() async { + if (!await hasNetwork(() => loadGoals())) return; + + showLoading("Loading your goals"); + + try { + final response = await getDataManager().getMyGoals(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'startDate'), + page: Pageable(0, 0, 50, 0), + ), + )); + + closeLoading(); + + connection.onGoalsLoaded(getGoalList(response.data)); + } catch (e) { + handleError(e, () => loadGoals(), () => dismissError(), "Retry"); + } + } + + void save(GoalRequest request) async { + if (!await hasNetwork(() => save(request))) return; + + showLoading("Saving"); + + try { + await getDataManager().saveGoalEntry(request); + + closeLoading(); + + connection.onGoalSaved(); + } catch (e) { + handleError(e, () => save(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/home/ConnectHome.dart b/frontend/lib/Grounded/see/home/ConnectHome.dart new file mode 100644 index 0000000..dad1be4 --- /dev/null +++ b/frontend/lib/Grounded/see/home/ConnectHome.dart @@ -0,0 +1,24 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/internal/application/Standing.dart'; +import '../../about/internal/application/UserDetails.dart'; + +abstract class ConnectHome { + void onUserLoaded(UserDetails details); + + void onPlanLoaded(List plan); + + void onOverdueLoaded(List overdue); + + /// Standing arrives derived, with the debt it was derived from. + void onStandingResolved(Standing standing, double debtScore); + + /// The one excuse pattern worth confronting the user with today. + void onExcuseInsight(ExcuseCluster? cluster); + + /// Distress detected — the strict persona drops entirely. + void onDistressDetected(); + + /// Creating a commitment is refused at this standing. + void onCreationBlocked(String reason); +} diff --git a/frontend/lib/Grounded/see/home/Home.dart b/frontend/lib/Grounded/see/home/Home.dart new file mode 100644 index 0000000..157df0a --- /dev/null +++ b/frontend/lib/Grounded/see/home/Home.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'HomeState.dart'; + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State createState() => HomeState(); +} diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart new file mode 100644 index 0000000..45949e1 --- /dev/null +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -0,0 +1,696 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/CommitmentStatus.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/Standing.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../about/internal/application/ToneLevel.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/DebtEngine.dart'; +import '../../utils/StandingEngine.dart'; +import '../../utils/Thresholds.dart'; +import '../../utils/ToneEngine.dart'; +import '../commitment/NewCommitment.dart'; +import '../excuse/ExcuseReport.dart'; +import '../goal/Goals.dart'; +import '../overdue/OverdueQueue.dart'; +import '../reportcard/ReportCardScreen.dart'; +import '../settings/Settings.dart'; +import '../training/Training.dart'; +import 'ConnectHome.dart'; +import 'Home.dart'; +import 'ViewHome.dart'; + +class HomeState extends State implements ConnectHome { + ViewHome? _model; + + UserDetails _user = UserDetails(pic: '', name: ''); + + List _plan = []; + + List _overdue = []; + + Standing _standing = Standing.Good; + + double _debt = 0; + + ExcuseCluster? _insight; + + bool _distressed = false; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewHome(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.initialise(); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onOpenOverdue() async { + final result = await GroundedNavigation() + .navigateToPageWithData(const OverdueQueue(), context); + + if (result == true) { + _model?.loadPlan(); + } + } + + void _onAddCommitment() async { + if (!StandingEngine.permitsNewCommitment(_standing)) { + _model?.requestNewCommitment(_standing, CommitmentClass.Standard); + return; + } + + final result = await GroundedNavigation() + .navigateToPageWithData(const NewCommitment(), context); + + if (result == true) { + _model?.loadPlan(); + } + } + + void _onOpenReportCard() { + GroundedNavigation().navigateToPage( + NavigatorType.justOpen, const ReportCardScreen(), context); + } + + void _onOpenGoals() async { + final result = await GroundedNavigation() + .navigateToPageWithData(const Goals(), context); + + if (result == true) { + _model?.loadPlan(); + } + } + + void _onOpenTraining() { + GroundedNavigation() + .navigateToPage(NavigatorType.justOpen, const Training(), context); + } + + void _onOpenSettings() { + GroundedNavigation() + .navigateToPage(NavigatorType.justOpen, const Settings(), context); + } + + void _onOpenExcuses() { + GroundedNavigation() + .navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + // Grounded and Lockdown replace the home screen with the overdue queue — + // you do not get to look at your nice plans, only at your mess. + final bool queueIsHome = + StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed; + + return Sheet( + eyebrow: _user.name.isEmpty ? "Grounded" : _user.name, + title: queueIsHome ? "What you owe" : "Today", + chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing), + banner: _standingBanner(), + action: chromeAction( + CupertinoIcons.person, + _onOpenSettings, + dotted: _user.sickMode, + dotColor: colorStandingWarned, + ), + child: _distressed + ? _distressBody() + : queueIsHome + ? _groundedBody() + : _planBody(), + ); + } + + /// The chrome carries the standing colour, so the tier is legible before a + /// single word is read. + Color _chromeFor(Standing standing) { + switch (standing) { + case Standing.Good: + return colorPrimaryDark; + case Standing.Warned: + return colorPrimaryDark; + case Standing.Grounded: + return colorStandingGrounded; + case Standing.Lockdown: + return colorStandingLockdown; + } + } + + /// The debt strip that sits in the black chrome under the title. + Widget _standingBanner() { + if (_distressed) { + return const SizedBox.shrink(); + } + + final Color tone = standingColor(_standing); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: _standing == Standing.Good ? tone : colorWhite, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + text( + standingLabel(_standing).toUpperCase(), + 9, + TextType.Bold, + color: colorWhite.withValues(alpha: 0.75), + letterSpacing: 1.2, + ), + ], + ), + const SizedBox(height: 8), + text( + ToneEngine.standingHeadline(_standing, _user.tone), + 16, + TextType.Medium, + color: colorWhite, + ), + ], + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + text("DEBT", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.0), + const SizedBox(height: 4), + text(formatDebt(_debt), 30, TextType.Light, color: colorWhite), + ], + ), + ], + ), + ); + } + + /// The normal day: the plan, with the overdue count kept visible above it so + /// it is never out of sight. + Widget _planBody() { + final int overdueCount = _overdue.length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (overdueCount > 0) ...[ + _overdueCallout(overdueCount), + const SizedBox(height: 24), + ], + if (_insight != null) ...[ + _insightCard(_insight!), + const SizedBox(height: 24), + ], + sectionBreak( + "The plan", + caption: "${_plan.length} committed", + trailing: _plan.isEmpty + ? null + : text(formatMinutes(_plannedMinutes()), 12, TextType.Bold, + color: colorGrey2), + ), + if (_plan.isEmpty) + emptyState( + CupertinoIcons.square_list, + "Nothing committed today", + "An empty plan is a decision too. Add something you actually intend to do.", + ) + else + ..._plan.map(_commitmentRow), + const SizedBox(height: 28), + _quickLinks(), + const SizedBox(height: 24), + roundedCornerButton( + "Commit to something", + _onAddCommitment, + icon: CupertinoIcons.add, + enabled: StandingEngine.permitsNewCommitment(_standing), + ), + if (!StandingEngine.permitsNewCommitment(_standing)) ...[ + const SizedBox(height: 10), + text( + ToneEngine.standingBody(_standing, _user.tone), + 12, + TextType.Regular, + color: colorGrey2, + align: TextAlign.center, + ), + ], + ], + ); + } + + /// Grounded: the plan is hidden entirely and only the mess is shown. + Widget _groundedBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + displayTitle( + _standing == Standing.Lockdown + ? "One at a time." + : "Clear this first.", + ), + const SizedBox(height: 12), + text( + ToneEngine.standingBody(_standing, _user.tone), + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 28), + card( + background: standingBackground(_standing), + borderColor: standingColor(_standing).withValues(alpha: 0.20), + child: Row( + children: [ + Expanded( + child: labelled( + "Open overdue", + "${_overdue.length}", + valueSize: 26, + valueType: TextType.Light, + valueColor: standingColor(_standing), + ), + ), + Expanded( + child: labelled( + "Debt to clear", + formatDebt( + StandingEngine.debtToNextTierDown(_debt, _standing)), + valueSize: 26, + valueType: TextType.Light, + valueColor: standingColor(_standing), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + sectionBreak("Outstanding", caption: "${_overdue.length} items"), + if (_overdue.isEmpty) + emptyState( + CupertinoIcons.checkmark_seal, + "The queue is empty", + "Your standing will recover as the debt decays.", + ) + else + ..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length) + .map(_commitmentRow), + const SizedBox(height: 24), + roundedCornerButton( + _standing == Standing.Lockdown ? "Deal with this one" : "Open the queue", + _onOpenOverdue, + background: standingColor(_standing), + icon: CupertinoIcons.arrow_right, + ), + ], + ); + } + + /// Distress: the strict persona drops entirely. This is the difference + /// between a product people keep and one they resent. + Widget _distressBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + text("A NOTE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + displayTitle(ToneEngine.distressHeadline()), + const SizedBox(height: 14), + text( + ToneEngine.distressBody(), + 15, + TextType.Regular, + color: colorGrey2, + height: 1.6, + ), + const SizedBox(height: 28), + card( + background: colorStandingGoodBg, + borderColor: colorPositive.withValues(alpha: 0.20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("PAUSED", 9, TextType.Bold, + color: colorPositive, letterSpacing: 1.2), + const SizedBox(height: 8), + text("Debt is not accruing right now.", 16, TextType.Medium, + color: colorPrimaryDark), + const SizedBox(height: 6), + text( + "Nothing you miss this week is counting against you.", + 13, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + ], + ), + ), + const SizedBox(height: 24), + sectionBreak("Three things", caption: "that actually matter"), + ..._plan + .where((item) => + item.commitmentClass == CommitmentClass.NonNegotiable) + .take(3) + .map(_commitmentRow), + const SizedBox(height: 24), + outlinedActionButton("Open settings", _onOpenSettings, + icon: CupertinoIcons.slider_horizontal_3), + ], + ); + } + + Widget _overdueCallout(int count) { + return card( + background: colorStandingGroundedBg, + borderColor: colorStandingGrounded.withValues(alpha: 0.20), + onTap: _onOpenOverdue, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorStandingGrounded, + borderRadius: BorderRadius.circular(13), + ), + child: text("$count", 17, TextType.Bold, color: colorWhite), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("OVERDUE", 9, TextType.Bold, + color: colorStandingGrounded, letterSpacing: 1.2), + const SizedBox(height: 5), + text( + count >= Thresholds.maxOpenOverdue + ? "You are at the cap. Nothing new until this drops." + : "$count item${count == 1 ? "" : "s"} past the window.", + 14, + TextType.Medium, + color: colorPrimaryDark, + ), + ], + ), + ), + Icon(CupertinoIcons.chevron_right, + size: 15, color: colorStandingGrounded), + ], + ), + ); + } + + /// The excuse confrontation. One pattern, stated plainly, with the + /// suggestion attached. + Widget _insightCard(ExcuseCluster cluster) { + return card( + background: colorPrimaryDark, + borderColor: colorPrimaryDark, + onTap: _onOpenExcuses, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("PATTERN", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2), + text("${cluster.occurrences}×", 11, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45)), + ], + ), + const SizedBox(height: 12), + text(cluster.insight, 15, TextType.Regular, + color: colorWhite, height: 1.55), + ], + ), + ); + } + + Widget _commitmentRow(Commitment item) { + final bool late = item.windowClosed && + item.status != CommitmentStatus.Completed && + item.status != CommitmentStatus.LateCompleted; + + final Color accent = classColor(item.commitmentClass); + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + onTap: _onOpenOverdue, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 3, + height: 42, + margin: const EdgeInsets.only(right: 14, top: 2), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(4), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(item.title, 15, TextType.Medium, + color: colorPrimaryDark, + maxLines: 2, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 8), + Row( + children: [ + text(formatWindow(item), 11, TextType.Regular, + color: colorGrey2), + const SizedBox(width: 10), + Container(width: 3, height: 3, decoration: BoxDecoration( + color: colorGrey, shape: BoxShape.circle)), + const SizedBox(width: 10), + text(formatMinutes(item.estMinutes), 11, + TextType.Regular, color: colorGrey2), + ], + ), + if (late) ...[ + const SizedBox(height: 10), + Row( + children: [ + pill( + overdueLabel(item), + colorStandingGrounded, + colorStandingGroundedBg, + textSize: 9, + ), + const SizedBox(width: 6), + pill( + "−${formatDebt(DebtEngine.commitmentDebt(item))}", + colorGrey2, + colorMuted, + textSize: 9, + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: 10), + pill( + classLabel(item.commitmentClass), + accent, + classBackground(item.commitmentClass), + textSize: 9, + ), + ], + ), + ), + ); + } + + Widget _quickLinks() { + return Row( + children: [ + Expanded( + child: _quickLink( + CupertinoIcons.flag_fill, + "Goals", + _onOpenGoals, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _quickLink( + CupertinoIcons.chart_bar_alt_fill, + "Report", + _onOpenReportCard, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _quickLink( + CupertinoIcons.flame_fill, + "Training", + _onOpenTraining, + ), + ), + ], + ); + } + + Widget _quickLink(IconData icon, String label, VoidCallback onTap) { + return card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), + onTap: onTap, + child: Row( + children: [ + Icon(icon, size: 17, color: colorPrimaryDark), + const SizedBox(width: 10), + Expanded( + child: text(label, 13, TextType.Medium, + color: colorPrimaryDark, maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + } + + double _plannedMinutes() { + double total = 0; + for (Commitment item in _plan) { + total = total + item.estMinutes; + } + return total; + } + + // ── ConnectHome ─────────────────────────────────────────────────────────── + + @override + void onUserLoaded(UserDetails details) { + setState(() { + _user = details; + }); + } + + @override + void onPlanLoaded(List plan) { + setState(() { + _plan = plan; + }); + } + + @override + void onOverdueLoaded(List overdue) { + setState(() { + _overdue = overdue; + }); + } + + @override + void onStandingResolved(Standing standing, double debtScore) { + setState(() { + _standing = standing; + _debt = debtScore; + }); + } + + @override + void onExcuseInsight(ExcuseCluster? cluster) { + setState(() { + _insight = cluster; + }); + } + + @override + void onDistressDetected() { + setState(() { + _distressed = true; + }); + } + + @override + void onCreationBlocked(String reason) { + _model?.showApplicationNotification( + NotificationType.warning, + "Not right now", + reason, + true, + true, + null, + ); + } +} diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart new file mode 100644 index 0000000..6be05b6 --- /dev/null +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -0,0 +1,164 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../about/external/data/pages/response/CommitmentPage.dart'; +import '../../about/external/initial/ReportCardRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/Standing.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../utils/DebtEngine.dart'; +import '../../utils/GuardrailEngine.dart'; +import '../../utils/StandingEngine.dart'; +import '../../utils/ToneEngine.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectHome.dart'; + +class ViewHome extends ParentViewModel { + ConnectHome connection; + + ViewHome(super.context, this.connection); + + /// Loads the cached user first so the screen never opens on a spinner, then + /// refreshes everything from the server. + void initialise() async { + final UserDetails cached = await getDataManager().getUserDetails(); + connection.onUserLoaded(cached); + + loadPlan(); + } + + void loadPlan() async { + if (!await hasNetwork(() => loadPlan())) return; + + showLoading("Loading your day"); + + try { + final response = await getDataManager().getTodayPlan(CommitmentsRequest( + query: PageAndSort( + sort: Sort('asc', 'dueStart'), + page: Pageable(0, 0, 50, 0), + ), + )); + + final CommitmentPage page = CommitmentPage.fromJson(response.data); + + closeLoading(); + + connection.onPlanLoaded(page.content); + + loadOverdue(); + } catch (e) { + handleError(e, () => loadPlan(), () => dismissError(), "Retry"); + } + } + + void loadOverdue() async { + try { + final response = await getDataManager().getOverdueQueue(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'dueEnd'), + page: Pageable(0, 0, 50, 0), + ), + )); + + final CommitmentPage page = CommitmentPage.fromJson(response.data); + + connection.onOverdueLoaded(page.content); + + resolveStanding(page.content); + + loadExcuseInsight(); + } catch (e) { + handleError(e, () => loadOverdue(), () => dismissError(), "Retry"); + } + } + + /// Standing is derived on device from the same formula the server uses, so + /// the number on screen is never stale relative to the queue beneath it. + void resolveStanding(List overdue) async { + final UserDetails details = await getDataManager().getUserDetails(); + + final bool distressed = await _checkDistress(); + + final double debt = DebtEngine.totalDebt(overdue); + + final Standing standing = StandingEngine.evaluate( + debt, + missedNonNegotiables: DebtEngine.missedNonNegotiables(overdue), + sickMode: details.sickMode, + distressed: distressed, + ); + + await getDataManager().setCachedDebtScore(debt); + + connection.onStandingResolved(standing, debt); + + if (distressed) { + connection.onDistressDetected(); + } + } + + Future _checkDistress() async { + final double previous = await getDataManager().getCachedDebtScore(); + final int opens = await getDataManager().getEngagementCount(); + final double current = await getDataManager().getCachedDebtScore(); + + return GuardrailEngine.detectDistress( + debtDelta: current - previous, + appOpensThisWeek: opens, + meanReadiness: 0, + ); + } + + void loadExcuseInsight() async { + try { + final DateTime now = DateTime.now(); + final DateTime start = now.subtract(const Duration(days: 30)); + + final response = + await getDataManager().getExcuseClusters(ReportCardRequest( + periodStart: start.toIso8601String(), + periodEnd: now.toIso8601String(), + )); + + final List clusters = (response.data as List) + .map((item) => ExcuseCluster.fromJson(item)) + .toList(); + + // Only the strongest pattern is surfaced on the home screen — a wall of + // findings reads as noise and gets ignored. + final List worth = + clusters.where((cluster) => cluster.insight.isNotEmpty).toList(); + + connection.onExcuseInsight(worth.isEmpty ? null : worth.first); + } catch (e) { + // The insight is a bonus, never a blocker — a failure here stays silent. + connection.onExcuseInsight(null); + } + } + + /// The gate on creating anything new. Grounded blocks everything; Warned + /// blocks electives only. + void requestNewCommitment(Standing standing, CommitmentClass intended) async { + final bool elective = intended == CommitmentClass.Elective; + + if (StandingEngine.permitsNewCommitment(standing, elective: elective)) { + return; + } + + if (standing == Standing.Warned && elective) { + connection.onCreationBlocked( + "Electives are blocked while you are warned. Clear some debt first."); + return; + } + + final UserDetails details = await getDataManager().getUserDetails(); + + connection + .onCreationBlocked(ToneEngine.standingBody(standing, details.tone)); + } +} diff --git a/frontend/lib/Grounded/see/live/ConnectLiveTask.dart b/frontend/lib/Grounded/see/live/ConnectLiveTask.dart new file mode 100644 index 0000000..197fc1b --- /dev/null +++ b/frontend/lib/Grounded/see/live/ConnectLiveTask.dart @@ -0,0 +1,8 @@ +abstract class ConnectLiveTask { + void onCompleted(); + + /// Completion attempted before the required foreground time was reached. + void onTooEarly(int secondsRemaining); + + void onAbandoned(); +} diff --git a/frontend/lib/Grounded/see/live/LiveTask.dart b/frontend/lib/Grounded/see/live/LiveTask.dart new file mode 100644 index 0000000..126d871 --- /dev/null +++ b/frontend/lib/Grounded/see/live/LiveTask.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Commitment.dart'; +import 'LiveTaskState.dart'; + +class LiveTask extends StatefulWidget { + /// The commitment being run. + final Commitment commitment; + + /// The goal it belongs to, shown as context in the runner and the ongoing + /// notification. + final String goalTitle; + + const LiveTask({super.key, required this.commitment, this.goalTitle = ""}); + + @override + State createState() => LiveTaskState(); +} diff --git a/frontend/lib/Grounded/see/live/LiveTaskState.dart b/frontend/lib/Grounded/see/live/LiveTaskState.dart new file mode 100644 index 0000000..199d970 --- /dev/null +++ b/frontend/lib/Grounded/see/live/LiveTaskState.dart @@ -0,0 +1,456 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/LiveSession.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ProofType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'ConnectLiveTask.dart'; +import 'LiveTask.dart'; +import 'ViewLiveTask.dart'; + +/// The full-screen runner. Deliberately the only thing on screen: a task you +/// are running is not a row in a list, it is the thing you are doing. +class LiveTaskState extends State + with WidgetsBindingObserver + implements ConnectLiveTask { + ViewLiveTask? _model; + + late LiveSession _session; + + Timer? _ticker; + + /// Drives the display only. The elapsed value itself is derived from + /// wall-clock, so a throttled ticker during screen-off cannot lose time. + int _tick = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + _session = LiveSession( + commitmentId: widget.commitment.id ?? "", + title: widget.commitment.title, + goalTitle: widget.goalTitle, + startedAt: DateTime.now(), + requiredSeconds: widget.commitment.proofType == ProofType.Timer + ? widget.commitment.proofTimerMinutes * 60 + : 0, + ); + + _session.resume(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewLiveTask(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => PopScope( + // Leaving mid-run is a decision, not a back gesture. + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + _onRequestExit(); + } + }, + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: colorPrimaryDark, + body: SafeArea(child: _runnerView()), + ), + ), + ), + ); + } + + void _initiate() { + _startTicker(); + _model?.publishSession(_session); + } + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + return; + } + setState(() { + _tick = _tick + 1; + }); + + // Refresh the lock-screen notification every 5s rather than every tick, + // so the ongoing notification stays current without thrashing. + if (_tick % 5 == 0 && _session.running) { + _model?.publishSession(_session); + } + }); + } + + /// Backgrounding pauses the clock — that is what makes Timer proof mean + /// something. The count is kept and shown rather than hidden. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (!_session.running) { + setState(() { + _session.resume(); + }); + _model?.publishSession(_session); + } + return; + } + + if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden) { + if (_session.running) { + setState(() { + _session.pause(); + _session.backgroundedCount = _session.backgroundedCount + 1; + }); + _model?.publishSession(_session); + } + } + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onTogglePause() { + setState(() { + if (_session.running) { + _session.pause(); + } else { + _session.resume(); + } + }); + _model?.publishSession(_session); + } + + void _onFinish() { + _model?.complete(widget.commitment, _session); + } + + void _onRequestExit() { + _model?.showApplicationNotification( + NotificationType.warning, + "Leave this running?", + _session.satisfied() + ? "You have met the requirement. You can finish it properly instead of walking away." + : "You are ${formatClock(_session.remainingSeconds())} short. Leaving now logs nothing.", + true, + true, + null, + action: "Leave anyway", + positiveAction: () { + Navigator.pop(context); + _model?.clearSession(); + Navigator.pop(context, false); + }, + ); + } + + void _onAbandon() { + final TextEditingController reason = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorBlack.withValues(alpha: 0.7), + builder: (BuildContext sheetContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + text("ABANDONING MID-RUN", 9, TextType.Bold, + color: colorStandingLockdown, letterSpacing: 1.2), + const SizedBox(height: 10), + text(widget.commitment.title, 24, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 16), + inputField( + "Reason", + reason, + hint: "Why is this stopping here?", + validator: Validators.excuse, + maxLines: 3, + ), + const SizedBox(height: 20), + destructiveButton("Abandon", () { + if (formKey.currentState?.validate() != true) { + return; + } + Navigator.pop(sheetContext); + _model?.abandon(widget.commitment, reason.text.trim()); + }), + const SizedBox(height: 8), + Center( + child: textButton("Keep going", + () => Navigator.pop(sheetContext), textSize: 13), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _runnerView() { + final bool timed = _session.requiredSeconds > 0; + + final bool satisfied = _session.satisfied(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _runnerHeader(), + _runnerClock(timed, satisfied), + _runnerControls(timed, satisfied), + ], + ), + ); + } + + Widget _runnerHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: _session.running ? colorPositive : colorWarning, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + text( + _session.running ? "IN PROGRESS" : "PAUSED", + 9, + TextType.Bold, + color: colorWhite.withValues(alpha: 0.55), + letterSpacing: 1.4, + ), + ], + ), + iconButton( + Icon(CupertinoIcons.xmark, + size: 15, color: colorWhite.withValues(alpha: 0.7)), + _onRequestExit, + ), + ], + ), + const SizedBox(height: 28), + if (widget.goalTitle.isNotEmpty) ...[ + text(widget.goalTitle.toUpperCase(), 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.40), letterSpacing: 1.4), + const SizedBox(height: 10), + ], + text(widget.commitment.title, 34, TextType.Light, + color: colorWhite, height: 1.15), + const SizedBox(height: 16), + Row( + children: [ + pill( + classLabel(widget.commitment.commitmentClass), + colorWhite, + colorWhite.withValues(alpha: 0.12), + textSize: 9, + ), + const SizedBox(width: 8), + pill( + proofLabel(widget.commitment.proofType), + colorWhite.withValues(alpha: 0.75), + colorWhite.withValues(alpha: 0.08), + textSize: 9, + ), + ], + ), + ], + ); + } + + Widget _runnerClock(bool timed, bool satisfied) { + final int elapsed = _session.elapsedSeconds(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + text( + timed ? (satisfied ? "REQUIREMENT MET" : "REMAINING") : "ELAPSED", + 9, + TextType.Bold, + color: satisfied + ? colorPositive + : colorWhite.withValues(alpha: 0.40), + letterSpacing: 1.6, + ), + const SizedBox(height: 18), + text( + timed && !satisfied + ? formatClock(_session.remainingSeconds()) + : formatClock(elapsed), + 78, + TextType.Light, + color: colorWhite, + height: 1.0, + ), + if (timed) ...[ + const SizedBox(height: 28), + meter( + _session.progress(), + fill: satisfied ? colorPositive : colorWhite, + track: colorWhite.withValues(alpha: 0.12), + height: 5, + ), + const SizedBox(height: 14), + text( + satisfied + ? "You can finish this now." + : "Leaving the app pauses the clock.", + 12, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.45), + align: TextAlign.center, + ), + ], + if (_session.backgroundedCount > 0) ...[ + const SizedBox(height: 18), + pill( + "Left ${_session.backgroundedCount}×", + colorWarning, + colorWarning.withValues(alpha: 0.12), + textSize: 9, + ), + ], + ], + ); + } + + Widget _runnerControls(bool timed, bool satisfied) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + roundedCornerButton( + satisfied || !timed ? "Finish" : "Finish early", + _onFinish, + background: satisfied || !timed ? colorWhite : colorWhite.withValues(alpha: 0.14), + foreground: satisfied || !timed ? colorPrimaryDark : colorWhite, + icon: CupertinoIcons.checkmark, + ), + const SizedBox(height: 10), + outlinedActionButton( + _session.running ? "Pause" : "Resume", + _onTogglePause, + foreground: colorWhite, + icon: _session.running + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, + ), + const SizedBox(height: 6), + Center( + child: textButton( + "Abandon this", + _onAbandon, + textSize: 12, + color: colorWhite.withValues(alpha: 0.45), + ), + ), + ], + ); + } + + // ── ConnectLiveTask ─────────────────────────────────────────────────────── + + @override + void onCompleted() { + _model?.showApplicationNotification( + NotificationType.success, + widget.commitment.wouldBeLate ? "Late complete" : "Done", + widget.commitment.wouldBeLate + ? "Recorded as a late complete — the window had already closed." + : "${formatClock(_session.elapsedSeconds())} of focused work, recorded.", + true, + true, + () { + Navigator.pop(context, true); + }, + ); + } + + @override + void onTooEarly(int secondsRemaining) { + _model?.showApplicationNotification( + NotificationType.warning, + "Not yet", + "${formatClock(secondsRemaining)} still to go. The timer is the proof — finishing early would just be the checkbox again.", + true, + true, + null, + ); + } + + @override + void onAbandoned() { + Navigator.pop(context, true); + } + + @override + void dispose() { + _ticker?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _model?.clearSession(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/live/ViewLiveTask.dart b/frontend/lib/Grounded/see/live/ViewLiveTask.dart new file mode 100644 index 0000000..6c265f4 --- /dev/null +++ b/frontend/lib/Grounded/see/live/ViewLiveTask.dart @@ -0,0 +1,75 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/LiveSession.dart'; +import '../../about/external/initial/AbandonRequest.dart'; +import '../../about/external/initial/CompletionRequest.dart'; +import '../../configs/NotificationServiceConfig.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectLiveTask.dart'; + +class ViewLiveTask extends ParentViewModel { + ConnectLiveTask connection; + + ViewLiveTask(super.context, this.connection); + + /// Mirrors the live state into the ongoing notification, so the run is + /// visible and controllable from the lock screen. + void publishSession(LiveSession session) { + LocalNotificationEngine.showSessionNotification(session); + } + + void clearSession() { + LocalNotificationEngine.cancelSessionNotification(); + } + + /// Completion is refused until the foreground requirement is actually met. + /// The timer is the proof, so it cannot be talked past. + void complete(Commitment commitment, LiveSession session) async { + if (!session.satisfied()) { + connection.onTooEarly(session.remainingSeconds()); + return; + } + + if (!await hasNetwork(() => complete(commitment, session))) return; + + showLoading("Recording"); + + try { + await getDataManager().completeCommitmentEntry(CompletionRequest( + commitmentId: commitment.id ?? "", + proofType: commitment.proofType.name, + timerSeconds: session.elapsedSeconds(), + )); + + clearSession(); + + closeLoading(); + + connection.onCompleted(); + } catch (e) { + handleError(e, () => complete(commitment, session), () => dismissError(), + "Retry"); + } + } + + void abandon(Commitment commitment, String reason) async { + if (!await hasNetwork(() => abandon(commitment, reason))) return; + + showLoading("Recording"); + + try { + await getDataManager().abandonCommitmentEntry(AbandonRequest( + commitmentId: commitment.id ?? "", + reason: reason, + )); + + clearSession(); + + closeLoading(); + + connection.onAbandoned(); + } catch (e) { + handleError( + e, () => abandon(commitment, reason), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/login/ConnectLogin.dart b/frontend/lib/Grounded/see/login/ConnectLogin.dart new file mode 100644 index 0000000..3682691 --- /dev/null +++ b/frontend/lib/Grounded/see/login/ConnectLogin.dart @@ -0,0 +1,5 @@ +import '../../about/internal/application/UserDetails.dart'; + +abstract class ConnectLogin { + void onLoggedIn(UserDetails details); +} diff --git a/frontend/lib/Grounded/see/login/Login.dart b/frontend/lib/Grounded/see/login/Login.dart new file mode 100644 index 0000000..0947628 --- /dev/null +++ b/frontend/lib/Grounded/see/login/Login.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'LoginState.dart'; + +class Login extends StatefulWidget { + const Login({super.key}); + + @override + State createState() => LoginState(); +} diff --git a/frontend/lib/Grounded/see/login/LoginState.dart b/frontend/lib/Grounded/see/login/LoginState.dart new file mode 100644 index 0000000..edb6fdf --- /dev/null +++ b/frontend/lib/Grounded/see/login/LoginState.dart @@ -0,0 +1,187 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/initial/LoginData.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/Validators.dart'; +import '../home/Home.dart'; +import 'ConnectLogin.dart'; +import 'Login.dart'; +import 'ViewLogin.dart'; + +class LoginState extends State implements ConnectLogin { + ViewLogin? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _username = TextEditingController(); + + final TextEditingController _password = TextEditingController(); + + bool _obscured = true; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewLogin(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => Scaffold( + backgroundColor: colorPrimaryDark, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ), + ); + } + + void _initiate() {} + + void _onToggleObscured() { + setState(() { + _obscured = !_obscured; + }); + } + + void _onSignIn() { + if (_formKey.currentState?.validate() != true) { + return; + } + + _model?.login(LoginData( + username: _username.text.trim(), + password: _password.text, + )); + } + + Widget _mobileView(BoxConstraints constraints) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("GROUNDED", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 2.0), + const SizedBox(height: 22), + text("Welcome back.", 36, TextType.Light, + color: colorWhite, height: 1.1), + const SizedBox(height: 10), + text( + "Your record has been waiting exactly where you left it.", + 14, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.55), + height: 1.5, + ), + ], + ), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: colorPrimaryLight, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + inputField( + "Username", + _username, + hint: "The one you signed up with", + validator: Validators.username, + keyboard: TextInputType.emailAddress, + icon: CupertinoIcons.person, + ), + const SizedBox(height: 20), + inputField( + "Password", + _password, + hint: "Your password", + validator: Validators.password, + obscure: _obscured, + icon: CupertinoIcons.lock, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: textButton( + _obscured ? "Show password" : "Hide password", + _onToggleObscured, + textSize: 12, + ), + ), + const SizedBox(height: 20), + roundedCornerButton( + "Sign in", + _onSignIn, + icon: CupertinoIcons.arrow_right, + ), + const SizedBox(height: 24), + Center( + child: text( + "Nothing here judges you for being away.", + 12, + TextType.Regular, + color: colorGrey2, + align: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + @override + void onLoggedIn(UserDetails details) { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const Home(), context); + } + + @override + void dispose() { + _username.dispose(); + _password.dispose(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/login/ViewLogin.dart b/frontend/lib/Grounded/see/login/ViewLogin.dart new file mode 100644 index 0000000..f163deb --- /dev/null +++ b/frontend/lib/Grounded/see/login/ViewLogin.dart @@ -0,0 +1,39 @@ +import '../../about/external/initial/LoginData.dart'; +import '../../about/internal/application/MeDescription.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectLogin.dart'; + +class ViewLogin extends ParentViewModel { + ConnectLogin connection; + + ViewLogin(super.context, this.connection); + + void login(LoginData request) async { + if (!await hasNetwork(() => login(request))) return; + + showLoading("Signing in"); + + try { + final response = await getDataManager().login(request); + + final Map body = response.data; + + await getDataManager().setMyDescription(MeDescription( + id: body['id'] ?? "", + name: body['name'] ?? "", + token: body['token'] ?? "", + )); + + final UserDetails details = UserDetails.fromJson(body); + + await getDataManager().setUserDetails(details); + + closeLoading(); + + connection.onLoggedIn(details); + } catch (e) { + handleError(e, () => login(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/overdue/ConnectOverdueQueue.dart b/frontend/lib/Grounded/see/overdue/ConnectOverdueQueue.dart new file mode 100644 index 0000000..86e194f --- /dev/null +++ b/frontend/lib/Grounded/see/overdue/ConnectOverdueQueue.dart @@ -0,0 +1,16 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/internal/application/Standing.dart'; + +abstract class ConnectOverdueQueue { + void onQueueLoaded(List queue, Standing standing, double debt); + + /// The item settled, with the debt it removed from the score. + void onItemCleared(Commitment item, double reliefApplied, String verb); + + /// Deferral refused — cap spent, or the class does not permit it. + void onDeferralRefused(String reason); + + void onAmnestySpent(int remaining); + + void onAmnestyRefused(); +} diff --git a/frontend/lib/Grounded/see/overdue/OverdueQueue.dart b/frontend/lib/Grounded/see/overdue/OverdueQueue.dart new file mode 100644 index 0000000..51e2c99 --- /dev/null +++ b/frontend/lib/Grounded/see/overdue/OverdueQueue.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'OverdueQueueState.dart'; + +class OverdueQueue extends StatefulWidget { + const OverdueQueue({super.key}); + + @override + State createState() => OverdueQueueState(); +} diff --git a/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart b/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart new file mode 100644 index 0000000..b1a228b --- /dev/null +++ b/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart @@ -0,0 +1,691 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Commitment.dart'; +import '../../about/external/initial/CompletionRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ProofType.dart'; +import '../../about/internal/application/Standing.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/DebtEngine.dart'; +import '../../utils/Thresholds.dart'; +import '../../utils/Validators.dart'; +import 'ConnectOverdueQueue.dart'; +import 'OverdueQueue.dart'; +import 'ViewOverdueQueue.dart'; + +class OverdueQueueState extends State + implements ConnectOverdueQueue { + ViewOverdueQueue? _model; + + List _queue = []; + + Standing _standing = Standing.Good; + + double _debt = 0; + + bool _changed = false; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewOverdueQueue(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadQueue(); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, _changed); + } + + void _onComplete(Commitment item) { + // Honour proof settles immediately; everything else has to produce its + // artefact before the completion is accepted. + if (item.proofType == ProofType.Honour) { + _model?.complete( + item, + CompletionRequest( + commitmentId: item.id ?? "", + proofType: item.proofType.name, + ), + ); + return; + } + + _openProofSheet(item); + } + + void _onDefer(Commitment item) { + _openDeferralSheet(item); + } + + void _onAbandon(Commitment item) { + _openAbandonSheet(item); + } + + void _onAmnesty(Commitment item) { + _model?.spendAmnesty(item); + } + + // ── Sheets ──────────────────────────────────────────────────────────────── + + /// The deferral sheet. The excuse field is the whole point of the screen — + /// free text, minimum length, no template buttons to route around it. + void _openDeferralSheet(Commitment item) { + final TextEditingController excuse = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return StatefulBuilder( + builder: (BuildContext sheetContext, StateSetter setSheetState) { + final int remaining = + Thresholds.maxDeferralsPerTask - item.deferralCount; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("DEFERRING", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + text(item.title, 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 14), + text( + remaining <= 1 + ? "This is the last deferral this task gets. After it, the only options are completing or abandoning." + : "$remaining deferrals left on this task.", + 13, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 24), + excuseField( + excuse, + Thresholds.minExcuseLength, + validator: Validators.excuse, + onChanged: (value) => setSheetState(() {}), + ), + const SizedBox(height: 24), + roundedCornerButton( + "Defer with this reason", + () { + if (formKey.currentState?.validate() != true) { + return; + } + Navigator.pop(sheetContext); + _model?.defer(item, excuse.text); + }, + icon: CupertinoIcons.clock, + ), + const SizedBox(height: 8), + Center( + child: textButton( + "Cancel", + () => Navigator.pop(sheetContext), + textSize: 13, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + } + + void _openAbandonSheet(Commitment item) { + final TextEditingController reason = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("ABANDONING", 9, TextType.Bold, + color: colorStandingLockdown, letterSpacing: 1.2), + const SizedBox(height: 10), + text(item.title, 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 14), + card( + background: colorStandingLockdownBg, + borderColor: + colorStandingLockdown.withValues(alpha: 0.20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("THE COST", 9, TextType.Bold, + color: colorStandingLockdown, letterSpacing: 1.2), + const SizedBox(height: 8), + text( + "Abandoning is the most expensive outcome there is. It costs double weight and will not decay for 30 days.", + 13, + TextType.Regular, + color: colorPrimaryDark, + height: 1.5, + ), + ], + ), + ), + const SizedBox(height: 20), + inputField( + "Reason", + reason, + hint: "Why is this never happening?", + validator: Validators.excuse, + maxLines: 3, + ), + const SizedBox(height: 24), + destructiveButton( + "Abandon permanently", + () { + if (formKey.currentState?.validate() != true) { + return; + } + Navigator.pop(sheetContext); + _model?.abandon(item, reason.text); + }, + icon: CupertinoIcons.xmark_circle, + ), + const SizedBox(height: 8), + Center( + child: textButton( + "Keep it", + () => Navigator.pop(sheetContext), + textSize: 13, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + /// Proof types other than Honour need their artefact. The sheet states what + /// is required rather than letting the user tap a checkbox and move on. + void _openProofSheet(Commitment item) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("PROOF REQUIRED", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + text(proofLabel(item.proofType), 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 14), + text( + _proofDescription(item), + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 24), + roundedCornerButton( + _proofAction(item.proofType), + () { + Navigator.pop(sheetContext); + _model?.complete( + item, + CompletionRequest( + commitmentId: item.id ?? "", + proofType: item.proofType.name, + ), + ); + }, + icon: _proofIcon(item.proofType), + ), + const SizedBox(height: 8), + Center( + child: textButton( + "Not now", + () => Navigator.pop(sheetContext), + textSize: 13, + ), + ), + ], + ), + ), + ); + }, + ); + } + + String _proofDescription(Commitment item) { + switch (item.proofType) { + case ProofType.Honour: + return "Your word is enough for this one."; + case ProofType.Photo: + return "Camera only — no gallery imports. The timestamp is embedded and near-duplicate submissions are flagged."; + case ProofType.Timer: + return "A foreground session of at least ${item.proofTimerMinutes} minutes. Leaving the app pauses the clock."; + case ProofType.Location: + return "You need to have actually been there. Dwell time inside the geofence counts, passing by does not."; + case ProofType.Health: + return "Your health platform has to confirm a workout inside the window."; + case ProofType.Witness: + return "Your accountability partner confirms this one."; + } + } + + String _proofAction(ProofType type) { + switch (type) { + case ProofType.Honour: + return "Mark complete"; + case ProofType.Photo: + return "Open camera"; + case ProofType.Timer: + return "Start the timer"; + case ProofType.Location: + return "Check my location"; + case ProofType.Health: + return "Check health data"; + case ProofType.Witness: + return "Request confirmation"; + } + } + + IconData _proofIcon(ProofType type) { + switch (type) { + case ProofType.Honour: + return CupertinoIcons.checkmark; + case ProofType.Photo: + return CupertinoIcons.camera_fill; + case ProofType.Timer: + return CupertinoIcons.timer; + case ProofType.Location: + return CupertinoIcons.location_fill; + case ProofType.Health: + return CupertinoIcons.heart_fill; + case ProofType.Witness: + return CupertinoIcons.person_2_fill; + } + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: "Outstanding", + title: "What you owe", + chrome: _queue.isEmpty ? colorPrimaryDark : standingColor(_standing), + onBack: _onBack, + banner: _debtBanner(), + child: _queue.isEmpty + ? emptyState( + CupertinoIcons.checkmark_seal, + "Nothing outstanding", + "The queue is empty. Your standing recovers as the remaining debt decays.", + accent: colorPositive, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + sectionBreak( + "The queue", + caption: "oldest first", + trailing: pill( + "${_queue.length} / ${Thresholds.maxOpenOverdue}", + _queue.length >= Thresholds.maxOpenOverdue + ? colorStandingGrounded + : colorGrey2, + _queue.length >= Thresholds.maxOpenOverdue + ? colorStandingGroundedBg + : colorMuted, + textSize: 9, + ), + ), + ..._queue.map(_queueRow), + ], + ), + ); + } + + Widget _debtBanner() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("TOTAL DEBT", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.55), + letterSpacing: 1.2), + const SizedBox(height: 6), + text(formatDebt(_debt), 32, TextType.Light, color: colorWhite), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + text("STANDING", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.55), + letterSpacing: 1.2), + const SizedBox(height: 6), + text(standingLabel(_standing), 15, TextType.Medium, + color: colorWhite), + ], + ), + ], + ), + ); + } + + Widget _queueRow(Commitment item) { + final Color accent = classColor(item.commitmentClass); + + final bool deferrable = + item.deferrableUnder(Thresholds.maxDeferralsPerTask); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(item.title, 17, TextType.Medium, + color: colorPrimaryDark, maxLines: 2, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 8), + text(formatWindow(item), 11, TextType.Regular, + color: colorGrey2), + ], + ), + ), + const SizedBox(width: 10), + pill( + classLabel(item.commitmentClass), + accent, + classBackground(item.commitmentClass), + textSize: 9, + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: labelled( + "Overdue", + overdueLabel(item), + valueSize: 13, + valueColor: colorStandingGrounded, + ), + ), + Expanded( + child: labelled( + "Costing", + formatDebt(DebtEngine.commitmentDebt(item)), + valueSize: 13, + ), + ), + Expanded( + child: labelled( + "Deferred", + "${item.deferralCount}/${Thresholds.maxDeferralsPerTask}", + valueSize: 13, + valueColor: + deferrable ? colorPrimaryDark : colorStandingGrounded, + ), + ), + ], + ), + hairline(margin: const EdgeInsets.symmetric(vertical: 16)), + Row( + children: [ + Expanded( + child: roundedCornerButton( + "Complete", + () => _onComplete(item), + icon: CupertinoIcons.checkmark, + verticalPadding: 13, + ), + ), + const SizedBox(width: 8), + Expanded( + child: outlinedActionButton( + deferrable ? "Defer" : "No deferrals", + () => _onDefer(item), + enabled: deferrable && + item.commitmentClass != CommitmentClass.NonNegotiable, + icon: CupertinoIcons.clock, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + textButton("Amnesty", () => _onAmnesty(item), + textSize: 12, color: colorGrey2), + textButton("Abandon", () => _onAbandon(item), + textSize: 12, color: colorStandingLockdown), + ], + ), + ], + ), + ), + ); + } + + // ── ConnectOverdueQueue ─────────────────────────────────────────────────── + + @override + void onQueueLoaded(List queue, Standing standing, double debt) { + setState(() { + _queue = queue; + _standing = standing; + _debt = debt; + }); + } + + @override + void onItemCleared(Commitment item, double reliefApplied, String verb) { + _changed = true; + + _model?.showApplicationNotification( + verb == "Abandoned" + ? NotificationType.warning + : NotificationType.success, + verb, + verb == "Late complete" + ? "Recorded as a late complete. It reduces the debt but does not clear it — the miss stays in your history." + : reliefApplied > 0 + ? "${formatDebt(reliefApplied)} came off your debt." + : "Recorded.", + true, + true, + null, + ); + } + + @override + void onDeferralRefused(String reason) { + _model?.showApplicationNotification( + NotificationType.warning, + "Not deferrable", + reason, + true, + true, + null, + ); + } + + @override + void onAmnestySpent(int remaining) { + _changed = true; + + _model?.showApplicationNotification( + NotificationType.success, + "Amnesty applied", + "No questions asked. $remaining token${remaining == 1 ? "" : "s"} left this month.", + true, + true, + null, + ); + } + + @override + void onAmnestyRefused() { + _model?.showApplicationNotification( + NotificationType.info, + "No tokens left", + "You have spent this month's amnesty. They reset at the start of next month.", + true, + true, + null, + ); + } +} diff --git a/frontend/lib/Grounded/see/overdue/ViewOverdueQueue.dart b/frontend/lib/Grounded/see/overdue/ViewOverdueQueue.dart new file mode 100644 index 0000000..2a9d949 --- /dev/null +++ b/frontend/lib/Grounded/see/overdue/ViewOverdueQueue.dart @@ -0,0 +1,190 @@ +import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../about/external/data/pages/response/CommitmentPage.dart'; +import '../../about/external/initial/AbandonRequest.dart'; +import '../../about/external/initial/AmnestyRequest.dart'; +import '../../about/external/initial/CompletionRequest.dart'; +import '../../about/external/initial/DeferralRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/Standing.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../utils/DebtEngine.dart'; +import '../../utils/GuardrailEngine.dart'; +import '../../utils/StandingEngine.dart'; +import '../../utils/Thresholds.dart'; +import '../../utils/ToneEngine.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectOverdueQueue.dart'; + +class ViewOverdueQueue extends ParentViewModel { + ConnectOverdueQueue connection; + + ViewOverdueQueue(super.context, this.connection); + + void loadQueue() async { + if (!await hasNetwork(() => loadQueue())) return; + + showLoading("Loading what you owe"); + + try { + final response = await getDataManager().getOverdueQueue(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'dueEnd'), + page: Pageable(0, 0, 50, 0), + ), + )); + + final CommitmentPage page = CommitmentPage.fromJson(response.data); + + final UserDetails details = await getDataManager().getUserDetails(); + + final double debt = DebtEngine.totalDebt(page.content); + + final Standing standing = StandingEngine.evaluate( + debt, + missedNonNegotiables: DebtEngine.missedNonNegotiables(page.content), + sickMode: details.sickMode, + ); + + await getDataManager().setCachedDebtScore(debt); + + closeLoading(); + + connection.onQueueLoaded(page.content, standing, debt); + } catch (e) { + handleError(e, () => loadQueue(), () => dismissError(), "Retry"); + } + } + + /// Completion outside the due window is recorded as a late complete, not a + /// complete. The server decides which — the client never claims one. + void complete(Commitment item, CompletionRequest request) async { + if (!await hasNetwork(() => complete(item, request))) return; + + showLoading("Recording"); + + try { + await getDataManager().completeCommitmentEntry(request); + + closeLoading(); + + connection.onItemCleared( + item, + DebtEngine.reliefFromClearing(item), + item.wouldBeLate ? "Late complete" : "Complete", + ); + + loadQueue(); + } catch (e) { + handleError( + e, () => complete(item, request), () => dismissError(), "Retry"); + } + } + + /// The deferral gate. Non-negotiables never defer; everything else runs out + /// of deferrals, after which the only routes left are completing or + /// abandoning. + void defer(Commitment item, String excuse) async { + if (item.commitmentClass == CommitmentClass.NonNegotiable) { + connection.onDeferralRefused(ToneEngine.nonNegotiableRefused()); + return; + } + + if (!item.deferrableUnder(Thresholds.maxDeferralsPerTask)) { + connection.onDeferralRefused( + ToneEngine.deferralRefused(Thresholds.maxDeferralsPerTask)); + return; + } + + if (excuse.trim().length < Thresholds.minExcuseLength) { + connection + .onDeferralRefused(ToneEngine.excuseTooShort(Thresholds.minExcuseLength)); + return; + } + + if (!await hasNetwork(() => defer(item, excuse))) return; + + showLoading("Recording the deferral"); + + try { + // The new window opens tomorrow at the same time — a deferral moves the + // window, it never removes it. + final DateTime start = + (item.dueStart ?? DateTime.now()).add(const Duration(days: 1)); + final DateTime end = + (item.dueEnd ?? DateTime.now()).add(const Duration(days: 1)); + + await getDataManager().deferCommitmentEntry(DeferralRequest( + commitmentId: item.id ?? "", + excuseText: excuse.trim(), + newDueStart: start.toIso8601String(), + newDueEnd: end.toIso8601String(), + )); + + closeLoading(); + + connection.onItemCleared(item, 0, "Deferred"); + + loadQueue(); + } catch (e) { + handleError(e, () => defer(item, excuse), () => dismissError(), "Retry"); + } + } + + /// Abandoning costs the most debt of all, and resists decay for a month. + void abandon(Commitment item, String reason) async { + if (!await hasNetwork(() => abandon(item, reason))) return; + + showLoading("Recording"); + + try { + await getDataManager().abandonCommitmentEntry(AbandonRequest( + commitmentId: item.id ?? "", + reason: reason.trim(), + )); + + closeLoading(); + + connection.onItemCleared(item, 0, "Abandoned"); + + loadQueue(); + } catch (e) { + handleError( + e, () => abandon(item, reason), () => dismissError(), "Retry"); + } + } + + /// Rationed, so a bad flu does not destroy three months of progress. + void spendAmnesty(Commitment item) async { + final int spent = await getDataManager().getAmnestySpent(); + + if (!GuardrailEngine.canSpendAmnesty( + Thresholds.amnestyTokensPerMonth, spent)) { + connection.onAmnestyRefused(); + return; + } + + if (!await hasNetwork(() => spendAmnesty(item))) return; + + showLoading("Applying amnesty"); + + try { + await getDataManager() + .spendAmnestyToken(AmnestyRequest(commitmentId: item.id ?? "")); + + await getDataManager().setAmnestySpent(spent + 1); + + closeLoading(); + + connection.onAmnestySpent(GuardrailEngine.tokensRemaining( + Thresholds.amnestyTokensPerMonth, spent + 1)); + + loadQueue(); + } catch (e) { + handleError(e, () => spendAmnesty(item), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/parent/ParentViewModel.dart b/frontend/lib/Grounded/see/parent/ParentViewModel.dart new file mode 100644 index 0000000..93288b5 --- /dev/null +++ b/frontend/lib/Grounded/see/parent/ParentViewModel.dart @@ -0,0 +1,652 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../about/external/data/GroundedError.dart'; +import '../../about/external/data/Severity.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../about/internal/file/FileStorage.dart'; +import '../../comms/Comms.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/text/Text.dart'; +import '../../informatics/AppDataManager.dart'; +import '../../informatics/DataManager.dart'; +import '../../memory/InternalMemory.dart'; +import '../../utils/Colors.dart'; +import '../system/sessionexpired/SessionExpired.dart'; +import '../system/updateme/UpdateMe.dart'; + +/// Everything cross-cutting lives here: the single data gateway, the loading, +/// network and error overlays, and the error decision tree that lets the +/// backend steer the client. +class ParentViewModel extends ChangeNotifier { + OverlayEntry? loadingEntry; + + OverlayEntry? networkEntry; + + OverlayEntry? errorEntry; + + late DataManager dataManager; + + BuildContext context; + + OverlayState? overlayState; + + ParentViewModel(this.context) { + overlayState = Overlay.of(context); + dataManager = + AppDataManager(InternalMemory(), Comms(InternalMemory()), FileStorage()); + } + + DataManager getDataManager() { + return dataManager; + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + void showLoading(String loadingText) async { + if (loadingEntry == null) { + _hideKeyboard(); + loadingEntry = OverlayEntry(builder: (context) { + return Scaffold( + backgroundColor: colorPrimaryDark, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Align( + alignment: Alignment.centerLeft, + child: text( + "GROUNDED", + 10, + TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 2.0, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 46, + height: 46, + child: CircularProgressIndicator( + strokeWidth: 2.5, + backgroundColor: colorWhite.withValues(alpha: 0.12), + color: colorWhite, + ), + ), + const SizedBox(height: 32), + text( + loadingText, + 30, + TextType.Light, + color: colorWhite, + align: TextAlign.center, + height: 1.15, + ), + ], + ), + text( + "Keep your connection active", + 12, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.45), + ), + ], + ), + ), + ), + ); + }); + + overlayState?.insert(loadingEntry!); + notifyListeners(); + } + } + + closeLoading() { + if (loadingEntry != null) { + loadingEntry?.remove(); + loadingEntry = null; + notifyListeners(); + } + } + + // ── Network ─────────────────────────────────────────────────────────────── + + void noNetwork(Function() actions) async { + if (networkEntry == null) { + _hideKeyboard(); + networkEntry = OverlayEntry(builder: (context) { + return Scaffold( + backgroundColor: colorPrimaryLight, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colorPrimaryDark, + borderRadius: BorderRadius.circular(18), + ), + child: Icon(CupertinoIcons.wifi_slash, + size: 26, color: colorWhite), + ), + const SizedBox(height: 28), + text("CONNECTION", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + text("You are offline.", 34, TextType.Light, + color: colorPrimaryDark, height: 1.15), + const SizedBox(height: 12), + text( + "Reconnect and we will pick up where you left off. Nothing has been lost.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: roundedCornerButton( + "Try again", + actions, + icon: CupertinoIcons.refresh, + ), + ), + ], + ), + ), + ), + ), + ); + }); + + overlayState?.insert(networkEntry!); + notifyListeners(); + } + } + + closeNetwork() { + if (networkEntry != null) { + networkEntry?.remove(); + networkEntry = null; + notifyListeners(); + } + } + + /// Guard every network call with this. Returns false and raises the offline + /// overlay wired to [actions] when there is nothing to talk to. + Future hasNetwork(Function() actions) async { + closeLoading(); + + List resultList = + await (Connectivity().checkConnectivity()); + + ConnectivityResult connectivityResult = ConnectivityResult.none; + + if (resultList.isNotEmpty) { + connectivityResult = resultList.first; + } + + if (connectivityResult == ConnectivityResult.mobile || + connectivityResult == ConnectivityResult.wifi || + connectivityResult == ConnectivityResult.ethernet) { + closeNetwork(); + return true; + } + + noNetwork(actions); + return false; + } + + // ── Errors ──────────────────────────────────────────────────────────────── + + /// The standard catch handler. Session expiry and the server-directed + /// redirects live here, so the backend can steer the client from any screen. + handleError(Object? error, Function() actions, Function() closeAction, + String buttonName) { + closeLoading(); + + if (error is DioException) { + DioException dioError = error; + + if (error.type == DioExceptionType.connectionTimeout) { + showError( + actions, + closeAction, + GroundedError( + code: 5000.01, + message: "An error occurred while processing your request.", + helper: + "Kindly ensure that you have a stable internet connection.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } else if (error.type == DioExceptionType.receiveTimeout) { + showError( + actions, + closeAction, + GroundedError( + code: 5000.02, + message: "An error occurred while processing your request.", + helper: + "Kindly ensure that you have a stable internet connection.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } else if (dioError.response?.statusCode == 401) { + sessionExpired(); + } else if (dioError.response?.statusCode == 403) { + showError( + actions, + closeAction, + GroundedError( + code: 5100.00, + message: + "A connection error occurred while processing your request. Usually a result of your network security blocking the request.", + helper: + "Try using your mobile data or switching to a different network.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } else if (dioError.response?.statusCode == 413) { + showError(actions, closeAction, + getGroundedError(dioError.response?.data), buttonName); + } else if (_isBusinessStatus(dioError.response?.statusCode)) { + _handleBusinessError(dioError, actions, closeAction, buttonName); + } else { + showError( + actions, + closeAction, + GroundedError( + code: 5500.02, + message: "An error occurred while processing your request.", + helper: + "Kindly relaunch the application and try again. If the problem persists, contact us.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } + } else if (error is Exception) { + showError( + actions, + closeAction, + GroundedError( + code: 6000.01, + message: "An error occurred while processing your request.", + helper: + "Kindly relaunch the application and try again. If the problem persists, contact us.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } else if (error is int) { + showError( + actions, + closeAction, + GroundedError( + code: 6000.02, + message: "An error occurred while processing your request.", + helper: + "Kindly relaunch or reinstall the application and retry below.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } else { + showError( + actions, + closeAction, + GroundedError( + code: 8700.02, + message: "An error occurred while processing your request.", + helper: + "Kindly relaunch the application and try again. If the problem persists, contact us.", + title: "Grounded Error", + severity: Severity.message.name), + buttonName); + } + } + + /// Statuses that carry an actionable error body the user should see. 500 is + /// included for services not yet migrated to the per-status scheme. + bool _isBusinessStatus(int? status) { + return status == 400 || + status == 404 || + status == 409 || + status == 422 || + status == 429 || + status == 500; + } + + /// Decodes the body and routes the app-flow control codes before falling + /// back to showing the error. + void _handleBusinessError(DioException dioError, Function() actions, + Function() closeAction, String buttonName) { + GroundedError error = getGroundedError(dioError.response?.data); + + if (error.code == 5000.901) { + updateMe(); + } else { + showError(actions, closeAction, error, buttonName); + } + } + + void showError(Function() actions, Function() closeActions, + GroundedError error, String buttonText) async { + if (errorEntry == null) { + _hideKeyboard(); + errorEntry = OverlayEntry(builder: (context) { + return Scaffold( + backgroundColor: colorPrimaryLight, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 48, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("SYSTEM NOTICE", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 12), + text(error.title, 34, TextType.Light, + color: colorPrimaryDark, height: 1.15), + const SizedBox(height: 24), + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorStandingGroundedBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + CupertinoIcons.exclamationmark_triangle_fill, + size: 20, + color: colorStandingGrounded, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("CODE", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 4), + text(error.code.toString(), 17, + TextType.Medium, + color: colorPrimaryDark), + ], + ), + ), + ], + ), + hairline( + margin: const EdgeInsets.symmetric(vertical: 20)), + text("WHAT HAPPENED", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 6), + text(error.message, 14, TextType.Regular, + color: colorPrimaryDark, height: 1.5), + const SizedBox(height: 20), + text("WHAT TO DO", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 6), + text(error.helper, 14, TextType.Regular, + color: colorPrimaryDark, height: 1.5), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + child: roundedCornerButton( + buttonText, + actions, + icon: CupertinoIcons.arrow_clockwise, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: outlinedActionButton("Close", () { + dismissError(); + closeActions(); + }), + ), + ], + ), + ), + ), + ); + }); + + overlayState?.insert(errorEntry!); + notifyListeners(); + } + } + + dismissError() { + if (errorEntry != null) { + errorEntry?.remove(); + errorEntry = null; + notifyListeners(); + } + } + + GroundedError getGroundedError(data) { + GroundedError error; + try { + error = GroundedError.fromJson(data); + } catch (e) { + error = GroundedError( + code: 900, + message: "An error occurred while processing your request.", + helper: "Kindly ensure that your internet connection is working.", + title: "Grounded Error", + severity: Severity.message.name); + } + + return error; + } + + // ── In-app notification ─────────────────────────────────────────────────── + + showApplicationNotification( + NotificationType type, + String title, + String description, + bool enableDrag, + bool barrierDismiss, + VoidCallback? closeAction, { + String? action, + VoidCallback? positiveAction, + }) async { + Color actionColor; + Color actionBgColor; + IconData actionIconData; + + switch (type) { + case NotificationType.success: + actionIconData = Icons.check_circle_rounded; + actionColor = colorPositive; + actionBgColor = colorStandingGoodBg; + break; + case NotificationType.info: + actionIconData = Icons.info_rounded; + actionColor = colorPrimaryDark; + actionBgColor = colorMuted; + break; + case NotificationType.warning: + actionIconData = Icons.warning_amber_rounded; + actionColor = colorStandingWarned; + actionBgColor = colorStandingWarnedBg; + break; + case NotificationType.error: + actionIconData = Icons.error_rounded; + actionColor = colorStandingGrounded; + actionBgColor = colorStandingGroundedBg; + break; + } + + final bool hasAction = action != null && positiveAction != null; + + await showModalBottomSheet( + context: context, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + backgroundColor: Colors.transparent, + elevation: 0, + enableDrag: enableDrag, + isDismissible: barrierDismiss, + isScrollControlled: true, + builder: (BuildContext context) { + return Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 52, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: actionBgColor, + borderRadius: BorderRadius.circular(15), + ), + child: Icon(actionIconData, + size: 24, color: actionColor), + ), + iconButton( + Icon(Icons.close_rounded, + size: 17, color: colorGrey2), + () { + Navigator.pop(context); + closeAction?.call(); + }, + bordered: true, + ), + ], + ), + const SizedBox(height: 20), + text(type.name.toUpperCase(), 9, TextType.Bold, + color: actionColor, letterSpacing: 1.2), + const SizedBox(height: 10), + text(title, 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 12), + text(description, 14, TextType.Regular, + color: colorGrey2, height: 1.55), + const SizedBox(height: 28), + if (hasAction) ...[ + SizedBox( + width: double.infinity, + child: roundedCornerButton( + action, + positiveAction, + background: actionColor, + icon: actionIconData, + ), + ), + const SizedBox(height: 10), + ], + Center( + child: textButton( + hasAction ? "Cancel" : "Dismiss", + () { + Navigator.pop(context); + closeAction?.call(); + }, + color: colorGrey2, + textSize: 13, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + // ── Server-directed navigation ──────────────────────────────────────────── + + void sessionExpired() { + GroundedNavigation().navigateToPage( + NavigatorType.makeNewMain, const SessionExpired(), context); + } + + void updateMe() { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const UpdateMe(), context); + } + + _hideKeyboard() { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + + @override + void dispose() { + closeLoading(); + closeNetwork(); + dismissError(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/reportcard/ConnectReportCardScreen.dart b/frontend/lib/Grounded/see/reportcard/ConnectReportCardScreen.dart new file mode 100644 index 0000000..b1aa21b --- /dev/null +++ b/frontend/lib/Grounded/see/reportcard/ConnectReportCardScreen.dart @@ -0,0 +1,5 @@ +import '../../about/external/data/ReportCard.dart'; + +abstract class ConnectReportCardScreen { + void onReportLoaded(ReportCard report); +} diff --git a/frontend/lib/Grounded/see/reportcard/ReportCardScreen.dart b/frontend/lib/Grounded/see/reportcard/ReportCardScreen.dart new file mode 100644 index 0000000..f14375e --- /dev/null +++ b/frontend/lib/Grounded/see/reportcard/ReportCardScreen.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'ReportCardScreenState.dart'; + +class ReportCardScreen extends StatefulWidget { + const ReportCardScreen({super.key}); + + @override + State createState() => ReportCardScreenState(); +} diff --git a/frontend/lib/Grounded/see/reportcard/ReportCardScreenState.dart b/frontend/lib/Grounded/see/reportcard/ReportCardScreenState.dart new file mode 100644 index 0000000..0b82b0b --- /dev/null +++ b/frontend/lib/Grounded/see/reportcard/ReportCardScreenState.dart @@ -0,0 +1,374 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/ReportCard.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectReportCardScreen.dart'; +import 'ReportCardScreen.dart'; +import 'ViewReportCardScreen.dart'; + +class ReportCardScreenState extends State + implements ConnectReportCardScreen { + ViewReportCardScreen? _model; + + ReportCard _report = ReportCard(); + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewReportCardScreen(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadReport(); + } + + void _onBack() { + Navigator.pop(context); + } + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: "This week", + title: "Report card", + onBack: _onBack, + banner: _gradeBanner(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_report.praise.isNotEmpty) ...[ + _praiseCard(), + const SizedBox(height: 24), + ], + _assignedAction(), + const SizedBox(height: 28), + sectionBreak("Debt", caption: "across the week"), + _debtTrend(), + const SizedBox(height: 28), + sectionBreak("Completion", caption: "by class"), + ..._report.completionByClass.entries.map(_completionRow), + const SizedBox(height: 20), + _worstHour(), + const SizedBox(height: 28), + if (_report.deferralLeaderboard.isNotEmpty) ...[ + sectionBreak("Most dodged", caption: "the leaderboard"), + ..._report.deferralLeaderboard.take(5).map(_deferralRow), + const SizedBox(height: 28), + ], + if (_report.estimationAccuracy.isNotEmpty) ...[ + sectionBreak("Your estimates", caption: "against reality"), + ..._report.estimationAccuracy.entries.map(_estimationRow), + const SizedBox(height: 28), + ], + if (_report.trainingAdherence > 0) ...[ + sectionBreak("Training"), + _trainingCard(), + ], + ], + ), + ); + } + + /// The grade sits in the chrome. Cosmetic, but it is the thing people + /// actually react to. + Widget _gradeBanner() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("PERIOD", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2), + const SizedBox(height: 6), + text( + "${formatDate(_report.periodStart)} — ${formatDate(_report.periodEnd)}", + 13, + TextType.Medium, + color: colorWhite, + ), + ], + ), + ), + if (_report.grade.isNotEmpty) + Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite, + borderRadius: BorderRadius.circular(16), + ), + child: text(_report.grade, 26, TextType.Light, + color: colorPrimaryDark), + ), + ], + ), + ); + } + + /// Rationed but real. Only rendered when something specific was earned. + Widget _praiseCard() { + return card( + background: colorStandingGoodBg, + borderColor: colorPositive.withValues(alpha: 0.20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(CupertinoIcons.checkmark_seal_fill, + size: 18, color: colorPositive), + const SizedBox(width: 12), + Expanded( + child: text(_report.praise, 14, TextType.Regular, + color: colorPrimaryDark, height: 1.55), + ), + ], + ), + ); + } + + /// One assigned action for next week. Not five. + Widget _assignedAction() { + return card( + background: colorPrimaryDark, + borderColor: colorPrimaryDark, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("NEXT WEEK, ONE THING", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), + const SizedBox(height: 12), + text( + _report.assignedAction.isEmpty + ? "Not enough history yet to assign anything." + : _report.assignedAction, + 19, + TextType.Light, + color: colorWhite, + height: 1.4, + ), + ], + ), + ); + } + + Widget _debtTrend() { + if (_report.debtTrend.isEmpty) { + return card( + child: text("No debt recorded this week.", 13, TextType.Regular, + color: colorGrey2), + ); + } + + final List spots = []; + for (int index = 0; index < _report.debtTrend.length; index++) { + spots.add(FlSpot(index.toDouble(), _report.debtTrend[index])); + } + + return card( + padding: const EdgeInsets.fromLTRB(8, 20, 16, 8), + child: SizedBox( + height: 150, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => + const FlLine(color: colorChartGrid, strokeWidth: 1), + ), + titlesData: FlTitlesData( + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 32)), + bottomTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + barWidth: 2.5, + color: colorDebtLine, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: true, color: colorDebtFill), + ), + ], + ), + ), + ), + ); + } + + Widget _completionRow(MapEntry entry) { + return Container( + margin: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text(entry.key, 13, TextType.Medium, color: colorPrimaryDark), + text("${(entry.value * 100).round()}%", 13, TextType.Bold, + color: colorGrey2), + ], + ), + const SizedBox(height: 8), + meter( + entry.value, + fill: entry.value >= 0.8 + ? colorPositive + : entry.value >= 0.5 + ? colorStandingWarned + : colorStandingGrounded, + ), + ], + ), + ); + } + + /// The recurring window where things go to die. + Widget _worstHour() { + if (_report.worstHour < 0) { + return const SizedBox.shrink(); + } + + return card( + background: colorStandingWarnedBg, + borderColor: colorStandingWarned.withValues(alpha: 0.20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("YOUR WORST HOUR", 9, TextType.Bold, + color: colorStandingWarned, letterSpacing: 1.2), + const SizedBox(height: 10), + text(hourLabel(_report.worstHour), 30, TextType.Light, + color: colorPrimaryDark), + const SizedBox(height: 8), + text( + "This is where things go to die. Stop scheduling anything that matters into it.", + 13, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + ], + ), + ); + } + + Widget _deferralRow(DeferralCount item) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Expanded( + child: text(item.title, 13, TextType.Regular, + color: colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + pill("${item.count}×", colorStandingGrounded, + colorStandingGroundedBg, textSize: 9), + ], + ), + ), + ); + } + + /// Your estimates are wrong, and this is by how much. + Widget _estimationRow(MapEntry entry) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Expanded( + child: text(entry.key, 13, TextType.Regular, + color: colorPrimaryDark), + ), + text("${entry.value.toStringAsFixed(1)}×", 15, TextType.Bold, + color: entry.value > 1.4 ? colorStandingGrounded : colorGrey2), + ], + ), + ), + ); + } + + Widget _trainingCard() { + return card( + child: Row( + children: [ + Expanded( + child: labelled( + "Adherence", + "${(_report.trainingAdherence * 100).round()}%", + valueSize: 26, + valueType: TextType.Light, + ), + ), + Expanded( + child: labelled( + "Integrity", + "${(_report.programIntegrity * 100).round()}%", + valueSize: 26, + valueType: TextType.Light, + valueColor: _report.programIntegrity < 0.7 + ? colorStandingGrounded + : colorPrimaryDark, + ), + ), + ], + ), + ); + } + + @override + void onReportLoaded(ReportCard report) { + setState(() { + _report = report; + }); + } +} diff --git a/frontend/lib/Grounded/see/reportcard/ViewReportCardScreen.dart b/frontend/lib/Grounded/see/reportcard/ViewReportCardScreen.dart new file mode 100644 index 0000000..f83753e --- /dev/null +++ b/frontend/lib/Grounded/see/reportcard/ViewReportCardScreen.dart @@ -0,0 +1,37 @@ +import '../../about/external/data/ReportCard.dart'; +import '../../about/external/initial/ReportCardRequest.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectReportCardScreen.dart'; + +class ViewReportCardScreen extends ParentViewModel { + ConnectReportCardScreen connection; + + ViewReportCardScreen(super.context, this.connection); + + void loadReport() async { + if (!await hasNetwork(() => loadReport())) return; + + showLoading("Preparing your report"); + + try { + final DateTime now = DateTime.now(); + + // The week runs Monday to now, so the card always covers the week you + // are actually in rather than a trailing seven days. + final DateTime start = + now.subtract(Duration(days: now.weekday - 1)); + + final response = + await getDataManager().getWeeklyReportCard(ReportCardRequest( + periodStart: start.toIso8601String(), + periodEnd: now.toIso8601String(), + )); + + closeLoading(); + + connection.onReportLoaded(ReportCard.fromJson(response.data)); + } catch (e) { + handleError(e, () => loadReport(), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/settings/ConnectSettings.dart b/frontend/lib/Grounded/see/settings/ConnectSettings.dart new file mode 100644 index 0000000..89a1ae3 --- /dev/null +++ b/frontend/lib/Grounded/see/settings/ConnectSettings.dart @@ -0,0 +1,11 @@ +import '../../about/internal/application/UserDetails.dart'; + +abstract class ConnectSettings { + void onUserLoaded(UserDetails details, int amnestyRemaining); + + void onToneChanged(UserDetails details); + + void onSickModeChanged(UserDetails details); + + void onSignedOut(); +} diff --git a/frontend/lib/Grounded/see/settings/Settings.dart b/frontend/lib/Grounded/see/settings/Settings.dart new file mode 100644 index 0000000..96cdc55 --- /dev/null +++ b/frontend/lib/Grounded/see/settings/Settings.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'SettingsState.dart'; + +class Settings extends StatefulWidget { + const Settings({super.key}); + + @override + State createState() => SettingsState(); +} diff --git a/frontend/lib/Grounded/see/settings/SettingsState.dart b/frontend/lib/Grounded/see/settings/SettingsState.dart new file mode 100644 index 0000000..313d284 --- /dev/null +++ b/frontend/lib/Grounded/see/settings/SettingsState.dart @@ -0,0 +1,344 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../about/internal/application/ToneLevel.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/Thresholds.dart'; +import '../../utils/Validators.dart'; +import '../login/Login.dart'; +import 'ConnectSettings.dart'; +import 'Settings.dart'; +import 'ViewSettings.dart'; + +class SettingsState extends State implements ConnectSettings { + ViewSettings? _model; + + UserDetails _user = UserDetails(pic: '', name: ''); + + int _amnestyRemaining = 0; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewSettings(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.initialise(); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context); + } + + void _onToneSelected(ToneLevel tone) { + _model?.changeTone(tone); + } + + void _onToggleSickMode() { + if (_user.sickMode) { + _model?.setSickMode(false, ""); + return; + } + + _openSickModeSheet(); + } + + void _onSignOut() { + _model?.signOut(); + } + + void _openSickModeSheet() { + final TextEditingController reason = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + builder: (BuildContext sheetContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: Container( + decoration: BoxDecoration( + color: colorSheetBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + text("PAUSE", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 10), + text("Sick or travelling.", 26, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 14), + text( + "Debt stops accruing entirely while this is on. It is logged in your history, which is the only reason it stays honest.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 24), + inputField( + "Reason", + reason, + hint: "What is going on?", + validator: Validators.excuse, + maxLines: 3, + ), + const SizedBox(height: 24), + roundedCornerButton( + "Pause everything", + () { + if (formKey.currentState?.validate() != true) { + return; + } + Navigator.pop(sheetContext); + _model?.setSickMode(true, reason.text.trim()); + }, + icon: CupertinoIcons.pause_fill, + ), + const SizedBox(height: 8), + Center( + child: textButton( + "Cancel", + () => Navigator.pop(sheetContext), + textSize: 13, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: _user.name.isEmpty ? "Account" : _user.name, + title: "Settings", + onBack: _onBack, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + sectionBreak("Tone", caption: "how it speaks to you"), + segmentedSelector( + options: ToneLevel.values, + selected: _user.tone, + label: toneLabel, + onSelected: _onToneSelected, + ), + const SizedBox(height: 12), + text( + _toneDescription(_user.tone), + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 10), + text( + "Whatever you pick, nothing here will attack you as a person. It criticises what you did, never who you are.", + 11, + TextType.Regular, + color: colorGrey, + height: 1.5, + ), + const SizedBox(height: 28), + sectionBreak("Amnesty", caption: "rationed on purpose"), + card( + child: Row( + children: [ + Expanded( + child: labelled( + "Remaining this month", + "$_amnestyRemaining of ${Thresholds.amnestyTokensPerMonth}", + valueSize: 20, + valueType: TextType.Light, + ), + ), + Container( + width: 46, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _amnestyRemaining > 0 + ? colorStandingGoodBg + : colorMuted, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + CupertinoIcons.checkmark_shield_fill, + size: 20, + color: _amnestyRemaining > 0 ? colorPositive : colorGrey, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + text( + "Tokens wipe an item's debt, no questions asked. They exist so one bad flu does not undo three months.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 28), + sectionBreak("Pause", caption: "sick or travel"), + card( + background: _user.sickMode ? colorStandingGoodBg : colorCard, + borderColor: _user.sickMode + ? colorPositive.withValues(alpha: 0.20) + : colorBorder, + onTap: _onToggleSickMode, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text( + _user.sickMode ? "Paused" : "Running", + 16, + TextType.Medium, + color: colorPrimaryDark, + ), + const SizedBox(height: 6), + text( + _user.sickMode + ? "Debt is not accruing. Tap to resume." + : "Debt is accruing normally. Tap to pause.", + 12, + TextType.Regular, + color: colorGrey2, + ), + ], + ), + ), + Icon( + _user.sickMode + ? CupertinoIcons.pause_circle_fill + : CupertinoIcons.play_circle, + size: 26, + color: _user.sickMode ? colorPositive : colorGrey2, + ), + ], + ), + ), + const SizedBox(height: 36), + outlinedActionButton( + "Sign out", + _onSignOut, + foreground: colorStandingLockdown, + icon: CupertinoIcons.square_arrow_right, + ), + const SizedBox(height: 20), + Center( + child: text("Grounded 1.0.0", 11, TextType.Regular, + color: colorGrey), + ), + ], + ), + ); + } + + String _toneDescription(ToneLevel tone) { + switch (tone) { + case ToneLevel.Firm: + return "Direct and unsentimental. States the facts and leaves them there."; + case ToneLevel.Strict: + return "Holds you to what you said. Disappointment rather than anger, because it works better."; + case ToneLevel.DrillSergeant: + return "Blunt and relentless about the behaviour. Still never about you."; + } + } + + // ── ConnectSettings ─────────────────────────────────────────────────────── + + @override + void onUserLoaded(UserDetails details, int amnestyRemaining) { + setState(() { + _user = details; + _amnestyRemaining = amnestyRemaining; + }); + } + + @override + void onToneChanged(UserDetails details) { + setState(() { + _user = details; + }); + } + + @override + void onSickModeChanged(UserDetails details) { + setState(() { + _user = details; + }); + } + + @override + void onSignedOut() { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const Login(), context); + } +} diff --git a/frontend/lib/Grounded/see/settings/ViewSettings.dart b/frontend/lib/Grounded/see/settings/ViewSettings.dart new file mode 100644 index 0000000..40d07c8 --- /dev/null +++ b/frontend/lib/Grounded/see/settings/ViewSettings.dart @@ -0,0 +1,91 @@ +import '../../about/external/initial/SickModeRequest.dart'; +import '../../about/external/initial/ToneRequest.dart'; +import '../../about/internal/application/MeDescription.dart'; +import '../../about/internal/application/ToneLevel.dart'; +import '../../about/internal/application/UserDetails.dart'; +import '../../utils/GuardrailEngine.dart'; +import '../../utils/Thresholds.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectSettings.dart'; + +class ViewSettings extends ParentViewModel { + ConnectSettings connection; + + ViewSettings(super.context, this.connection); + + void initialise() async { + final UserDetails details = await getDataManager().getUserDetails(); + + final int spent = await getDataManager().getAmnestySpent(); + + connection.onUserLoaded( + details, + GuardrailEngine.tokensRemaining(Thresholds.amnestyTokensPerMonth, spent), + ); + } + + /// The tone slider is capped: it changes register, never cruelty. + void changeTone(ToneLevel tone) async { + if (!await hasNetwork(() => changeTone(tone))) return; + + showLoading("Saving"); + + try { + await getDataManager().updateTone(ToneRequest(tone: tone.name)); + + final UserDetails details = await getDataManager().getUserDetails(); + details.tone = tone; + await getDataManager().setUserDetails(details); + + closeLoading(); + + connection.onToneChanged(details); + } catch (e) { + handleError(e, () => changeTone(tone), () => dismissError(), "Retry"); + } + } + + /// Sick mode pauses debt accrual entirely. It requires a reason and is + /// logged, so it stays honest without being punitive. + void setSickMode(bool enabled, String reason) async { + if (!await hasNetwork(() => setSickMode(enabled, reason))) return; + + showLoading(enabled ? "Pausing" : "Resuming"); + + try { + await getDataManager().updateSickMode(SickModeRequest( + enabled: enabled, + reason: reason, + )); + + final UserDetails details = await getDataManager().getUserDetails(); + details.sickMode = enabled; + await getDataManager().setUserDetails(details); + + closeLoading(); + + connection.onSickModeChanged(details); + } catch (e) { + handleError(e, () => setSickMode(enabled, reason), () => dismissError(), + "Retry"); + } + } + + void signOut() async { + showLoading("Signing out"); + + try { + await getDataManager().logout(); + } catch (e) { + // A failed logout call must never trap the user in the app; the local + // session is cleared either way. + } + + await getDataManager() + .setMyDescription(MeDescription(id: "", name: "", token: "")); + + closeLoading(); + + connection.onSignedOut(); + } +} diff --git a/frontend/lib/Grounded/see/splash/ConnectSplash.dart b/frontend/lib/Grounded/see/splash/ConnectSplash.dart new file mode 100644 index 0000000..46f5e2c --- /dev/null +++ b/frontend/lib/Grounded/see/splash/ConnectSplash.dart @@ -0,0 +1,7 @@ +import '../../about/internal/application/MeDescription.dart'; + +abstract class ConnectSplash { + void launchHome(MeDescription value); + + void launchLogin(); +} diff --git a/frontend/lib/Grounded/see/splash/Splash.dart b/frontend/lib/Grounded/see/splash/Splash.dart new file mode 100644 index 0000000..957cfa6 --- /dev/null +++ b/frontend/lib/Grounded/see/splash/Splash.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'SplashState.dart'; + +class Splash extends StatefulWidget { + const Splash({super.key}); + + @override + State createState() => SplashState(); +} diff --git a/frontend/lib/Grounded/see/splash/SplashState.dart b/frontend/lib/Grounded/see/splash/SplashState.dart new file mode 100644 index 0000000..a77a051 --- /dev/null +++ b/frontend/lib/Grounded/see/splash/SplashState.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/MeDescription.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/Images.dart'; +import '../home/Home.dart'; +import '../login/Login.dart'; +import 'ConnectSplash.dart'; +import 'Splash.dart'; +import 'ViewSplash.dart'; + +class SplashState extends State implements ConnectSplash { + ViewSplash? _model; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewSplash(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => PopScope( + canPop: false, + child: Scaffold( + backgroundColor: colorPrimaryDark, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ), + ), + ); + } + + Future _initiate() async { + // A beat on the wordmark, then the session decides where we land. + await Future.delayed(const Duration(milliseconds: 900)); + _model?.initialize(); + } + + Widget _mobileView(BoxConstraints constraints) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("GROUNDED", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The same mark the native splash shows, so the handover from + // the OS screen into the app is invisible. + Image.asset( + splashMark, + width: 108, + height: 108, + errorBuilder: (context, error, stackTrace) => + const SizedBox(height: 108), + ), + const SizedBox(height: 28), + text("Grounded", 52, TextType.Light, + color: colorWhite, height: 1.05), + const SizedBox(height: 16), + Container( + width: 44, + height: 2, + color: colorWhite.withValues(alpha: 0.30), + ), + const SizedBox(height: 20), + text( + "A to-do app that does not believe you.", + 15, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.60), + height: 1.5, + ), + ], + ), + SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: colorWhite.withValues(alpha: 0.12), + color: colorWhite.withValues(alpha: 0.70), + ), + ), + ], + ), + ), + ); + } + + @override + void launchHome(MeDescription value) { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const Home(), context); + } + + @override + void launchLogin() { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const Login(), context); + } +} diff --git a/frontend/lib/Grounded/see/splash/ViewSplash.dart b/frontend/lib/Grounded/see/splash/ViewSplash.dart new file mode 100644 index 0000000..0652b84 --- /dev/null +++ b/frontend/lib/Grounded/see/splash/ViewSplash.dart @@ -0,0 +1,29 @@ +import '../parent/ParentViewModel.dart'; +import 'ConnectSplash.dart'; + +class ViewSplash extends ParentViewModel { + ConnectSplash connection; + + ViewSplash(super.context, this.connection); + + void initialize() { + // App opens feed the distress conjunction, so the count is bumped before + // anything else happens. + _recordEngagement(); + + getDataManager().getMyDescription().then((value) { + if (value.token != "") { + connection.launchHome(value); + } else { + connection.launchLogin(); + } + }).onError((error, stackTrace) { + connection.launchLogin(); + }); + } + + void _recordEngagement() async { + final int count = await getDataManager().getEngagementCount(); + await getDataManager().setEngagementCount(count + 1); + } +} diff --git a/frontend/lib/Grounded/see/system/sessionexpired/ConnectSessionExpired.dart b/frontend/lib/Grounded/see/system/sessionexpired/ConnectSessionExpired.dart new file mode 100644 index 0000000..fb9a3fb --- /dev/null +++ b/frontend/lib/Grounded/see/system/sessionexpired/ConnectSessionExpired.dart @@ -0,0 +1,3 @@ +abstract class ConnectSessionExpired { + void launchLogin(); +} diff --git a/frontend/lib/Grounded/see/system/sessionexpired/SessionExpired.dart b/frontend/lib/Grounded/see/system/sessionexpired/SessionExpired.dart new file mode 100644 index 0000000..4fdafe2 --- /dev/null +++ b/frontend/lib/Grounded/see/system/sessionexpired/SessionExpired.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'SessionExpiredState.dart'; + +class SessionExpired extends StatefulWidget { + const SessionExpired({super.key}); + + @override + State createState() => SessionExpiredState(); +} diff --git a/frontend/lib/Grounded/see/system/sessionexpired/SessionExpiredState.dart b/frontend/lib/Grounded/see/system/sessionexpired/SessionExpiredState.dart new file mode 100644 index 0000000..7c9ef0b --- /dev/null +++ b/frontend/lib/Grounded/see/system/sessionexpired/SessionExpiredState.dart @@ -0,0 +1,117 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/internal/application/NavigatorType.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../configs/Navigator.dart'; +import '../../../designs/Responsive.dart'; +import '../../../designs/buttons/Buttons.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import '../../login/Login.dart'; +import 'ConnectSessionExpired.dart'; +import 'SessionExpired.dart'; +import 'ViewSessionExpired.dart'; + +class SessionExpiredState extends State + implements ConnectSessionExpired { + ViewSessionExpired? _model; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewSessionExpired(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => PopScope( + canPop: false, + child: Scaffold( + backgroundColor: colorPrimaryDark, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ), + ), + ); + } + + void _initiate() {} + + void _onSignIn() { + _model?.signOut(); + } + + Widget _mobileView(BoxConstraints constraints) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("GROUNDED", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(18), + ), + child: Icon(CupertinoIcons.lock_fill, + size: 24, color: colorWhite), + ), + const SizedBox(height: 28), + text("SESSION", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2), + const SizedBox(height: 10), + text("Your session has ended.", 34, TextType.Light, + color: colorWhite, height: 1.15), + const SizedBox(height: 14), + text( + "Sign in again to pick up where you left off. Your record is intact — nothing was cleared while you were away.", + 14, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.60), + height: 1.55, + ), + ], + ), + SizedBox( + width: double.infinity, + child: roundedCornerButton( + "Sign in", + _onSignIn, + background: colorWhite, + foreground: colorPrimaryDark, + icon: CupertinoIcons.arrow_right, + ), + ), + ], + ), + ), + ); + } + + @override + void launchLogin() { + GroundedNavigation() + .navigateToPage(NavigatorType.makeNewMain, const Login(), context); + } +} diff --git a/frontend/lib/Grounded/see/system/sessionexpired/ViewSessionExpired.dart b/frontend/lib/Grounded/see/system/sessionexpired/ViewSessionExpired.dart new file mode 100644 index 0000000..28a63b7 --- /dev/null +++ b/frontend/lib/Grounded/see/system/sessionexpired/ViewSessionExpired.dart @@ -0,0 +1,18 @@ +import '../../../about/internal/application/MeDescription.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectSessionExpired.dart'; + +class ViewSessionExpired extends ParentViewModel { + ConnectSessionExpired connection; + + ViewSessionExpired(super.context, this.connection); + + /// Clears the stored session before sending the user back to login, so an + /// expired token can never be replayed. + void signOut() async { + await getDataManager() + .setMyDescription(MeDescription(id: "", name: "", token: "")); + + connection.launchLogin(); + } +} diff --git a/frontend/lib/Grounded/see/system/updateme/ConnectUpdateMe.dart b/frontend/lib/Grounded/see/system/updateme/ConnectUpdateMe.dart new file mode 100644 index 0000000..cb38ca0 --- /dev/null +++ b/frontend/lib/Grounded/see/system/updateme/ConnectUpdateMe.dart @@ -0,0 +1,3 @@ +abstract class ConnectUpdateMe { + void onStoreOpened(); +} diff --git a/frontend/lib/Grounded/see/system/updateme/UpdateMe.dart b/frontend/lib/Grounded/see/system/updateme/UpdateMe.dart new file mode 100644 index 0000000..7d72b22 --- /dev/null +++ b/frontend/lib/Grounded/see/system/updateme/UpdateMe.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'UpdateMeState.dart'; + +class UpdateMe extends StatefulWidget { + const UpdateMe({super.key}); + + @override + State createState() => UpdateMeState(); +} diff --git a/frontend/lib/Grounded/see/system/updateme/UpdateMeState.dart b/frontend/lib/Grounded/see/system/updateme/UpdateMeState.dart new file mode 100644 index 0000000..f7fa51f --- /dev/null +++ b/frontend/lib/Grounded/see/system/updateme/UpdateMeState.dart @@ -0,0 +1,116 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../about/internal/application/TextType.dart'; +import '../../../designs/Responsive.dart'; +import '../../../designs/buttons/Buttons.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import 'ConnectUpdateMe.dart'; +import 'UpdateMe.dart'; +import 'ViewUpdateMe.dart'; + +class UpdateMeState extends State implements ConnectUpdateMe { + ViewUpdateMe? _model; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewUpdateMe(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => PopScope( + canPop: false, + child: Scaffold( + backgroundColor: colorPrimaryDark, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ), + ), + ); + } + + void _initiate() {} + + void _onUpdate() { + _model?.openStore(); + } + + Widget _mobileView(BoxConstraints constraints) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text("GROUNDED", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(18), + ), + child: Icon(CupertinoIcons.arrow_up_circle_fill, + size: 24, color: colorWhite), + ), + const SizedBox(height: 28), + text("UPDATE REQUIRED", 10, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), + letterSpacing: 1.2), + const SizedBox(height: 10), + text("This version is out of date.", 34, TextType.Light, + color: colorWhite, height: 1.15), + const SizedBox(height: 14), + text( + "Update to continue. Your commitments, debt and history are on the server and will be waiting.", + 14, + TextType.Regular, + color: colorWhite.withValues(alpha: 0.60), + height: 1.55, + ), + ], + ), + SizedBox( + width: double.infinity, + child: roundedCornerButton( + "Update now", + _onUpdate, + background: colorWhite, + foreground: colorPrimaryDark, + icon: CupertinoIcons.cloud_download_fill, + ), + ), + ], + ), + ), + ); + } + + @override + void onStoreOpened() async { + final Uri store = Uri.parse("https://grounded.app/download"); + if (await canLaunchUrl(store)) { + await launchUrl(store, mode: LaunchMode.externalApplication); + } + } +} diff --git a/frontend/lib/Grounded/see/system/updateme/ViewUpdateMe.dart b/frontend/lib/Grounded/see/system/updateme/ViewUpdateMe.dart new file mode 100644 index 0000000..bd731dd --- /dev/null +++ b/frontend/lib/Grounded/see/system/updateme/ViewUpdateMe.dart @@ -0,0 +1,12 @@ +import '../../parent/ParentViewModel.dart'; +import 'ConnectUpdateMe.dart'; + +class ViewUpdateMe extends ParentViewModel { + ConnectUpdateMe connection; + + ViewUpdateMe(super.context, this.connection); + + void openStore() { + connection.onStoreOpened(); + } +} diff --git a/frontend/lib/Grounded/see/training/ConnectTraining.dart b/frontend/lib/Grounded/see/training/ConnectTraining.dart new file mode 100644 index 0000000..705e395 --- /dev/null +++ b/frontend/lib/Grounded/see/training/ConnectTraining.dart @@ -0,0 +1,16 @@ +import '../../about/external/data/Program.dart'; +import '../../about/external/data/SessionLog.dart'; +import '../../about/external/data/SessionTemplate.dart'; + +abstract class ConnectTraining { + void onProgramLoaded(Program program, List sessions); + + void onHistoryLoaded(List sessions); + + /// The weekly plyometric contact ceiling has been reached — the app stops + /// you rather than pushing you. + void onContactCeilingReached(int contacts, int ceiling); + + /// Not enough recovery since the last hard lower-body session. + void onRecoveryBlocked(int hoursRemaining); +} diff --git a/frontend/lib/Grounded/see/training/Training.dart b/frontend/lib/Grounded/see/training/Training.dart new file mode 100644 index 0000000..1b06192 --- /dev/null +++ b/frontend/lib/Grounded/see/training/Training.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'TrainingState.dart'; + +class Training extends StatefulWidget { + const Training({super.key}); + + @override + State createState() => TrainingState(); +} diff --git a/frontend/lib/Grounded/see/training/TrainingState.dart b/frontend/lib/Grounded/see/training/TrainingState.dart new file mode 100644 index 0000000..cd797de --- /dev/null +++ b/frontend/lib/Grounded/see/training/TrainingState.dart @@ -0,0 +1,350 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Program.dart'; +import '../../about/external/data/SessionLog.dart'; +import '../../about/external/data/SessionTemplate.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/IntegrityEngine.dart'; +import 'ConnectTraining.dart'; +import 'Training.dart'; +import 'ViewTraining.dart'; + +class TrainingState extends State implements ConnectTraining { + ViewTraining? _model; + + Program _program = Program(); + + List _sessions = []; + + List _history = []; + + int _contacts = 0; + + bool _ceilingReached = false; + + int _recoveryHoursRemaining = 0; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewTraining(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadProgram(); + } + + void _onBack() { + Navigator.pop(context); + } + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: _program.name.isEmpty ? "No program" : _program.name, + title: "Training", + onBack: _onBack, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_ceilingReached || _recoveryHoursRemaining > 0) ...[ + _stopCard(), + const SizedBox(height: 24), + ], + if (_program.id == null) + emptyState( + CupertinoIcons.flame, + "No active program", + "Build a program with sessions, progression rules and scheduled deloads, and missed sessions start feeding your debt.", + ) + else ...[ + _programCard(), + const SizedBox(height: 28), + sectionBreak("Sessions", caption: "${_sessions.length} per cycle"), + if (_sessions.isEmpty) + text("No sessions defined yet.", 13, TextType.Regular, + color: colorGrey2) + else + ..._sessions.map(_sessionRow), + const SizedBox(height: 28), + sectionBreak("Volume", caption: "this week"), + _volumeCard(), + const SizedBox(height: 28), + sectionBreak("Recent", caption: "last sessions"), + if (_history.isEmpty) + text("Nothing logged yet.", 13, TextType.Regular, + color: colorGrey2) + else + ..._history.take(5).map(_historyRow), + ], + ], + ), + ); + } + + /// Plyo and recovery are the two places the app refuses rather than nags. + Widget _stopCard() { + final bool ceiling = _ceilingReached; + + return card( + background: colorStandingLockdownBg, + borderColor: colorStandingLockdown.withValues(alpha: 0.25), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(CupertinoIcons.hand_raised_fill, + size: 16, color: colorStandingLockdown), + const SizedBox(width: 8), + text(ceiling ? "CONTACT CEILING" : "RECOVERY", 9, TextType.Bold, + color: colorStandingLockdown, letterSpacing: 1.2), + ], + ), + const SizedBox(height: 12), + text( + ceiling ? "Stop plyometrics this week." : "Not recovered yet.", + 22, + TextType.Light, + color: colorPrimaryDark, + ), + const SizedBox(height: 10), + text( + ceiling + ? "You are at $_contacts of ${_program.weeklyContactCeiling} ground contacts. Connective tissue does not recover on a motivation schedule — this is the one place the app stops you." + : "$_recoveryHoursRemaining hours left before the next hard lower-body session. Training through this is not discipline, it is a shortcut to an injury.", + 13, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + ], + ), + ); + } + + Widget _programCard() { + return card( + background: colorPrimaryDark, + borderColor: colorPrimaryDark, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("ACTIVE PROGRAM", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), + const SizedBox(height: 10), + text(_program.name, 24, TextType.Light, color: colorWhite), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: labelled( + "Weeks", + "${_program.weeks}", + valueSize: 18, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + Expanded( + child: labelled( + "Per week", + "${_program.sessionsPerWeek}", + valueSize: 18, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + Expanded( + child: labelled( + "Deloads", + "${_program.deloadWeeks.length}", + valueSize: 18, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _sessionRow(SessionTemplate session) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(session.name, 15, TextType.Medium, + color: colorPrimaryDark), + const SizedBox(height: 6), + text("${session.prescriptions.length} exercises", 11, + TextType.Regular, color: colorGrey2), + ], + ), + ), + if (session.highIntensityLowerBody) + pill("HIGH LOAD", colorStandingWarned, colorStandingWarnedBg, + textSize: 9), + ], + ), + ), + ); + } + + /// Volume per muscle group, not "you went to the gym". + Widget _volumeCard() { + final Map volume = + IntegrityEngine.weeklySetsByMuscleGroup(_history); + + if (volume.isEmpty) { + return card( + child: text("No sets logged this week.", 13, TextType.Regular, + color: colorGrey2), + ); + } + + return card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: volume.entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text(entry.key, 13, TextType.Regular, + color: colorPrimaryDark), + text("${entry.value} sets", 12, TextType.Bold, + color: colorGrey2), + ], + ), + const SizedBox(height: 6), + meter(entry.value / 20), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _historyRow(SessionLog session) { + final bool watered = session.integrityScore > 0 && + session.integrityScore < 0.7; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(session.templateName, 14, TextType.Medium, + color: colorPrimaryDark), + const SizedBox(height: 6), + text( + "${formatDate(session.startedAt)} · ${session.sets.length} sets · RPE ${session.sessionRpe.toStringAsFixed(1)}", + 11, + TextType.Regular, + color: colorGrey2, + ), + ], + ), + ), + if (session.integrityScore > 0) + pill( + "${(session.integrityScore * 100).round()}%", + watered ? colorStandingGrounded : colorPositive, + watered ? colorStandingGroundedBg : colorStandingGoodBg, + textSize: 9, + ), + ], + ), + ), + ); + } + + // ── ConnectTraining ─────────────────────────────────────────────────────── + + @override + void onProgramLoaded(Program program, List sessions) { + setState(() { + _program = program; + _sessions = sessions; + }); + } + + @override + void onHistoryLoaded(List sessions) { + setState(() { + _history = sessions; + }); + } + + @override + void onContactCeilingReached(int contacts, int ceiling) { + setState(() { + _contacts = contacts; + _ceilingReached = true; + }); + + _model?.showApplicationNotification( + NotificationType.warning, + "Contact ceiling reached", + "$contacts of $ceiling ground contacts this week. No more plyometrics until it resets.", + true, + true, + null, + ); + } + + @override + void onRecoveryBlocked(int hoursRemaining) { + setState(() { + _recoveryHoursRemaining = hoursRemaining; + }); + } +} diff --git a/frontend/lib/Grounded/see/training/ViewTraining.dart b/frontend/lib/Grounded/see/training/ViewTraining.dart new file mode 100644 index 0000000..a1634b2 --- /dev/null +++ b/frontend/lib/Grounded/see/training/ViewTraining.dart @@ -0,0 +1,129 @@ +import '../../about/external/data/Program.dart'; +import '../../about/external/data/SessionLog.dart'; +import '../../about/external/data/SessionTemplate.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../about/external/data/pages/response/SessionLogPage.dart'; +import '../../about/external/initial/IdRequest.dart'; +import '../../utils/IntegrityEngine.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectTraining.dart'; + +class ViewTraining extends ParentViewModel { + ConnectTraining connection; + + ViewTraining(super.context, this.connection); + + void loadProgram() async { + if (!await hasNetwork(() => loadProgram())) return; + + showLoading("Loading your program"); + + try { + final response = await getDataManager().getMyPrograms(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'active'), + page: Pageable(0, 0, 20, 0), + ), + )); + + final List programs = getProgramList(response.data); + + final Program active = programs.firstWhere( + (item) => item.active, + orElse: () => programs.isEmpty ? Program() : programs.first, + ); + + await getDataManager().setActiveProgram(active); + + final List sessions = await _loadSessions(active); + + closeLoading(); + + connection.onProgramLoaded(active, sessions); + + loadHistory(active); + } catch (e) { + handleError(e, () => loadProgram(), () => dismissError(), "Retry"); + } + } + + Future> _loadSessions(Program program) async { + if (program.id == null) { + return []; + } + + final response = await getDataManager() + .getProgramSessions(IdRequest(id: program.id ?? "")); + + return getSessionTemplateList(response.data); + } + + void loadHistory(Program program) async { + try { + final response = await getDataManager().getSessionHistory(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'startedAt'), + page: Pageable(0, 0, 20, 0), + ), + )); + + final SessionLogPage page = SessionLogPage.fromJson(response.data); + + connection.onHistoryLoaded(page.content); + + _checkLoadCeilings(program, page.content); + } catch (e) { + handleError(e, () => loadHistory(program), () => dismissError(), "Retry"); + } + } + + /// Plyometrics is the one modality where the app should stop you rather than + /// push you — CNS and connective tissue do not recover on a motivation + /// schedule. + void _checkLoadCeilings(Program program, List history) { + final DateTime weekStart = + DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + + final List thisWeek = history + .where((session) => + session.startedAt != null && + session.startedAt!.isAfter(weekStart)) + .toList(); + + final int contacts = IntegrityEngine.weeklyContacts(thisWeek); + + if (IntegrityEngine.contactCeilingBreached( + thisWeek, program.weeklyContactCeiling)) { + connection.onContactCeilingReached( + contacts, program.weeklyContactCeiling); + return; + } + + _checkRecovery(program, history); + } + + void _checkRecovery(Program program, List history) { + // The most recent hard lower-body session gates the next one. + final List hard = history + .where((session) => session.sessionRpe >= 8 && session.startedAt != null) + .toList(); + + if (hard.isEmpty) { + return; + } + + final DateTime last = hard.first.startedAt!; + + if (IntegrityEngine.recoveredEnough(last, program.lowerBodyRecoveryHours)) { + return; + } + + final int elapsed = DateTime.now().difference(last).inHours; + + connection.onRecoveryBlocked(program.lowerBodyRecoveryHours - elapsed); + } +} diff --git a/frontend/lib/Grounded/utils/CapacityEngine.dart b/frontend/lib/Grounded/utils/CapacityEngine.dart new file mode 100644 index 0000000..03f6e67 --- /dev/null +++ b/frontend/lib/Grounded/utils/CapacityEngine.dart @@ -0,0 +1,134 @@ +import '../about/external/data/Commitment.dart'; +import '../about/internal/application/CapacityProfile.dart'; + +/// The result of a capacity check at plan time. +class CapacityVerdict { + /// Minutes the plan actually implies, after the estimation multipliers. + double projectedMinutes; + + /// Minutes history says get done on this weekday, at p50. + double historicalMinutes; + + /// The ceiling — historical minutes times the headroom factor. + double allowedMinutes; + + bool blocked; + + /// Minutes that must come out of the plan before it will be accepted. + double excessMinutes; + + String message; + + CapacityVerdict({ + this.projectedMinutes = 0, + this.historicalMinutes = 0, + this.allowedMinutes = 0, + this.blocked = false, + this.excessMinutes = 0, + this.message = "", + }); +} + +/// Chronic overdue is usually an overcommitment problem misdiagnosed as a +/// laziness problem. This is the check that catches it. +/// +/// ``` +/// projected_load = Sum (est_minutes x your_multiplier[category]) +/// if projected_load > 0.85 x p50(historical_completed_minutes[weekday]): +/// block, and force removal +/// ``` +class CapacityEngine { + /// Plan against 85% of what you historically get done, not 100%. + static const double headroom = 0.85; + + /// The plan as the app believes it, not as you estimated it. Your estimates + /// are wrong and the multiplier is applied silently. + static double projectedLoad( + List plan, + CapacityProfile profile, + ) { + double total = 0; + for (Commitment commitment in plan) { + total = + total + (commitment.estMinutes * profile.multiplierFor(commitment.category)); + } + return total; + } + + static CapacityVerdict check( + List plan, + CapacityProfile profile, + int weekday, + ) { + final double projected = projectedLoad(plan, profile); + final double historical = profile.capacityFor(weekday); + final double allowed = historical * headroom; + + // No history yet — the app has not earned the right to block anything. + if (historical <= 0) { + return CapacityVerdict( + projectedMinutes: projected, + historicalMinutes: 0, + allowedMinutes: 0, + blocked: false, + message: "Not enough history yet to judge this plan.", + ); + } + + if (projected <= allowed) { + return CapacityVerdict( + projectedMinutes: projected, + historicalMinutes: historical, + allowedMinutes: allowed, + blocked: false, + message: "This plan fits what you actually get done.", + ); + } + + final double excess = projected - allowed; + + return CapacityVerdict( + projectedMinutes: projected, + historicalMinutes: historical, + allowedMinutes: allowed, + blocked: true, + excessMinutes: excess, + message: + "You have allocated ${_hours(projected)} of tasks into a day where you historically complete ${_hours(historical)}. Cut ${_hours(excess)}.", + ); + } + + /// The learned multiplier for a category, from what you said against what it + /// took. Surfaced in the weekly review rather than hidden. + static double learnMultiplier( + List estimatedMinutes, + List actualMinutes, + ) { + if (estimatedMinutes.isEmpty || + estimatedMinutes.length != actualMinutes.length) { + return 1.0; + } + + double estimated = 0; + double actual = 0; + + for (int index = 0; index < estimatedMinutes.length; index++) { + estimated = estimated + estimatedMinutes[index]; + actual = actual + actualMinutes[index]; + } + + if (estimated <= 0) { + return 1.0; + } + + return actual / estimated; + } + + static String _hours(double minutes) { + if (minutes < 60) { + return "${minutes.round()}min"; + } + final double hours = minutes / 60; + return "${hours.toStringAsFixed(1)}h"; + } +} diff --git a/frontend/lib/Grounded/utils/Colors.dart b/frontend/lib/Grounded/utils/Colors.dart new file mode 100644 index 0000000..a171ee0 --- /dev/null +++ b/frontend/lib/Grounded/utils/Colors.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +MaterialColor createMaterialColor(Color color) { + List strengths = [.05]; + final swatch = {}; + final int r = (color.r * 255.0).round() & 0xff, + g = (color.g * 255.0).round() & 0xff, + b = (color.b * 255.0).round() & 0xff; + + for (int i = 1; i < 10; i++) { + strengths.add(0.1 * i); + } + for (var strength in strengths) { + final double ds = 0.5 - strength; + swatch[(strength * 1000).round()] = Color.fromRGBO( + r + ((ds < 0 ? r : (255 - r)) * ds).round(), + g + ((ds < 0 ? g : (255 - g)) * ds).round(), + b + ((ds < 0 ? b : (255 - b)) * ds).round(), + 1, + ); + } + return MaterialColor(color.toARGB32(), swatch); +} + +// ── Main scheme ─────────────────────────────────────────────────────────────── +// Grounded reads as an institution, not a toy: near-black ink, one hard +// accent, and a warning palette that carries the enforcement tiers. +var colorWhite = const Color(0xFFFFFFFF); +var colorPrimary = const Color(0xFF2F6F4E); +var colorPrimaryDark = const Color(0xFF141414); +var colorPrimaryDark2 = const Color(0xFF232323); +var colorPrimaryLight = const Color(0xFFEDEEEA); +var colorPrimaryLight2 = const Color(0xFFF4F5F2); + +var colorAccent = const Color(0xFFB5442F); +var colorMilkWhite = const Color(0xFFD9D8CE); +var colorMuted = const Color(0xFFE6EAE6); +var colorSecondary = const Color(0xFFF1F4F1); + +var colorPositive = const Color(0xFF2F6F4E); +var colorNegative = const Color(0xFFB3261E); +var colorGrey = const Color(0xFFA8A8A8); +var colorGrey2 = const Color(0xFF6E6E6E); +var colorGrey3 = const Color(0xFF8A8A8A); +var colorTinted = const Color(0xFFE0A88F); +var colorWarmYellow = const Color(0xFFD9A404); +var colorReddish = const Color(0xFFC0392B); +var colorDarkBlue = const Color(0xFF10161F); + +// ── Semantic ────────────────────────────────────────────────────────────────── +const Color colorSuccess = Color(0xFF2E7D4F); +const Color colorWarning = Color(0xFFC98A04); +const Color colorDestructive = Color(0xFFA32C1C); +const Color colorBlack = Color(0xFF000000); +const Color colorBorder = Color(0x14000000); +const Color colorDivider = Color(0x12000000); + +// ── Standing tiers ──────────────────────────────────────────────────────────── +// Each standing has one colour used consistently everywhere it appears, so the +// tier is legible at a glance without reading the label. +const Color colorStandingGood = Color(0xFF2F6F4E); +const Color colorStandingGoodBg = Color(0xFFE4EFE8); +const Color colorStandingWarned = Color(0xFFC98A04); +const Color colorStandingWarnedBg = Color(0xFFFAF0D8); +const Color colorStandingGrounded = Color(0xFFB5442F); +const Color colorStandingGroundedBg = Color(0xFFF7E3DE); +const Color colorStandingLockdown = Color(0xFF7A1F14); +const Color colorStandingLockdownBg = Color(0xFFEFD6D2); + +// ── Commitment classes ──────────────────────────────────────────────────────── +const Color colorClassNonNegotiable = Color(0xFF7A1F14); +const Color colorClassNonNegotiableBg = Color(0xFFF3DFDB); +const Color colorClassStandard = Color(0xFF2C4A63); +const Color colorClassStandardBg = Color(0xFFE0E8EF); +const Color colorClassElective = Color(0xFF6E6E6E); +const Color colorClassElectiveBg = Color(0xFFEDEDED); + +// ── Debt / charting ─────────────────────────────────────────────────────────── +const Color colorDebtLine = Color(0xFFB5442F); +const Color colorDebtFill = Color(0x1AB5442F); +const Color colorChartGrid = Color(0x0F000000); +const Color colorChartAxis = Color(0xFF8A8A8A); + +// ── Surfaces ────────────────────────────────────────────────────────────────── +const Color colorCard = Color(0xFFFFFFFF); +const Color colorSheetBackground = Color(0xFFF6F5F2); +const Color colorInset = Color(0xFFFAFAF8); + +// ── Dark surfaces ───────────────────────────────────────────────────────────── +const Color colorDarkBg = Color(0xFF080808); +const Color colorDarkCard = Color(0xFF141414); +const Color colorDarkSurface = Color(0xFF1C1C1C); +const Color colorDarkBorder = Color(0xFF262626); + +Color getColorFromHex(String hexColor) { + if (hexColor.isEmpty || hexColor.length <= 6) { + hexColor = "#ffffff"; + } + + hexColor = hexColor.toUpperCase().replaceAll("#", ""); + + if (hexColor.length == 6) { + hexColor = "FF$hexColor"; + } + + try { + return Color(int.parse(hexColor, radix: 16)); + } catch (e) { + return colorWhite; + } +} + +String colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}'; +} diff --git a/frontend/lib/Grounded/utils/CommonUtils.dart b/frontend/lib/Grounded/utils/CommonUtils.dart new file mode 100644 index 0000000..cf1d2bc --- /dev/null +++ b/frontend/lib/Grounded/utils/CommonUtils.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../about/external/data/Commitment.dart'; +import '../about/internal/application/CommitmentClass.dart'; +import '../about/internal/application/Standing.dart'; +import 'Colors.dart'; + +/// Formatting and small shared derivations. Anything a screen would otherwise +/// inline twice belongs here. + +/// "Mon 06:00–08:00" — the window, which is the whole point. +String formatWindow(Commitment commitment) { + if (commitment.dueStart == null || commitment.dueEnd == null) { + return "No window set"; + } + + final DateFormat day = DateFormat('EEE'); + final DateFormat time = DateFormat('HH:mm'); + + final String startDay = day.format(commitment.dueStart!); + final String startTime = time.format(commitment.dueStart!); + final String endTime = time.format(commitment.dueEnd!); + + final bool sameDay = commitment.dueStart!.day == commitment.dueEnd!.day && + commitment.dueStart!.month == commitment.dueEnd!.month; + + if (sameDay) { + return "$startDay $startTime–$endTime"; + } + + return "$startDay $startTime – ${day.format(commitment.dueEnd!)} $endTime"; +} + +String formatDate(DateTime? value) { + if (value == null) { + return ""; + } + return DateFormat('d MMM yyyy').format(value); +} + +String formatDateTime(DateTime? value) { + if (value == null) { + return ""; + } + return DateFormat('d MMM, HH:mm').format(value); +} + +/// "3h 20m" from raw minutes. +String formatMinutes(num minutes) { + final int total = minutes.round(); + if (total < 60) { + return "${total}m"; + } + final int hours = total ~/ 60; + final int remainder = total % 60; + if (remainder == 0) { + return "${hours}h"; + } + return "${hours}h ${remainder}m"; +} + +/// "12:04" from raw seconds — for the proof timer and rest clocks. +String formatClock(int seconds) { + final int safe = seconds < 0 ? 0 : seconds; + final int minutes = safe ~/ 60; + final int remainder = safe % 60; + return "${minutes.toString().padLeft(2, '0')}:${remainder.toString().padLeft(2, '0')}"; +} + +/// Debt is shown to one decimal — precise enough to move visibly when you +/// clear something, coarse enough not to look like a lie. +String formatDebt(double debt) { + return debt.toStringAsFixed(1); +} + +/// "2 days overdue", "Due in 40m", "Window closes in 3h". +String overdueLabel(Commitment commitment) { + if (commitment.dueEnd == null) { + return ""; + } + + final Duration difference = DateTime.now().difference(commitment.dueEnd!); + + if (difference.isNegative) { + final Duration remaining = difference.abs(); + if (remaining.inHours < 1) { + return "Closes in ${remaining.inMinutes}m"; + } + if (remaining.inDays < 1) { + return "Closes in ${remaining.inHours}h"; + } + return "Closes in ${remaining.inDays}d"; + } + + if (difference.inHours < 1) { + return "${difference.inMinutes}m overdue"; + } + if (difference.inDays < 1) { + return "${difference.inHours}h overdue"; + } + if (difference.inDays == 1) { + return "1 day overdue"; + } + return "${difference.inDays} days overdue"; +} + +Color standingColor(Standing standing) { + switch (standing) { + case Standing.Good: + return colorStandingGood; + case Standing.Warned: + return colorStandingWarned; + case Standing.Grounded: + return colorStandingGrounded; + case Standing.Lockdown: + return colorStandingLockdown; + } +} + +Color standingBackground(Standing standing) { + switch (standing) { + case Standing.Good: + return colorStandingGoodBg; + case Standing.Warned: + return colorStandingWarnedBg; + case Standing.Grounded: + return colorStandingGroundedBg; + case Standing.Lockdown: + return colorStandingLockdownBg; + } +} + +Color classColor(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return colorClassNonNegotiable; + case CommitmentClass.Standard: + return colorClassStandard; + case CommitmentClass.Elective: + return colorClassElective; + } +} + +Color classBackground(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return colorClassNonNegotiableBg; + case CommitmentClass.Standard: + return colorClassStandardBg; + case CommitmentClass.Elective: + return colorClassElectiveBg; + } +} + +/// The weekday name for a 1..7 index. +String weekdayName(int weekday) { + const List names = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + if (weekday < 1 || weekday > 7) { + return ""; + } + return names[weekday - 1]; +} + +/// "7pm" for an hour-of-day index. +String hourLabel(int hour) { + if (hour < 0) { + return ""; + } + if (hour == 0) { + return "midnight"; + } + if (hour < 12) { + return "${hour}am"; + } + if (hour == 12) { + return "noon"; + } + return "${hour - 12}pm"; +} diff --git a/frontend/lib/Grounded/utils/DebtEngine.dart b/frontend/lib/Grounded/utils/DebtEngine.dart new file mode 100644 index 0000000..1f8dc30 --- /dev/null +++ b/frontend/lib/Grounded/utils/DebtEngine.dart @@ -0,0 +1,159 @@ +import 'dart:math'; + +import '../about/external/data/Commitment.dart'; +import '../about/external/data/Habit.dart'; +import '../about/internal/application/CommitmentClass.dart'; +import '../about/internal/application/CommitmentStatus.dart'; + +/// The single number the whole system runs on. +/// +/// ``` +/// debt = Sum over open/missed commitments: +/// w(class) x severity(days_overdue) x recency_decay(t) +/// +/// w(class): non-negotiable 5.0 | standard 2.0 | elective 0.0 +/// severity(d): 1 + log2(1 + d) +/// recency_decay: 0.5 ^ (days_since / half_life) +/// abandonment: one-time +2x w(class), no decay for 30 days +/// late_complete: debt reduced to 30% of accrued, not 0 +/// ``` +/// +/// Sublinear severity is the load-bearing choice: with linear growth a single +/// ancient task swamps the score and the number stops meaning anything. +class DebtEngine { + /// Half-life of the recency decay, in days. + static const double halfLifeDays = 14; + + /// Multiplier applied once when a commitment is abandoned. + static const double abandonmentMultiplier = 2.0; + + /// Days an abandonment resists decay. + static const int abandonmentProtectionDays = 30; + + /// What a late completion leaves behind. Never zero — otherwise you learn + /// that everything is negotiable. + static const double lateCompleteRetention = 0.30; + + /// Debt weight per habit shortfall unit. + static const double habitShortfallWeight = 1.0; + + /// `severity(d) = 1 + log2(1 + d)`. + static double severity(int daysOverdue) { + final int days = daysOverdue < 0 ? 0 : daysOverdue; + return 1 + (log(1 + days) / ln2); + } + + /// `recency_decay(t) = 0.5 ^ (days_since / half_life)`. + static double recencyDecay(int daysSince) { + final int days = daysSince < 0 ? 0 : daysSince; + return pow(0.5, days / halfLifeDays).toDouble(); + } + + /// Debt contributed by one commitment, as of [now]. + static double commitmentDebt(Commitment commitment, {DateTime? now}) { + final DateTime moment = now ?? DateTime.now(); + + final double weight = classWeight(commitment.commitmentClass); + + // Electives never accrue debt, whatever happens to them. + if (weight == 0) { + return 0; + } + + // Cleanly completed and archived items are settled. + if (commitment.status == CommitmentStatus.Completed || + commitment.status == CommitmentStatus.Archived) { + return 0; + } + + if (commitment.dueEnd == null) { + return 0; + } + + final int daysSince = moment.difference(commitment.dueEnd!).inDays; + + // The window is still open — nothing is owed yet. + if (daysSince < 0) { + return 0; + } + + if (commitment.status == CommitmentStatus.Abandoned) { + // Abandonment is the most expensive outcome and resists decay for a + // month, so it cannot be waited out. + final double base = weight * abandonmentMultiplier * severity(daysSince); + if (daysSince <= abandonmentProtectionDays) { + return base; + } + return base * recencyDecay(daysSince - abandonmentProtectionDays); + } + + final double accrued = + weight * severity(daysSince) * recencyDecay(daysSince); + + if (commitment.status == CommitmentStatus.LateCompleted) { + return accrued * lateCompleteRetention; + } + + return accrued; + } + + /// Debt contributed by a habit. A single miss costs nothing — only falling + /// below the frequency target in the rolling window does. + static double habitDebt(Habit habit) { + if (!habit.behindTarget) { + return 0; + } + return habit.shortfall * habitShortfallWeight; + } + + /// The whole score, as of [now]. + static double totalDebt( + List commitments, { + List habits = const [], + DateTime? now, + }) { + final DateTime moment = now ?? DateTime.now(); + + double total = 0; + + for (Commitment commitment in commitments) { + total = total + commitmentDebt(commitment, now: moment); + } + + for (Habit habit in habits) { + total = total + habitDebt(habit); + } + + return total; + } + + /// Open items past their window — the count that [Thresholds.maxOpenOverdue] + /// caps. + static int openOverdueCount(List commitments) { + return commitments + .where((commitment) => + commitment.status == CommitmentStatus.Overdue || + (commitment.status == CommitmentStatus.Open && + commitment.windowClosed)) + .length; + } + + /// Missed non-negotiables — three of these force Grounded regardless of the + /// numeric score. + static int missedNonNegotiables(List commitments) { + return commitments + .where((commitment) => + commitment.commitmentClass == CommitmentClass.NonNegotiable && + (commitment.status == CommitmentStatus.Overdue || + commitment.status == CommitmentStatus.Abandoned || + (commitment.status == CommitmentStatus.Open && + commitment.windowClosed))) + .length; + } + + /// What clearing this item would remove from the score — used to show the + /// user the actual price of each row in the overdue queue. + static double reliefFromClearing(Commitment commitment, {DateTime? now}) { + return commitmentDebt(commitment, now: now); + } +} diff --git a/frontend/lib/Grounded/utils/ExcuseAnalyser.dart b/frontend/lib/Grounded/utils/ExcuseAnalyser.dart new file mode 100644 index 0000000..6e20707 --- /dev/null +++ b/frontend/lib/Grounded/utils/ExcuseAnalyser.dart @@ -0,0 +1,211 @@ +import '../about/external/data/CommitmentEvent.dart'; +import '../about/external/data/ExcuseCluster.dart'; +import '../about/internal/application/EventType.dart'; + +/// Clusters excuses over time and turns the pattern into a confrontation: +/// "Too tired has appeared 14 times this month, 11 of them on gym days, +/// 9 of them after 7pm. Consider moving gym to morning." +/// +/// The on-device pass is a cheap keyword bucketing so the confrontation works +/// offline; the server refines clusters and overwrites [ExcuseCluster.insight]. +class ExcuseAnalyser { + /// Excuse families the local pass recognises. Order matters — the first + /// family whose keyword appears wins. + static const Map> families = >{ + "Too tired": ["tired", "exhausted", "knackered", "no energy", "sleepy"], + "No time": ["no time", "busy", "ran out of time", "swamped"], + "Not feeling it": ["not feeling", "no motivation", "cant be", "cannot be"], + "Unwell": ["sick", "ill", "headache", "pain", "sore", "injured"], + "Interrupted": ["interrupted", "came up", "emergency", "had to"], + "Forgot": ["forgot", "slipped my mind", "missed it"], + "Weather": ["rain", "cold", "hot", "weather"], + "Travel": ["travel", "away", "trip", "commute", "traffic"], + }; + + static const String unclustered = "Other"; + + /// The family this excuse belongs to. + static String classify(String excuse) { + final String text = excuse.toLowerCase(); + + for (MapEntry> family in families.entries) { + for (String keyword in family.value) { + if (text.contains(keyword)) { + return family.key; + } + } + } + + return unclustered; + } + + /// Build the taxonomy from the event log. Only deferrals and misses carry + /// excuses worth clustering. + static List cluster(List events) { + final Map clusters = {}; + final Map> categoryCounts = + >{}; + + for (CommitmentEvent event in events) { + if (event.event != EventType.DEFERRED && + event.event != EventType.MISSED && + event.event != EventType.ABANDONED) { + continue; + } + + if (event.excuseText.isEmpty) { + continue; + } + + final String label = classify(event.excuseText); + + final ExcuseCluster cluster = + clusters.putIfAbsent(label, () => ExcuseCluster(label: label)); + + cluster.occurrences = cluster.occurrences + 1; + + if (event.at != null) { + final int weekday = event.at!.weekday; + final int hour = event.at!.hour; + cluster.byWeekday[weekday] = (cluster.byWeekday[weekday] ?? 0) + 1; + cluster.byHour[hour] = (cluster.byHour[hour] ?? 0) + 1; + } + + if (event.excuseClusterId != null && + event.excuseClusterId!.isNotEmpty) { + final Map counts = + categoryCounts.putIfAbsent(label, () => {}); + counts[event.excuseClusterId!] = + (counts[event.excuseClusterId!] ?? 0) + 1; + } + } + + final List result = clusters.values.toList(); + + for (ExcuseCluster cluster in result) { + cluster.dominantCategory = _dominant(categoryCounts[cluster.label]); + cluster.insight = describe(cluster); + } + + result.sort((a, b) => b.occurrences.compareTo(a.occurrences)); + + return result; + } + + /// The confrontation copy. Only the concentrations that are actually + /// meaningful get mentioned — a flat distribution says nothing. + static String describe(ExcuseCluster cluster) { + if (cluster.occurrences < 3) { + return ""; + } + + final StringBuffer buffer = StringBuffer(); + + buffer.write( + "'${cluster.label}' has appeared ${cluster.occurrences} times"); + + final MapEntry? weekday = _peak(cluster.byWeekday); + if (weekday != null && weekday.value >= (cluster.occurrences * 0.4)) { + buffer.write(", ${weekday.value} of them on ${_weekdayName(weekday.key)}s"); + } + + final MapEntry? hour = _peak(cluster.byHour); + if (hour != null && hour.value >= (cluster.occurrences * 0.35)) { + buffer.write(", ${hour.value} of them after ${_hourLabel(hour.key)}"); + } + + buffer.write("."); + + final String suggestion = _suggest(cluster, weekday, hour); + if (suggestion.isNotEmpty) { + buffer.write(" $suggestion"); + } + + return buffer.toString(); + } + + static String _suggest( + ExcuseCluster cluster, + MapEntry? weekday, + MapEntry? hour, + ) { + if (hour == null) { + return ""; + } + + if (hour.key >= 18 && cluster.label == "Too tired") { + return "Consider moving this to the morning."; + } + + if (hour.key >= 18) { + return "Evenings are not working for this. Try scheduling it earlier."; + } + + if (weekday != null) { + return "${_weekdayName(weekday.key)} is where this keeps failing."; + } + + return ""; + } + + static MapEntry? _peak(Map histogram) { + if (histogram.isEmpty) { + return null; + } + + MapEntry? peak; + for (MapEntry entry in histogram.entries) { + if (peak == null || entry.value > peak.value) { + peak = entry; + } + } + return peak; + } + + static String _dominant(Map? counts) { + if (counts == null || counts.isEmpty) { + return ""; + } + + String label = ""; + int best = 0; + + for (MapEntry entry in counts.entries) { + if (entry.value > best) { + best = entry.value; + label = entry.key; + } + } + + return label; + } + + static String _weekdayName(int weekday) { + const List names = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + if (weekday < 1 || weekday > 7) { + return ""; + } + return names[weekday - 1]; + } + + static String _hourLabel(int hour) { + if (hour == 0) { + return "midnight"; + } + if (hour < 12) { + return "${hour}am"; + } + if (hour == 12) { + return "noon"; + } + return "${hour - 12}pm"; + } +} diff --git a/frontend/lib/Grounded/utils/GuardrailEngine.dart b/frontend/lib/Grounded/utils/GuardrailEngine.dart new file mode 100644 index 0000000..e40e8d6 --- /dev/null +++ b/frontend/lib/Grounded/utils/GuardrailEngine.dart @@ -0,0 +1,44 @@ +/// The counterweights. An app built on guilt has an obvious failure mode: the +/// people who need it most delete it during their worst week. These are not +/// nice-to-haves — they are the retention strategy. +class GuardrailEngine { + /// Debt increase over the window that counts as a spike. + static const double debtSpikeDelta = 15; + + /// App opens per week below which engagement counts as dropped. + static const int engagementFloor = 3; + + /// Readiness score below which inputs count as degraded. + static const int readinessFloor = 4; + + /// Distress is the conjunction, not any single signal: debt spiking *and* + /// engagement dropping *and* readiness degrading. Strictness must never be + /// the response to someone who is actually struggling. + static bool detectDistress({ + required double debtDelta, + required int appOpensThisWeek, + required int meanReadiness, + }) { + final bool debtSpiking = debtDelta >= debtSpikeDelta; + final bool disengaging = appOpensThisWeek <= engagementFloor; + final bool degrading = meanReadiness > 0 && meanReadiness <= readinessFloor; + + return debtSpiking && disengaging && degrading; + } + + /// How many amnesty tokens remain this month. Rationed so they feel + /// valuable, but they exist so a bad flu does not destroy three months of + /// progress. + static int tokensRemaining(int granted, int spent) { + final int remaining = granted - spent; + return remaining > 0 ? remaining : 0; + } + + static bool canSpendAmnesty(int granted, int spent) { + return tokensRemaining(granted, spent) > 0; + } + + /// When distressed, the plan is cut to this many non-negotiables and nothing + /// else is asked for. + static const int distressPlanSize = 3; +} diff --git a/frontend/lib/Grounded/utils/Images.dart b/frontend/lib/Grounded/utils/Images.dart new file mode 100644 index 0000000..1262b80 --- /dev/null +++ b/frontend/lib/Grounded/utils/Images.dart @@ -0,0 +1,12 @@ +const String imagePath = "assets/images"; + +const String iconPath = "assets/icons"; + +/// The anchor mark, matching the native splash so the handover is invisible. +const String splashMark = "$iconPath/splash.png"; + +const String logoMark = "$iconPath/icon.png"; + +const String loadingBg = "$imagePath/loading.jpg"; + +const String emptyQueue = "$imagePath/empty_queue.png"; diff --git a/frontend/lib/Grounded/utils/IntegrityEngine.dart b/frontend/lib/Grounded/utils/IntegrityEngine.dart new file mode 100644 index 0000000..5031daa --- /dev/null +++ b/frontend/lib/Grounded/utils/IntegrityEngine.dart @@ -0,0 +1,191 @@ +import '../about/external/data/ExercisePrescription.dart'; +import '../about/external/data/SessionLog.dart'; +import '../about/external/data/SetLog.dart'; + +/// Did you do the session, or a watered-down version of it? +/// +/// ``` +/// integrity = 0.5 x (prescribed_sets_done / prescribed_sets) +/// + 0.3 x (1 - mean |actual_reps - target| / target) +/// + 0.2 x (hard_exercises_done / hard_exercises_prescribed) +/// ``` +/// +/// Skipping the hard exercise while doing the easy ones is the most common +/// form of self-deception in training, and the third term is what detects it. +class IntegrityEngine { + static const double setsWeight = 0.5; + static const double repsWeight = 0.3; + static const double hardWeight = 0.2; + + static double score( + SessionLog session, + List prescribed, + ) { + if (prescribed.isEmpty) { + return 0; + } + + return (setsWeight * _setCompletion(session, prescribed)) + + (repsWeight * _repAccuracy(session, prescribed)) + + (hardWeight * _hardCompletion(session, prescribed)); + } + + /// Prescribed sets actually done, capped at 1 so overshooting one lift does + /// not paper over skipping another. + static double _setCompletion( + SessionLog session, + List prescribed, + ) { + int prescribedSets = 0; + int doneSets = 0; + + for (ExercisePrescription item in prescribed) { + prescribedSets = prescribedSets + item.sets; + + final int done = session.sets + .where((entry) => + entry.exerciseId == item.exerciseId && entry.isPrescribed) + .length; + + doneSets = doneSets + (done > item.sets ? item.sets : done); + } + + if (prescribedSets == 0) { + return 0; + } + + return doneSets / prescribedSets; + } + + /// How close the reps landed to target, averaged across prescribed sets. + static double _repAccuracy( + SessionLog session, + List prescribed, + ) { + double totalError = 0; + int counted = 0; + + for (ExercisePrescription item in prescribed) { + if (item.targetReps <= 0) { + continue; + } + + final List done = session.sets + .where((entry) => + entry.exerciseId == item.exerciseId && entry.isPrescribed) + .toList(); + + for (SetLog entry in done) { + final double error = + (entry.reps - item.targetReps).abs() / item.targetReps; + totalError = totalError + (error > 1 ? 1 : error); + counted = counted + 1; + } + } + + if (counted == 0) { + return 0; + } + + return 1 - (totalError / counted); + } + + /// The term that catches cherry-picking. "Hard" comes from your historical + /// RPE on that exercise, not a static label. + static double _hardCompletion( + SessionLog session, + List prescribed, + ) { + final List hard = + prescribed.where((item) => item.hard).toList(); + + if (hard.isEmpty) { + return 1; + } + + int done = 0; + + for (ExercisePrescription item in hard) { + final bool touched = session.sets.any((entry) => + entry.exerciseId == item.exerciseId && entry.isPrescribed); + if (touched) { + done = done + 1; + } + } + + return done / hard.length; + } + + /// Whether an exercise counts as hard for this user, derived from historical + /// RPE rather than asserted up front. + static bool isHard(List historicalRpe, {double cutoff = 8.0}) { + if (historicalRpe.isEmpty) { + return false; + } + + double total = 0; + for (double value in historicalRpe) { + total = total + value; + } + + return (total / historicalRpe.length) >= cutoff; + } + + /// Weekly volume per muscle group, for under-target warnings — "you went to + /// the gym" is not a metric. + static Map weeklySetsByMuscleGroup(List sessions) { + final Map volume = {}; + + for (SessionLog session in sessions) { + for (SetLog entry in session.sets) { + if (entry.muscleGroup.isEmpty) { + continue; + } + volume[entry.muscleGroup] = (volume[entry.muscleGroup] ?? 0) + 1; + } + } + + return volume; + } + + /// Total plyometric ground contacts across the week. + static int weeklyContacts(List sessions) { + int total = 0; + for (SessionLog session in sessions) { + total = total + session.contacts; + } + return total; + } + + /// Plyo is the one modality where the app stops you rather than pushes you. + /// CNS and connective tissue do not recover on a motivation schedule. + static bool contactCeilingBreached(List sessions, int ceiling) { + if (ceiling <= 0) { + return false; + } + return weeklyContacts(sessions) >= ceiling; + } + + /// Whether enough recovery has passed since the last high-intensity lower + /// body session. + static bool recoveredEnough( + DateTime? lastHighIntensityLowerBody, + int requiredHours, { + DateTime? now, + }) { + if (lastHighIntensityLowerBody == null) { + return true; + } + final DateTime moment = now ?? DateTime.now(); + return moment.difference(lastHighIntensityLowerBody).inHours >= + requiredHours; + } + + /// Training through a deload logs as non-compliance, the same as skipping. + static bool violatesDeload(SessionLog session, bool isDeloadWeek) { + if (!isDeloadWeek) { + return false; + } + return session.sets.any((entry) => entry.rpe >= 8); + } +} diff --git a/frontend/lib/Grounded/utils/ObjectConvertors.dart b/frontend/lib/Grounded/utils/ObjectConvertors.dart new file mode 100644 index 0000000..09024aa --- /dev/null +++ b/frontend/lib/Grounded/utils/ObjectConvertors.dart @@ -0,0 +1,116 @@ +import '../about/external/data/Commitment.dart'; +import '../about/external/data/CommitmentEvent.dart'; +import '../about/external/data/DebtEntry.dart'; +import '../about/external/data/ExcuseCluster.dart'; +import '../about/external/data/ExercisePrescription.dart'; +import '../about/external/data/Goal.dart'; +import '../about/external/data/Habit.dart'; +import '../about/external/data/Program.dart'; +import '../about/external/data/RoutineChain.dart'; +import '../about/external/data/SessionLog.dart'; +import '../about/external/data/SessionTemplate.dart'; +import '../about/external/data/SetLog.dart'; +import '../about/external/data/StandingChange.dart'; + +/// List parsing lives here. ViewModels never inline +/// `.map((e) => T.fromJson(e)).toList()` — add a convertor if one is missing. + +List getCommitmentList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => Commitment.fromJson(item)).toList(); +} + +List getGoalList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => Goal.fromJson(item)).toList(); +} + +List getCommitmentEventList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => CommitmentEvent.fromJson(item)).toList(); +} + +List getDebtEntryList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => DebtEntry.fromJson(item)).toList(); +} + +List getStandingChangeList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => StandingChange.fromJson(item)).toList(); +} + +List getExcuseClusterList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => ExcuseCluster.fromJson(item)).toList(); +} + +List getHabitList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => Habit.fromJson(item)).toList(); +} + +List getRoutineChainList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => RoutineChain.fromJson(item)).toList(); +} + +List getProgramList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => Program.fromJson(item)).toList(); +} + +List getSessionTemplateList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => SessionTemplate.fromJson(item)).toList(); +} + +List getPrescriptionList(dynamic data) { + if (data == null) { + return []; + } + return (data as List) + .map((item) => ExercisePrescription.fromJson(item)) + .toList(); +} + +List getSessionLogList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => SessionLog.fromJson(item)).toList(); +} + +List getSetLogList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => SetLog.fromJson(item)).toList(); +} + +List getStringList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => item.toString()).toList(); +} diff --git a/frontend/lib/Grounded/utils/StandingEngine.dart b/frontend/lib/Grounded/utils/StandingEngine.dart new file mode 100644 index 0000000..ad3528f --- /dev/null +++ b/frontend/lib/Grounded/utils/StandingEngine.dart @@ -0,0 +1,91 @@ +import '../about/external/data/Commitment.dart'; +import '../about/internal/application/Standing.dart'; +import 'DebtEngine.dart'; +import 'Thresholds.dart'; + +/// Standing is computed continuously and drives real consequences. It is +/// derived, never set — there is no way to talk your way up a tier. +class StandingEngine { + /// The standing implied by the current debt and the missed-non-negotiable + /// count. Sick mode never escalates: strictness must never be the response + /// to someone who is actually struggling. + static Standing evaluate( + double debtScore, { + int missedNonNegotiables = 0, + bool sickMode = false, + bool distressed = false, + }) { + if (sickMode || distressed) { + return Standing.Good; + } + + if (debtScore >= Thresholds.lockdownThreshold) { + return Standing.Lockdown; + } + + if (debtScore >= Thresholds.groundedThreshold || + missedNonNegotiables >= Thresholds.nonNegotiableMissesForGrounded) { + return Standing.Grounded; + } + + if (debtScore >= Thresholds.warnedThreshold) { + return Standing.Warned; + } + + return Standing.Good; + } + + /// Evaluate straight from the commitment list. + static Standing evaluateFor( + List commitments, { + bool sickMode = false, + bool distressed = false, + DateTime? now, + }) { + return evaluate( + DebtEngine.totalDebt(commitments, now: now), + missedNonNegotiables: DebtEngine.missedNonNegotiables(commitments), + sickMode: sickMode, + distressed: distressed, + ); + } + + /// Whether a new commitment of this class may be created right now. + static bool permitsNewCommitment(Standing standing, {bool elective = false}) { + if (elective) { + return canAddElectives(standing); + } + return canAddCommitments(standing); + } + + /// Grounded replaces the home screen with the overdue queue — you do not get + /// to look at your nice plans, only at your mess. + static bool showsOverdueQueueAsHome(Standing standing) { + return standing == Standing.Grounded || standing == Standing.Lockdown; + } + + /// Lockdown puts a blocking interstitial in front of the app that must be + /// cleared one item at a time. + static bool blocksAppOnOpen(Standing standing) { + return standing == Standing.Lockdown; + } + + /// Whether the accountability partner is auto-notified at this tier. + static bool notifiesPartner(Standing standing) { + return standing == Standing.Grounded || standing == Standing.Lockdown; + } + + /// Debt still to shed before dropping a tier. Zero when already at Good. + static double debtToNextTierDown(double debtScore, Standing standing) { + switch (standing) { + case Standing.Lockdown: + return debtScore - Thresholds.lockdownThreshold; + case Standing.Grounded: + return debtScore - Thresholds.groundedThreshold; + case Standing.Warned: + return debtScore - Thresholds.warnedThreshold; + case Standing.Good: + return 0; + } + } +} diff --git a/frontend/lib/Grounded/utils/Thresholds.dart b/frontend/lib/Grounded/utils/Thresholds.dart new file mode 100644 index 0000000..68d6744 --- /dev/null +++ b/frontend/lib/Grounded/utils/Thresholds.dart @@ -0,0 +1,39 @@ +/// Configurable limits with sane defaults. These are the dials the user is +/// allowed to turn — everything else about enforcement is fixed. +class Thresholds { + /// Hard cap on unresolved overdue items. + static const int maxOpenOverdue = 5; + + /// After this, a task cannot be deferred again — only completed or + /// explicitly abandoned. + static const int maxDeferralsPerTask = 2; + + /// Minimum excuse length. Free text, no template buttons; the friction is + /// the point. + static const int minExcuseLength = 15; + + /// Standing thresholds on the debt score. + static const double warnedThreshold = 12; + static const double groundedThreshold = 30; + static const double lockdownThreshold = 60; + + /// Missed non-negotiables that force Grounded regardless of score. + static const int nonNegotiableMissesForGrounded = 3; + + /// Amnesty tokens granted per month. + static const int amnestyTokensPerMonth = 3; + + /// Capacity check headroom — plan against 85% of what you historically get + /// done, not 100%. + static const double capacityHeadroom = 0.85; + + /// Days after which an untouched elective auto-archives. + static const int electiveArchiveDays = 21; + + /// Snooze tax: each snooze shortens the next interval instead of extending + /// it, and the third becomes a full-screen alarm. + static const List snoozeLadderMinutes = [10, 5, 2]; + + /// Minutes a push can go unacknowledged before SMS fallback fires. + static const int smsFallbackMinutes = 20; +} diff --git a/frontend/lib/Grounded/utils/ToneEngine.dart b/frontend/lib/Grounded/utils/ToneEngine.dart new file mode 100644 index 0000000..33df8ed --- /dev/null +++ b/frontend/lib/Grounded/utils/ToneEngine.dart @@ -0,0 +1,117 @@ +import '../about/internal/application/EscalationTier.dart'; +import '../about/internal/application/Standing.dart'; +import '../about/internal/application/ToneLevel.dart'; + +/// All user-facing enforcement copy comes from here, so the hard cap is +/// enforceable in one place: language may criticise behaviour, never the +/// person. Nothing that mocks the user's worth ships. +class ToneEngine { + /// The escalation ladder. Disappointment is deliberately placed above anger + /// because it works better as a lever. + static EscalationTier tierFor(int unacknowledgedCount) { + if (unacknowledgedCount <= 0) { + return EscalationTier.Reminder; + } + if (unacknowledgedCount == 1) { + return EscalationTier.Nudge; + } + if (unacknowledgedCount == 2) { + return EscalationTier.Nag; + } + if (unacknowledgedCount == 3) { + return EscalationTier.Disappointed; + } + return EscalationTier.Cold; + } + + /// Notification body for a commitment at a given tier and tone. + static String nudge( + EscalationTier tier, + ToneLevel tone, + String title, + ) { + switch (tier) { + case EscalationTier.Reminder: + return "$title is due now."; + case EscalationTier.Nudge: + return "$title is still sitting there."; + case EscalationTier.Nag: + return tone == ToneLevel.Firm + ? "$title is overdue. It needs a decision." + : "Third time: $title. Do it or abandon it."; + case EscalationTier.Disappointed: + return "You said you would do $title. You have not."; + case EscalationTier.Cold: + return "$title. No more reminders about this one."; + } + } + + /// The header copy on app open, which shifts with standing. + static String standingHeadline(Standing standing, ToneLevel tone) { + switch (standing) { + case Standing.Good: + return "You are in good standing."; + case Standing.Warned: + return "You are slipping."; + case Standing.Grounded: + return tone == ToneLevel.Firm + ? "You are grounded until this is cleared." + : "Grounded. Nothing new until the queue is empty."; + case Standing.Lockdown: + return "Lockdown. One item at a time."; + } + } + + static String standingBody(Standing standing, ToneLevel tone) { + switch (standing) { + case Standing.Good: + return "Keep the plan honest and it stays this way."; + case Standing.Warned: + return "New electives are blocked. Clear some debt before adding more."; + case Standing.Grounded: + return "Your plans are hidden. This is what is actually outstanding."; + case Standing.Lockdown: + return "Complete or abandon each item below. There is no third option."; + } + } + + /// Praise is rationed but real. A parent who only criticises gets tuned out — + /// this returns empty unless something specific was genuinely earned. + static String praise(String specificAchievement) { + if (specificAchievement.isEmpty) { + return ""; + } + return specificAchievement; + } + + /// The register the app switches to when distress is detected. The strict + /// persona drops entirely — this is the difference between a product people + /// keep and one they resent. + static String distressHeadline() { + return "Let us cut this back."; + } + + static String distressBody() { + return "Something has clearly been hard lately. Pick three things that " + "genuinely matter this week and let the rest go. Nothing here is " + "counting against you right now."; + } + + /// Copy shown when a deferral is refused because the cap is spent. + static String deferralRefused(int maxDeferrals) { + return "This has been deferred $maxDeferrals times. It can now only be " + "completed or abandoned."; + } + + /// Copy shown when a non-negotiable deferral is attempted. + static String nonNegotiableRefused() { + return "Non-negotiables are not deferrable. That is what makes them " + "non-negotiable."; + } + + /// Copy shown when the excuse is too short. + static String excuseTooShort(int minimum) { + return "Write at least $minimum characters. If it is not worth explaining, " + "it is not worth deferring."; + } +} diff --git a/frontend/lib/Grounded/utils/Validators.dart b/frontend/lib/Grounded/utils/Validators.dart new file mode 100644 index 0000000..204331e --- /dev/null +++ b/frontend/lib/Grounded/utils/Validators.dart @@ -0,0 +1,80 @@ +import 'Thresholds.dart'; + +/// Field-level validation. Returns null when valid, so it drops straight into +/// a TextFormField validator. +class Validators { + static String? required(String? value, String field) { + if (value == null || value.trim().isEmpty) { + return "$field is required."; + } + return null; + } + + static String? title(String? value) { + final String? empty = required(value, "Title"); + if (empty != null) { + return empty; + } + if (value!.trim().length < 3) { + return "Give it a name you will recognise later."; + } + return null; + } + + /// The excuse gate. Free text only, minimum length, no template buttons — + /// the friction is the point. + static String? excuse(String? value) { + if (value == null || value.trim().isEmpty) { + return "An excuse is required to defer."; + } + if (value.trim().length < Thresholds.minExcuseLength) { + return "Write at least ${Thresholds.minExcuseLength} characters."; + } + return null; + } + + static String? estimateMinutes(String? value) { + final String? empty = required(value, "Estimate"); + if (empty != null) { + return empty; + } + final int? minutes = int.tryParse(value!.trim()); + if (minutes == null || minutes <= 0) { + return "Enter the minutes you think it will take."; + } + if (minutes > 720) { + return "Nothing on a daily plan takes more than 12 hours. Split it."; + } + return null; + } + + /// A window, not a date. The end must actually close after the start. + static String? window(DateTime? start, DateTime? end) { + if (start == null || end == null) { + return "Set a due window, not just a day."; + } + if (!end.isAfter(start)) { + return "The window has to close after it opens."; + } + return null; + } + + static String? username(String? value) { + final String? empty = required(value, "Username"); + if (empty != null) { + return empty; + } + return null; + } + + static String? password(String? value) { + final String? empty = required(value, "Password"); + if (empty != null) { + return empty; + } + if (value!.length < 8) { + return "Passwords are at least 8 characters."; + } + return null; + } +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart new file mode 100644 index 0000000..4d98518 --- /dev/null +++ b/frontend/lib/main.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +import 'Grounded/about/internal/application/TextType.dart'; +import 'Grounded/configs/NotificationServiceConfig.dart'; +import 'Grounded/designs/Component.dart'; +import 'Grounded/see/splash/Splash.dart'; +import 'Grounded/utils/Colors.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await dotenv.load(fileName: ".env"); + + // Channels are created up front so a full-screen alarm can fire the first + // time a non-negotiable window closes, not the second. + await LocalNotificationEngine.init(); + + runApp(const GroundedApp()); +} + +class GroundedApp extends StatelessWidget { + const GroundedApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Grounded', + debugShowCheckedModeBanner: false, + theme: ThemeData( + disabledColor: colorGrey, + scaffoldBackgroundColor: colorPrimaryLight, + dividerColor: colorDivider, + colorScheme: ColorScheme.fromSwatch( + primarySwatch: createMaterialColor(colorPrimaryDark), + ).copyWith(error: colorNegative), + dialogTheme: DialogThemeData( + backgroundColor: colorWhite, + elevation: 0, + contentTextStyle: TextStyle( + color: colorGrey2, + fontFamily: getTextType(TextType.Regular), + fontSize: 13, + ), + titleTextStyle: TextStyle( + color: colorPrimaryDark, + fontFamily: getTextType(TextType.Bold), + fontSize: 17, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + home: const Splash(), + ); + } +} diff --git a/frontend/mason-lock.json b/frontend/mason-lock.json new file mode 100644 index 0000000..74c306e --- /dev/null +++ b/frontend/mason-lock.json @@ -0,0 +1 @@ +{"bricks":{"api_endpoint":{"path":"C:/Users/Alpha/Documents/Grounded/frontend/bricks/api_endpoint"},"internal_memory":{"path":"C:/Users/Alpha/Documents/Grounded/frontend/bricks/internal_memory"},"mvvc_template":{"path":"C:/Users/Alpha/Documents/Grounded/frontend/bricks/mvvc_template"}}} \ No newline at end of file diff --git a/frontend/mason.yaml b/frontend/mason.yaml new file mode 100644 index 0000000..1f9cc7e --- /dev/null +++ b/frontend/mason.yaml @@ -0,0 +1,7 @@ +bricks: + mvvc_template: + path: bricks/mvvc_template + api_endpoint: + path: bricks/api_endpoint + internal_memory: + path: bricks/internal_memory \ No newline at end of file diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock new file mode 100644 index 0000000..4b43340 --- /dev/null +++ b/frontend/pubspec.lock @@ -0,0 +1,1242 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + calendar_date_picker2: + dependency: "direct main" + description: + name: calendar_date_picker2 + sha256: d6ea697c7fc0eebc64c62ad2ac6232a8c38795010bd8466b687bfebcbbdd872e + url: "https://pub.dev" + source: hosted + version: "3.0.0" + camera: + dependency: "direct main" + description: + name: camera + sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f" + url: "https://pub.dev" + source: hosted + version: "0.12.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "4fb17b62dc25f97ec752596b749a31e80ccc9829ef95412badfdf0bc6c43a166" + url: "https://pub.dev" + source: hosted + version: "0.7.4+2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "866e9cd8370f8055d005c0413937a52dc1d7a472687f0ee3ce02392955aababa" + url: "https://pub.dev" + source: hosted + version: "0.10.2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" + url: "https://pub.dev" + source: hosted + version: "7.3.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + url: "https://pub.dev" + source: hosted + version: "21.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" + url: "https://pub.dev" + source: hosted + version: "2.4.8" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_spinkit: + dependency: "direct main" + description: + name: flutter_spinkit + sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f" + url: "https://pub.dev" + source: hosted + version: "5.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + get_it: + dependency: transitive + description: + name: get_it + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.dev" + source: hosted + version: "8.3.0" + group_button: + dependency: "direct main" + description: + name: group_button + sha256: "0610fcf28ed122bfb4b410fce161a390f7f2531d55d1d65c5375982001415940" + url: "https://pub.dev" + source: hosted + version: "5.3.4" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + local_notifier: + dependency: "direct main" + description: + name: local_notifier + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 + url: "https://pub.dev" + source: hosted + version: "0.1.6" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + modal_bottom_sheet: + dependency: "direct main" + description: + name: modal_bottom_sheet + sha256: eac66ef8cb0461bf069a38c5eb0fa728cee525a531a8304bd3f7b2185407c67e + url: "https://pub.dev" + source: hosted + version: "3.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + page_transition: + dependency: "direct main" + description: + name: page_transition + sha256: "61dac670e80ebdd7da847c21841e1dfd8f9ab483b3a0c5fddc877ad8d23b69ee" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" + url: "https://pub.dev" + source: hosted + version: "9.4.10" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + pin_code_fields: + dependency: "direct main" + description: + name: pin_code_fields + sha256: "0ae83c636a3c1ae00bc09bb496bc554ea290b01dfe396e3c77e12d3a3f9207e9" + url: "https://pub.dev" + source: hosted + version: "9.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: "4b497e9898d095de40d246db943371183fa7482492a88391cfa8415ef94d57ba" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stacked: + dependency: "direct main" + description: + name: stacked + sha256: "5f4a6ba6cfa43c5854690de0f946eef1694250cf46ecf7859519d90bf764b1e4" + url: "https://pub.dev" + source: hosted + version: "3.5.0" + stacked_shared: + dependency: transitive + description: + name: stacked_shared + sha256: "3d69b34d87422b78a7e5123681d3f4bcdd79757170454933f68795c54812d003" + url: "https://pub.dev" + source: hosted + version: "1.4.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: c6c25eb7acadfd231925a60004736443cbf63f1eb5740d6dd83a1bc09cf00f6d + url: "https://pub.dev" + source: hosted + version: "3.2.0" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml new file mode 100644 index 0000000..ae103ed --- /dev/null +++ b/frontend/pubspec.yaml @@ -0,0 +1,132 @@ +name: Grounded +description: Grounded — a to-do app that doesn't believe you. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.6.2 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.2 + + # ── State management (MVVM) ───────────────────────────────────────────── + stacked: ^3.5.0 + + # ── HTTP / env ────────────────────────────────────────────────────────── + dio: ^5.7.0 + flutter_dotenv: ^6.0.1 + + # ── Secure storage ────────────────────────────────────────────────────── + flutter_secure_storage: ^10.3.1 + shared_preferences: ^2.3.2 + + # ── Navigation ────────────────────────────────────────────────────────── + page_transition: ^2.0.5 + + # ── Connectivity / device ─────────────────────────────────────────────── + connectivity_plus: ^7.1.1 + device_info_plus: ^12.4.0 + permission_handler: ^12.0.3 + path_provider: ^2.0.11 + uuid: ^4.5.1 + + # ── Proof capture (photo / timer / location) ──────────────────────────── + camera: ^0.12.0+1 + file_picker: ^11.0.2 + image: ^4.2.0 + geolocator: ^14.0.2 + + # ── Report card / charts ──────────────────────────────────────────────── + fl_chart: ^1.2.0 + + # ── Notifications ─────────────────────────────────────────────────────── + flutter_local_notifications: ^21.0.0 + local_notifier: ^0.1.6 + + # ── UI ────────────────────────────────────────────────────────────────── + flutter_spinkit: ^5.1.0 + modal_bottom_sheet: ^3.0.0-pre + smooth_page_indicator: ^2.0.1 + group_button: ^5.3.4 + pin_code_fields: ^9.3.0 + calendar_date_picker2: ^3.0.0 + photo_view: ^0.15.0 + flutter_animate: ^4.5.0 + vibration: ^3.1.2 + url_launcher: ^6.1.11 + intl: ^0.20.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + + flutter_launcher_icons: ^0.14.4 + + flutter_native_splash: ^2.4.6 + +flutter: + + uses-material-design: true + + assets: + - assets/ + - assets/icons/ + - assets/images/ + - .env + + # General Sans (Fontshare, FFL) — see fonts/LICENSE-GeneralSans.txt + fonts: + + - family: GroundedBold + fonts: + - asset: fonts/bold.ttf + + - family: GroundedLight + fonts: + - asset: fonts/light.ttf + + - family: GroundedRegular + fonts: + - asset: fonts/regular.ttf + + - family: GroundedMedium + fonts: + - asset: fonts/medium.ttf + +# ── Launcher icon ───────────────────────────────────────────────────────────── +flutter_launcher_icons: + image_path: "assets/icons/icon.png" + android: "ic_launcher" + adaptive_icon_background: "#141414" + adaptive_icon_foreground: "assets/icons/icon_foreground.png" + min_sdk_android: 21 + ios: true + remove_alpha_ios: true + web: + generate: true + background_color: "#141414" + theme_color: "#141414" + windows: + generate: true + icon_size: 256 + +# ── Native splash ───────────────────────────────────────────────────────────── +# The native splash matches the Flutter splash exactly, so the handover from +# the OS screen to the app is invisible. +flutter_native_splash: + color: "#141414" + image: "assets/icons/splash.png" + android_12: + color: "#141414" + image: "assets/icons/icon_foreground.png" + icon_background_color: "#141414" + android: true + ios: true + web: true diff --git a/frontend/web/favicon.png b/frontend/web/favicon.png new file mode 100644 index 0000000..3991827 Binary files /dev/null and b/frontend/web/favicon.png differ diff --git a/frontend/web/icons/Icon-192.png b/frontend/web/icons/Icon-192.png new file mode 100644 index 0000000..0189403 Binary files /dev/null and b/frontend/web/icons/Icon-192.png differ diff --git a/frontend/web/icons/Icon-512.png b/frontend/web/icons/Icon-512.png new file mode 100644 index 0000000..8385253 Binary files /dev/null and b/frontend/web/icons/Icon-512.png differ diff --git a/frontend/web/icons/Icon-maskable-192.png b/frontend/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..0189403 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-192.png differ diff --git a/frontend/web/icons/Icon-maskable-512.png b/frontend/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..8385253 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-512.png differ diff --git a/frontend/web/index.html b/frontend/web/index.html new file mode 100644 index 0000000..4e6d931 --- /dev/null +++ b/frontend/web/index.html @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + grounded + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/web/manifest.json b/frontend/web/manifest.json new file mode 100644 index 0000000..56eb30b --- /dev/null +++ b/frontend/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "grounded", + "short_name": "grounded", + "start_url": ".", + "display": "standalone", + "background_color": "#141414", + "theme_color": "#141414", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/frontend/web/splash/img/dark-1x.png b/frontend/web/splash/img/dark-1x.png new file mode 100644 index 0000000..92ef668 Binary files /dev/null and b/frontend/web/splash/img/dark-1x.png differ diff --git a/frontend/web/splash/img/dark-2x.png b/frontend/web/splash/img/dark-2x.png new file mode 100644 index 0000000..ee90019 Binary files /dev/null and b/frontend/web/splash/img/dark-2x.png differ diff --git a/frontend/web/splash/img/dark-3x.png b/frontend/web/splash/img/dark-3x.png new file mode 100644 index 0000000..0c41fdb Binary files /dev/null and b/frontend/web/splash/img/dark-3x.png differ diff --git a/frontend/web/splash/img/dark-4x.png b/frontend/web/splash/img/dark-4x.png new file mode 100644 index 0000000..82f7f01 Binary files /dev/null and b/frontend/web/splash/img/dark-4x.png differ diff --git a/frontend/web/splash/img/light-1x.png b/frontend/web/splash/img/light-1x.png new file mode 100644 index 0000000..92ef668 Binary files /dev/null and b/frontend/web/splash/img/light-1x.png differ diff --git a/frontend/web/splash/img/light-2x.png b/frontend/web/splash/img/light-2x.png new file mode 100644 index 0000000..ee90019 Binary files /dev/null and b/frontend/web/splash/img/light-2x.png differ diff --git a/frontend/web/splash/img/light-3x.png b/frontend/web/splash/img/light-3x.png new file mode 100644 index 0000000..0c41fdb Binary files /dev/null and b/frontend/web/splash/img/light-3x.png differ diff --git a/frontend/web/splash/img/light-4x.png b/frontend/web/splash/img/light-4x.png new file mode 100644 index 0000000..82f7f01 Binary files /dev/null and b/frontend/web/splash/img/light-4x.png differ diff --git a/frontend/windows/.gitignore b/frontend/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/frontend/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/frontend/windows/CMakeLists.txt b/frontend/windows/CMakeLists.txt new file mode 100644 index 0000000..e54b60d --- /dev/null +++ b/frontend/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(grounded LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "grounded") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/frontend/windows/flutter/CMakeLists.txt b/frontend/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/frontend/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/frontend/windows/runner/CMakeLists.txt b/frontend/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/frontend/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/windows/runner/Runner.rc b/frontend/windows/runner/Runner.rc new file mode 100644 index 0000000..feafc75 --- /dev/null +++ b/frontend/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "nya" "\0" + VALUE "FileDescription", "grounded" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "grounded" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 nya. All rights reserved." "\0" + VALUE "OriginalFilename", "grounded.exe" "\0" + VALUE "ProductName", "grounded" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/frontend/windows/runner/flutter_window.cpp b/frontend/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/frontend/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/frontend/windows/runner/flutter_window.h b/frontend/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/frontend/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/frontend/windows/runner/main.cpp b/frontend/windows/runner/main.cpp new file mode 100644 index 0000000..7a71383 --- /dev/null +++ b/frontend/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"grounded", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/frontend/windows/runner/resource.h b/frontend/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/frontend/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/frontend/windows/runner/resources/app_icon.ico b/frontend/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..e1f2a15 Binary files /dev/null and b/frontend/windows/runner/resources/app_icon.ico differ diff --git a/frontend/windows/runner/runner.exe.manifest b/frontend/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/frontend/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/frontend/windows/runner/utils.cpp b/frontend/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/frontend/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/frontend/windows/runner/utils.h b/frontend/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/frontend/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/frontend/windows/runner/win32_window.cpp b/frontend/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/frontend/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/frontend/windows/runner/win32_window.h b/frontend/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/frontend/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_