Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c31f1904 | |||
| 9f1d2317af | |||
| c3d9cc273f | |||
| 63e01389e8 | |||
| 5ca5856f15 | |||
| a337c3fda3 | |||
| fc1c7cade2 | |||
| 0971433c7a | |||
| 844f68d376 | |||
| cab8d7104f | |||
| baba5c3462 | |||
| 97c049e453 | |||
| a19af3388d | |||
| bae6597ec2 | |||
| bc48a49de8 | |||
| a687e3637f | |||
| c8becd6afd | |||
| e63370bf0a | |||
| f39b8ddb30 | |||
| def7519feb | |||
| e86d1a59af | |||
| c8f3861eb5 | |||
| a2e734c498 | |||
| 3a336ddf88 | |||
| 3a8cc0db23 | |||
| fa1b65484c | |||
| 4b6fca39a8 | |||
| 8a31034621 | |||
| 6232c7c99a | |||
| 3422caebfc | |||
| 3bd9c64477 | |||
| ef4872e24a | |||
| 0999774f34 | |||
| 46425a4b27 | |||
| 86244cdfbc | |||
| 6abc4257be | |||
| 04b7e1cc8a | |||
| 3c3055d536 | |||
| 868cb0e49e | |||
| fceae5529d | |||
| f30aa8d273 | |||
| abf91874c3 | |||
| 54c2588bd6 | |||
| ccaec61de2 | |||
| 467fa6a553 | |||
| 140f6cf63a | |||
| 4c2b2a0d1a |
@@ -0,0 +1,120 @@
|
|||||||
|
# CI runs only on release tags.
|
||||||
|
#
|
||||||
|
# Tag v*: analyze + test → build APK → attach to Forgejo Release
|
||||||
|
#
|
||||||
|
# To cut a release:
|
||||||
|
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||||
|
# The tag push triggers this workflow; the build job attaches the APK.
|
||||||
|
#
|
||||||
|
# Note: JavaScript actions (actions/checkout, actions/upload-artifact) cannot run
|
||||||
|
# inside the Flutter container because it has no Node.js. All steps use shell
|
||||||
|
# commands directly instead.
|
||||||
|
#
|
||||||
|
# Required secrets (repo → Settings → Secrets → Actions):
|
||||||
|
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||||
|
name: CI & Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze & test
|
||||||
|
runs-on: py3.12-node22
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||||
|
git checkout "$GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Analyze
|
||||||
|
run: flutter analyze
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build release APK
|
||||||
|
needs: [analyze]
|
||||||
|
runs-on: py3.12-node22
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||||
|
git checkout "$GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: |
|
||||||
|
# Derive version from the tag (e.g. v26.03.12 → name=26.03.12 number=260312)
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
BUILD_NAME="${TAG#v}"
|
||||||
|
BUILD_NUMBER=$(echo "$BUILD_NAME" | tr -d '.')
|
||||||
|
flutter build apk --release \
|
||||||
|
--build-name="$BUILD_NAME" \
|
||||||
|
--build-number="$BUILD_NUMBER"
|
||||||
|
|
||||||
|
- name: Set artifact name
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload artifact to Forgejo
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
APK: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
|
||||||
|
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
|
||||||
|
run: |
|
||||||
|
# Upload the APK as a workflow artifact via the Forgejo API.
|
||||||
|
curl -s -X POST "$API/actions/artifacts" \
|
||||||
|
-H "Authorization: token $RELEASE_TOKEN" \
|
||||||
|
-F "name=$ARTIFACT_NAME" \
|
||||||
|
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
|
||||||
|
|
||||||
|
- name: Publish Forgejo release
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
|
||||||
|
run: |
|
||||||
|
# Look for an existing release (created via the UI or a prior run).
|
||||||
|
EXISTING=$(curl -s \
|
||||||
|
"$API/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token $RELEASE_TOKEN")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
|
||||||
|
else
|
||||||
|
echo "No existing release found, creating $TAG..."
|
||||||
|
RESPONSE=$(curl -s -X POST "$API/releases" \
|
||||||
|
-H "Authorization: token $RELEASE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "Failed to create release. API response:"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release created with id $RELEASE_ID."
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "Authorization: token $RELEASE_TOKEN" \
|
||||||
|
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
|
||||||
|
|
||||||
|
echo "Done — $TAG is live at:"
|
||||||
|
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
# Fabled — Android App
|
# Fabled — Android App
|
||||||
|
|
||||||
Native Android client for [FabledAssistant](https://github.com/yourusername/fabledassistant), a self-hosted AI productivity assistant.
|
Native Android client for FabledAssistant, a self-hosted AI second-brain and productivity assistant.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Notes** — create, edit, and browse markdown notes
|
- **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu.
|
||||||
- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority
|
- **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable.
|
||||||
- **Chat** — streaming AI conversations with real-time SSE response display
|
- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
|
||||||
- **Quick Capture** — FAB shortcut to create a note or task from anywhere
|
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
|
||||||
- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server
|
- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
|
||||||
- **Session persistence** — stays logged in across app restarts via a persistent cookie jar
|
- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
|
||||||
- **Home screen widget** — tap to open the chat screen directly from the Android launcher
|
- **Session persistence** — stays logged in across restarts via a persistent cookie jar.
|
||||||
|
- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted)
|
- A running FabledAssistant server (self-hosted)
|
||||||
- Android 5.0+ (API 21)
|
- Android 5.0+ (API 21)
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -38,20 +39,33 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── main.dart # Entry point; resolves async deps before runApp
|
├── main.dart # Entry point; resolves async deps before runApp
|
||||||
├── app.dart # GoRouter + auth redirect guards + shell nav
|
├── app.dart # GoRouter + auth redirect guards + 3-tab shell
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── constants.dart # Route name constants
|
│ ├── constants.dart # Route name constants
|
||||||
│ └── exceptions.dart # AppException hierarchy
|
│ ├── exceptions.dart # AppException hierarchy
|
||||||
|
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── api/ # Dio HTTP layer (one class per resource)
|
│ ├── api/ # Dio HTTP layer (one class per resource)
|
||||||
│ ├── models/ # Plain Dart models with fromJson/toJson
|
│ ├── models/ # Plain Dart models with fromJson/toJson
|
||||||
│ └── repositories/ # Thin wrappers over API classes
|
│ └── repositories/ # Thin wrappers over API classes
|
||||||
└── providers/ # Riverpod providers (state + dependency wiring)
|
├── providers/ # Riverpod providers (state + dependency wiring)
|
||||||
screens/ # Flutter UI screens
|
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
|
||||||
|
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
|
||||||
|
├── screens/
|
||||||
|
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
|
||||||
|
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
|
||||||
|
│ ├── chat/ # ConversationsTabScreen + ChatScreen
|
||||||
|
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
|
||||||
|
│ ├── tasks/ # TaskEditScreen
|
||||||
|
│ └── settings/ auth/ setup/ splash/
|
||||||
|
└── widgets/
|
||||||
|
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
|
||||||
|
├── briefing_digest_card.dart # Expandable first-message card
|
||||||
|
└── library_item_card.dart # Note / task / project row widgets
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `flutter_inappwebview`, `flutter_markdown`
|
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`
|
||||||
|
|
||||||
## Building a Release APK
|
## Building a Release APK
|
||||||
|
|
||||||
@@ -59,4 +73,25 @@ lib/
|
|||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
```
|
```
|
||||||
|
|
||||||
The signed APK will be at `build/app/outputs/flutter-apk/app-release.apk`.
|
The APK will be at `build/app/outputs/flutter-apk/app-release.apk`.
|
||||||
|
|
||||||
|
## CI / CD
|
||||||
|
|
||||||
|
CI and build are defined in a single workflow (`.forgejo/workflows/ci.yml`) running on the shared `py3.12-node22` act runner with the `ghcr.io/cirruslabs/flutter:stable` container.
|
||||||
|
|
||||||
|
| Trigger | Analyze + Test | Build APK | Release |
|
||||||
|
|---------|---------------|-----------|---------|
|
||||||
|
| PR | ✓ | — | — |
|
||||||
|
| Push `dev` | ✓ | ✓ (artifact `fabledapp-dev-<sha>`) | — |
|
||||||
|
| Push `main` | ✓ | — | — |
|
||||||
|
| Tag `v*` | ✓ | ✓ (artifact `fabledapp-<sha>`) | ✓ Forgejo Release + APK attached |
|
||||||
|
|
||||||
|
The build job has `needs: [analyze]` — a failed analyze or test blocks the APK build.
|
||||||
|
|
||||||
|
To cut a release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v26.03.11 && git push origin v26.03.11
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
# ?'key': value applies ? to the key (never null for string literals);
|
||||||
|
# the if (x != null) form is correct for nullable-value map entries.
|
||||||
|
use_null_aware_elements: false
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
+2
-4
@@ -7,8 +7,6 @@ gradle-wrapper.jar
|
|||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Signing secrets — never commit these
|
||||||
# See https://flutter.dev/to/reference-keystore
|
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
fabled-release-key.jks
|
||||||
**/*.jks
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,12 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.fabledapp.fabled_app"
|
namespace = "com.fabledapp.fabled_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -20,21 +29,39 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.fabledapp.fabled_app"
|
applicationId = "com.fabledapp.fabled_app"
|
||||||
// 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
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.all {
|
||||||
|
val variant = this
|
||||||
|
outputs.all {
|
||||||
|
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
|
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="fabled_app"
|
android:label="Fabled"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
@@ -0,0 +1,720 @@
|
|||||||
|
# Fabled App Overhaul — Plan 1: Foundation (Theme + Capture Queue)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Apply the main app's slate-indigo palette and Fraunces typography to the Flutter app, and replace the blocking quick-capture input with a multi-item sequential work queue.
|
||||||
|
|
||||||
|
**Architecture:** A new `lib/core/theme.dart` defines both light and dark `ThemeData` with custom `ColorScheme` and Fraunces headings via `google_fonts`. The capture bar in `app.dart` delegates to a new `CaptureWorkQueueNotifier` (in-memory queue, drains sequentially) instead of blocking on each request. A `_captureResultProvider` carries per-item outcomes to the bar for snackbar display.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter/Dart, Riverpod, google_fonts package.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `lib/core/theme.dart` — light + dark `ThemeData`, `GradientButton` widget
|
||||||
|
- `lib/providers/capture_work_queue_provider.dart` — in-memory work queue notifier + result provider
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `pubspec.yaml` — add `google_fonts: ^6.2.1`
|
||||||
|
- `lib/main.dart` — import and use `fabledTheme` / `fabledDarkTheme`
|
||||||
|
- `lib/app.dart` — update `_QuickCaptureBar` to use work queue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Theme
|
||||||
|
|
||||||
|
### Task 1: Add google_fonts dependency
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pubspec.yaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add dependency**
|
||||||
|
|
||||||
|
In the `dependencies:` section, after `flutter_markdown_plus`, add:
|
||||||
|
```yaml
|
||||||
|
google_fonts: ^6.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it resolves**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exits 0, no version conflicts.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pubspec.yaml pubspec.lock
|
||||||
|
git commit -m "feat: add google_fonts dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create theme.dart
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/core/theme.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `lib/core/theme.dart`**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _darkBackground = Color(0xFF111113);
|
||||||
|
const _darkSurface = Color(0xFF18181C);
|
||||||
|
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||||
|
const _darkPrimary = Color(0xFF6366F1);
|
||||||
|
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||||
|
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||||
|
const _darkOutline = Color(0xFF2E2E3A);
|
||||||
|
|
||||||
|
const _lightBackground = Color(0xFFF4F4F8);
|
||||||
|
const _lightSurface = Color(0xFFFFFFFF);
|
||||||
|
const _lightSurfaceVar = Color(0xFFF0F0F5);
|
||||||
|
const _lightPrimary = Color(0xFF4F46E5);
|
||||||
|
const _lightOnSurface = Color(0xFF18181C);
|
||||||
|
const _lightOnSurfaceVar = Color(0xFF6B6B88);
|
||||||
|
const _lightOutline = Color(0xFFD4D4E4);
|
||||||
|
|
||||||
|
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TextTheme _buildTextTheme(TextTheme base) {
|
||||||
|
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||||
|
return base.copyWith(
|
||||||
|
// Headings / titles use Fraunces
|
||||||
|
headlineLarge: fraunces.headlineLarge,
|
||||||
|
headlineMedium: fraunces.headlineMedium,
|
||||||
|
headlineSmall: fraunces.headlineSmall,
|
||||||
|
titleLarge: fraunces.titleLarge,
|
||||||
|
titleMedium: fraunces.titleMedium,
|
||||||
|
// Body / labels remain system default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ThemeData fabledDarkTheme() {
|
||||||
|
final cs = ColorScheme(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primary: _darkPrimary,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: const Color(0xFF3730A3),
|
||||||
|
onPrimaryContainer: _darkOnSurface,
|
||||||
|
secondary: _darkPrimary,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
secondaryContainer: _darkSurfaceVar,
|
||||||
|
onSecondaryContainer: _darkOnSurface,
|
||||||
|
tertiary: _darkPrimary,
|
||||||
|
onTertiary: Colors.white,
|
||||||
|
tertiaryContainer: _darkSurfaceVar,
|
||||||
|
onTertiaryContainer: _darkOnSurface,
|
||||||
|
error: const Color(0xFFEF4444),
|
||||||
|
onError: Colors.white,
|
||||||
|
errorContainer: const Color(0xFF7F1D1D),
|
||||||
|
onErrorContainer: const Color(0xFFFEE2E2),
|
||||||
|
surface: _darkSurface,
|
||||||
|
onSurface: _darkOnSurface,
|
||||||
|
surfaceContainerHighest: _darkSurfaceVar,
|
||||||
|
onSurfaceVariant: _darkOnSurfaceVar,
|
||||||
|
outline: _darkOutline,
|
||||||
|
outlineVariant: _darkOutline,
|
||||||
|
shadow: Colors.black,
|
||||||
|
scrim: Colors.black,
|
||||||
|
inverseSurface: _darkOnSurface,
|
||||||
|
onInverseSurface: _darkSurface,
|
||||||
|
inversePrimary: _lightPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: cs,
|
||||||
|
scaffoldBackgroundColor: _darkBackground,
|
||||||
|
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
color: _darkSurface,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: _darkSurface,
|
||||||
|
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
|
backgroundColor: _darkSurface,
|
||||||
|
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: _darkSurfaceVar,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: _darkSurfaceVar,
|
||||||
|
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
|
||||||
|
side: BorderSide(color: _darkOutline),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData fabledLightTheme() {
|
||||||
|
final cs = ColorScheme(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primary: _lightPrimary,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: const Color(0xFFE0E0FF),
|
||||||
|
onPrimaryContainer: _lightOnSurface,
|
||||||
|
secondary: _lightPrimary,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
secondaryContainer: _lightSurfaceVar,
|
||||||
|
onSecondaryContainer: _lightOnSurface,
|
||||||
|
tertiary: _lightPrimary,
|
||||||
|
onTertiary: Colors.white,
|
||||||
|
tertiaryContainer: _lightSurfaceVar,
|
||||||
|
onTertiaryContainer: _lightOnSurface,
|
||||||
|
error: const Color(0xFFDC2626),
|
||||||
|
onError: Colors.white,
|
||||||
|
errorContainer: const Color(0xFFFEE2E2),
|
||||||
|
onErrorContainer: const Color(0xFF7F1D1D),
|
||||||
|
surface: _lightSurface,
|
||||||
|
onSurface: _lightOnSurface,
|
||||||
|
surfaceContainerHighest: _lightSurfaceVar,
|
||||||
|
onSurfaceVariant: _lightOnSurfaceVar,
|
||||||
|
outline: _lightOutline,
|
||||||
|
outlineVariant: _lightOutline,
|
||||||
|
shadow: Colors.black,
|
||||||
|
scrim: Colors.black,
|
||||||
|
inverseSurface: _lightOnSurface,
|
||||||
|
onInverseSurface: _lightSurface,
|
||||||
|
inversePrimary: _darkPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: cs,
|
||||||
|
scaffoldBackgroundColor: _lightBackground,
|
||||||
|
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
color: _lightSurface,
|
||||||
|
elevation: 1,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: _lightSurface,
|
||||||
|
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
|
backgroundColor: _lightSurface,
|
||||||
|
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: _lightSurfaceVar,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: _lightSurfaceVar,
|
||||||
|
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
|
||||||
|
side: BorderSide(color: _lightOutline),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||||
|
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||||
|
|
||||||
|
class GradientButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const GradientButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final disabled = onPressed == null;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: disabled ? 0.45 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: disabled
|
||||||
|
? null
|
||||||
|
: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||||
|
),
|
||||||
|
color: disabled ? const Color(0xFF6366F1) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: disabled
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(padding: padding, child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||||
|
flutter analyze lib/core/theme.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors (warnings about deprecated APIs are OK).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/core/theme.dart
|
||||||
|
git commit -m "feat: custom slate-indigo theme with Fraunces typography"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire theme into the app
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/main.dart` (import theme)
|
||||||
|
- Modify: `lib/app.dart` (FabledApp widget uses new themes)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read `lib/app.dart` lines 498–523 (the `FabledApp` widget)**
|
||||||
|
|
||||||
|
Locate the `FabledApp.build()` method. It currently has:
|
||||||
|
```dart
|
||||||
|
theme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.indigo,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace with custom themes**
|
||||||
|
|
||||||
|
Add import at the top of `lib/app.dart`:
|
||||||
|
```dart
|
||||||
|
import 'core/theme.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `theme:` and `darkTheme:` arguments:
|
||||||
|
```dart
|
||||||
|
theme: fabledLightTheme(),
|
||||||
|
darkTheme: fabledDarkTheme(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the now-unused `import 'package:flutter/material.dart'` reference to `Colors.indigo` (keep the `material.dart` import itself).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the app and visually verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: app launches with dark slate-indigo background, indigo navigation bar, Fraunces headings visible on any screen that uses `titleLarge` or `headlineMedium`. No runtime errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/app.dart
|
||||||
|
git commit -m "feat: wire fabledLightTheme/fabledDarkTheme into MaterialApp"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Capture Work Queue
|
||||||
|
|
||||||
|
### Task 4: Create CaptureWorkQueueNotifier
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/providers/capture_work_queue_provider.dart`
|
||||||
|
|
||||||
|
This provider manages an in-memory FIFO queue of capture texts. A single async drain loop processes them sequentially. On success it publishes a result via `captureResultProvider` so the UI can show a snackbar. On `NetworkException` it falls through to the offline `captureQueueProvider`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `lib/providers/capture_work_queue_provider.dart`**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/exceptions.dart';
|
||||||
|
import '../data/api/quick_capture_api.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
import 'capture_queue_provider.dart';
|
||||||
|
import 'notes_provider.dart';
|
||||||
|
import 'tasks_provider.dart';
|
||||||
|
|
||||||
|
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||||
|
class CaptureResult {
|
||||||
|
final String message;
|
||||||
|
final bool isError;
|
||||||
|
const CaptureResult(this.message, {this.isError = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent capture result. UI watches this to show snackbars.
|
||||||
|
/// Reset to null by the notifier before each new item so listeners always fire.
|
||||||
|
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);
|
||||||
|
|
||||||
|
/// In-memory sequential work queue for quick captures.
|
||||||
|
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||||
|
final captureWorkQueueProvider =
|
||||||
|
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||||
|
(ref) => CaptureWorkQueueNotifier(ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||||
|
final Ref _ref;
|
||||||
|
bool _running = false;
|
||||||
|
|
||||||
|
CaptureWorkQueueNotifier(this._ref) : super([]);
|
||||||
|
|
||||||
|
/// Add text to the queue and start the drain loop if not already running.
|
||||||
|
void enqueue(String text) {
|
||||||
|
state = [...state, text];
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drain() async {
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
while (state.isNotEmpty) {
|
||||||
|
final text = state.first;
|
||||||
|
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||||
|
_ref.read(captureResultProvider.notifier).state = null;
|
||||||
|
try {
|
||||||
|
final api = _ref.read(quickCaptureApiProvider);
|
||||||
|
final result = await api.capture(text);
|
||||||
|
|
||||||
|
// Dequeue on success.
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
|
||||||
|
// Invalidate content providers so lists refresh.
|
||||||
|
switch (result.type) {
|
||||||
|
case 'note':
|
||||||
|
_ref.invalidate(notesProvider);
|
||||||
|
case 'task':
|
||||||
|
case 'todo':
|
||||||
|
_ref.invalidate(tasksProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish result for snackbar.
|
||||||
|
final msg = result.message.isNotEmpty
|
||||||
|
? result.message
|
||||||
|
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||||
|
_ref.read(captureResultProvider.notifier).state =
|
||||||
|
CaptureResult(msg);
|
||||||
|
} on NetworkException catch (_) {
|
||||||
|
// Persist to offline queue and stop draining — still offline.
|
||||||
|
await _ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
_ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||||
|
"You're offline — capture saved and will retry automatically.",
|
||||||
|
isError: false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} on AppException catch (e) {
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
_ref.read(captureResultProvider.notifier).state =
|
||||||
|
CaptureResult(e.message, isError: true);
|
||||||
|
} catch (_) {
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
_ref.read(captureResultProvider.notifier).state =
|
||||||
|
CaptureResult('Capture failed. Please try again.', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _typeLabel(String type) => switch (type) {
|
||||||
|
'note' => 'Note',
|
||||||
|
'task' => 'Task',
|
||||||
|
'event' => 'Event',
|
||||||
|
'todo' => 'To-do',
|
||||||
|
_ => type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Analyze for errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze lib/providers/capture_work_queue_provider.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/providers/capture_work_queue_provider.dart
|
||||||
|
git commit -m "feat: CaptureWorkQueueNotifier — sequential multi-item capture queue"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update _QuickCaptureBar to use the work queue
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/app.dart` — `_QuickCaptureBarState`
|
||||||
|
|
||||||
|
The bar currently calls `ref.read(quickCaptureApiProvider).capture(text)` directly and sets `_busy`. Replace with: enqueue to work queue, watch queue depth for badge, watch `captureResultProvider` for snackbars, show progress bar when queue is non-empty.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports to `lib/app.dart`**
|
||||||
|
|
||||||
|
Add at the top alongside existing imports:
|
||||||
|
```dart
|
||||||
|
import 'providers/capture_work_queue_provider.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `_QuickCaptureBarState` completely**
|
||||||
|
|
||||||
|
Replace the entire `_QuickCaptureBarState` class (from `class _QuickCaptureBarState` through its closing `}`) with:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
_controller.clear();
|
||||||
|
setState(() {}); // clear suffix icon
|
||||||
|
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drainOfflineQueue() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
final queue = ref.read(captureQueueProvider);
|
||||||
|
if (queue.isEmpty) return;
|
||||||
|
final api = ref.read(quickCaptureApiProvider);
|
||||||
|
for (final text in List<String>.from(queue)) {
|
||||||
|
if (!mounted) break;
|
||||||
|
try {
|
||||||
|
final result = await api.capture(text);
|
||||||
|
if (!mounted) break;
|
||||||
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
|
switch (result.type) {
|
||||||
|
case 'note':
|
||||||
|
ref.invalidate(notesProvider);
|
||||||
|
case 'task':
|
||||||
|
case 'todo':
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
ref.invalidate(projectsProvider);
|
||||||
|
ref.invalidate(projectMilestonesProvider);
|
||||||
|
}
|
||||||
|
} on NetworkException {
|
||||||
|
break;
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hintForLocation(String location) {
|
||||||
|
if (location.startsWith(Routes.tasks)) return 'Add a task…';
|
||||||
|
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||||
|
return 'Capture a note…';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
|
final offlineQueueCount = ref.watch(captureQueueProvider).length;
|
||||||
|
final workQueue = ref.watch(captureWorkQueueProvider);
|
||||||
|
final isWorking = workQueue.isNotEmpty;
|
||||||
|
final totalPending = workQueue.length + offlineQueueCount;
|
||||||
|
|
||||||
|
// Show snackbar when a result is published.
|
||||||
|
ref.listen(captureResultProvider, (_, result) {
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(result.message),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: _hintForLocation(location),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 10),
|
||||||
|
prefixIcon: totalPending > 0
|
||||||
|
? Badge(
|
||||||
|
label: Text('$totalPending'),
|
||||||
|
child: const Icon(Icons.cloud_upload_outlined),
|
||||||
|
)
|
||||||
|
: isWorking
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.auto_awesome_outlined),
|
||||||
|
suffixIcon: _controller.text.trim().isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: _submit,
|
||||||
|
tooltip: 'Capture',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
tooltip: 'Settings',
|
||||||
|
onPressed: () => context.push(Routes.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Thin progress bar while the work queue is draining.
|
||||||
|
if (isWorking)
|
||||||
|
const LinearProgressIndicator(minHeight: 2)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove now-unused `_busy` field and old `_submit`/`_send` methods**
|
||||||
|
|
||||||
|
They are fully replaced by the new class above. Verify there are no remaining references to `_busy` or the old `_send` method in `_QuickCaptureBarState`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Analyze**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze lib/app.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors. Fix any missing imports surfaced by the analyzer.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run and test manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Test:
|
||||||
|
1. Type a capture and submit — input clears immediately, progress bar appears briefly, snackbar shows on completion
|
||||||
|
2. Type and submit 3 captures rapidly — all 3 appear in the queue badge, drain one by one, 3 snackbars appear in sequence
|
||||||
|
3. The input field is never disabled during processing
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/app.dart
|
||||||
|
git commit -m "feat: multi-item capture work queue with sequential drain and progress indicator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] `flutter analyze` — zero errors
|
||||||
|
- [ ] App launches with dark slate-indigo background on a device/emulator in dark mode
|
||||||
|
- [ ] App launches with light theme when device is set to light mode
|
||||||
|
- [ ] Fraunces font visible in screen titles (e.g. Notes AppBar title)
|
||||||
|
- [ ] Capture bar: submit while processing → second item queues, badge shows count
|
||||||
|
- [ ] Capture bar: submit 3 items → all drain sequentially, 3 snackbars
|
||||||
|
- [ ] Offline: capture → "saved and will retry" snackbar, falls to offline queue
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
|||||||
|
# Fabled App Overhaul — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-11
|
||||||
|
**Project:** `/home/bvandeusen/Nextcloud/Projects/fabled_app`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Reposition the Flutter Android app from a general-purpose mirror of the web app into a focused mobile companion: the Daily Briefing is the primary experience, Quick Capture is the secondary utility, and Notes/Tasks/Projects are a browsable library — secondary to both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation & Shell
|
||||||
|
|
||||||
|
Three-tab shell replacing the current four-tab (Notes · Tasks · Projects · Chat) structure.
|
||||||
|
|
||||||
|
| Tab | Icon | Screen | Route |
|
||||||
|
|-----|------|--------|-------|
|
||||||
|
| Briefing | `Icons.wb_sunny_outlined` / `Icons.wb_sunny` | `BriefingScreen` | `/briefing` |
|
||||||
|
| Library | `Icons.library_books_outlined` / `Icons.library_books` | `LibraryScreen` | `/library` |
|
||||||
|
| Chat | `Icons.chat_bubble_outline` / `Icons.chat_bubble` | `ConversationsListScreen` (inline) | `/conversations` |
|
||||||
|
|
||||||
|
The `_QuickCaptureBar` remains pinned above the shell on all three tabs. The settings icon stays in the capture bar row.
|
||||||
|
|
||||||
|
**Briefing is the initial route** — the app opens directly to the briefing tab on every launch.
|
||||||
|
|
||||||
|
**Wide layout (≥ 600dp):** `NavigationRail` on the left (as today), same 3 destinations.
|
||||||
|
|
||||||
|
### Dead Code Removed
|
||||||
|
|
||||||
|
**Screens deleted:**
|
||||||
|
- `lib/screens/notes/notes_list_screen.dart`
|
||||||
|
- `lib/screens/tasks/tasks_list_screen.dart`
|
||||||
|
- `lib/screens/projects/project_list_screen.dart`
|
||||||
|
- `lib/screens/chat/conversations_list_screen.dart`
|
||||||
|
- `lib/screens/quick_capture/quick_capture_screen.dart`
|
||||||
|
|
||||||
|
**Screens kept:**
|
||||||
|
- `lib/screens/notes/note_detail_screen.dart`
|
||||||
|
- `lib/screens/notes/note_edit_screen.dart`
|
||||||
|
- `lib/screens/tasks/task_edit_screen.dart`
|
||||||
|
- `lib/screens/chat/chat_screen.dart`
|
||||||
|
- All auth, settings, setup, splash screens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
Custom `ColorScheme` matching the main web app's "Illuminated Transcript" palette, in `lib/core/theme.dart`. System light/dark preference respected.
|
||||||
|
|
||||||
|
### Dark theme
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `background` | `#111113` |
|
||||||
|
| `surface` | `#18181c` |
|
||||||
|
| `primary` | `#6366f1` |
|
||||||
|
| `onSurface` | `#e8e8f0` |
|
||||||
|
| `onSurfaceVariant` (muted) | `#8888a8` |
|
||||||
|
|
||||||
|
### Light theme
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `background` | `#f4f4f8` |
|
||||||
|
| `surface` | `#ffffff` |
|
||||||
|
| `primary` | `#4f46e5` |
|
||||||
|
| `onSurface` | `#18181c` |
|
||||||
|
| `onSurfaceVariant` (muted) | `#6b6b88` |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- Add `google_fonts` to `pubspec.yaml`
|
||||||
|
- `headlineMedium`, `titleLarge`, `titleMedium` → `GoogleFonts.fraunces()`
|
||||||
|
- Body styles → system default (unchanged)
|
||||||
|
|
||||||
|
### Buttons & Cards
|
||||||
|
- Primary action buttons: `BoxDecoration` with `LinearGradient(135°, #6366f1, #4f46e5)`; applied to send buttons in capture bar and briefing reply bar
|
||||||
|
- Cards: `borderRadius: 14`, subtle elevation shadow, no explicit border
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BriefingScreen
|
||||||
|
|
||||||
|
**File:** `lib/screens/briefing/briefing_screen.dart`
|
||||||
|
|
||||||
|
The app's primary screen. Opens on launch.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
AppBar:
|
||||||
|
title: "Briefing" (Fraunces)
|
||||||
|
subtitle: today's date ("Wednesday, March 11")
|
||||||
|
actions: [↻ refresh button]
|
||||||
|
|
||||||
|
Body (scrollable column):
|
||||||
|
┌─ DigestCard ──────────────────────┐
|
||||||
|
│ ☀ Good morning — Mar 11 │
|
||||||
|
│ [first assistant message, │
|
||||||
|
│ truncated to 5 lines] │
|
||||||
|
│ [Show more ↓] │
|
||||||
|
└───────────────────────────────────┘
|
||||||
|
|
||||||
|
─── Conversation ───
|
||||||
|
|
||||||
|
[scrollable message list]
|
||||||
|
[streaming bubble while generating]
|
||||||
|
|
||||||
|
Bottom pinned:
|
||||||
|
[ Reply to your briefing… ] [➤]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Digest Card
|
||||||
|
- Extracted widget: `lib/widgets/briefing_digest_card.dart`
|
||||||
|
- Shows the content of the first `assistant` message from today's conversation
|
||||||
|
- Truncated to 5 lines by default; `[Show more]` expands with `AnimatedSize`
|
||||||
|
- If no briefing exists yet: "No briefing yet today" placeholder + "Generate now" button
|
||||||
|
|
||||||
|
### Conversation
|
||||||
|
- Message bubbles reuse the same widget used in `ChatScreen`; extracted to `lib/widgets/chat_message_bubble.dart` (shared between both screens)
|
||||||
|
- User messages: right-aligned, primary colour container
|
||||||
|
- Assistant messages: left-aligned, surface container with left accent border (indigo, 2dp)
|
||||||
|
- Streaming: a streaming bubble appears at the bottom of the list while SSE is active
|
||||||
|
|
||||||
|
### Reply Bar
|
||||||
|
- Always visible (pinned to bottom, above system nav bar)
|
||||||
|
- Send button: indigo gradient, disabled when input is empty or streaming
|
||||||
|
- Submitting a reply uses the existing SSE chat endpoint with today's briefing `conversation_id`
|
||||||
|
|
||||||
|
### Overflow Menu (`···`)
|
||||||
|
- "View past briefings" → pushes `BriefingHistoryScreen` (simple date list, read-only — no reply bar)
|
||||||
|
|
||||||
|
### Refresh Button
|
||||||
|
- Calls `POST /api/briefing/trigger` with `{"slot": "compilation"}`
|
||||||
|
- Shows `CircularProgressIndicator` in the AppBar while in-flight
|
||||||
|
- Reloads conversation on completion
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `lib/screens/briefing/briefing_screen.dart`
|
||||||
|
- `lib/screens/briefing/briefing_history_screen.dart`
|
||||||
|
- `lib/widgets/briefing_digest_card.dart`
|
||||||
|
- `lib/widgets/chat_message_bubble.dart` (extracted from ChatScreen)
|
||||||
|
- `lib/data/api/briefing_api.dart` — `getToday()`, `getMessages(id)`, `triggerSlot(slot)`
|
||||||
|
- `lib/data/models/briefing_conversation.dart` — `id`, `briefingDate`, `title`, `messages`
|
||||||
|
- `lib/providers/briefing_provider.dart` — `briefingTodayProvider` (AsyncNotifier)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LibraryScreen
|
||||||
|
|
||||||
|
**File:** `lib/screens/library/library_screen.dart`
|
||||||
|
|
||||||
|
Unified browsing screen for notes, tasks, and projects.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
AppBar:
|
||||||
|
title: "Library"
|
||||||
|
actions: [🔍 search icon → expands inline search bar]
|
||||||
|
|
||||||
|
Filter pills (scrollable horizontal row):
|
||||||
|
[All] [Notes] [Tasks] [Projects]
|
||||||
|
|
||||||
|
Content:
|
||||||
|
Unified list sorted by updated_at desc
|
||||||
|
Each item: LibraryItemCard
|
||||||
|
|
||||||
|
FAB:
|
||||||
|
[+] → bottom sheet: "New note" | "New task"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Pills
|
||||||
|
| Pill | Content | Extra controls |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| All | Notes + tasks interleaved | — |
|
||||||
|
| Notes | Notes only | — |
|
||||||
|
| Tasks | Tasks only | Secondary status row: Todo · In Progress · Done · All |
|
||||||
|
| Projects | Project cards | — |
|
||||||
|
|
||||||
|
### Item Cards (`lib/widgets/library_item_card.dart`)
|
||||||
|
- **Note:** title (Fraunces medium), 1-line body snippet, tag chips, relative timestamp
|
||||||
|
- **Task:** status checkbox (tappable → cycles `todo → in_progress → done` via `PATCH /api/tasks/:id/status`), title, due date, priority dot (high = red, medium = amber)
|
||||||
|
- **Project:** left colour strip matching project colour, title, active task count, milestone progress bar
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- Tapping 🔍 slides an `AnimatedContainer` search bar into the AppBar
|
||||||
|
- Searches title + body across notes and tasks via `GET /api/notes?q=` (respects active filter pill)
|
||||||
|
- Dismisses with Escape / back gesture
|
||||||
|
|
||||||
|
### FAB
|
||||||
|
- Bottom sheet with two large tap targets: "New note" → `NoteEditScreen`, "New task" → `TaskEditScreen`
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `lib/screens/library/library_screen.dart`
|
||||||
|
- `lib/widgets/library_item_card.dart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Capture Queue
|
||||||
|
|
||||||
|
The `_QuickCaptureBar` in `app.dart` is updated to support multiple in-flight captures queued sequentially.
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
- Input **never disables** while the worker is active (only disables on offline — existing behaviour)
|
||||||
|
- Submitting appends text to the work queue; the worker drains it one item at a time
|
||||||
|
- Each completion fires a snackbar (`"Note created: …"`)
|
||||||
|
- Queue depth > 0: prefix icon shows a badge `⋯ N`
|
||||||
|
- Worker active: a 2dp `LinearProgressIndicator` in indigo animates below the capture bar
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- New `lib/providers/capture_work_queue_provider.dart` — `CaptureWorkQueueNotifier` (in-memory `List<String>` + async drain loop)
|
||||||
|
- Separate from the existing `captureQueueProvider` (which handles offline persistence); the two queues are distinct:
|
||||||
|
- **Work queue:** in-memory, drains sequentially while online
|
||||||
|
- **Offline queue:** persisted to SharedPreferences, drained on reconnect
|
||||||
|
- Network errors during work queue drain fall through to offline queue (existing logic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created / Modified Summary
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- `lib/core/theme.dart`
|
||||||
|
- `lib/screens/briefing/briefing_screen.dart`
|
||||||
|
- `lib/screens/briefing/briefing_history_screen.dart`
|
||||||
|
- `lib/screens/library/library_screen.dart`
|
||||||
|
- `lib/widgets/briefing_digest_card.dart`
|
||||||
|
- `lib/widgets/chat_message_bubble.dart`
|
||||||
|
- `lib/widgets/library_item_card.dart`
|
||||||
|
- `lib/data/api/briefing_api.dart`
|
||||||
|
- `lib/data/models/briefing_conversation.dart`
|
||||||
|
- `lib/providers/briefing_provider.dart`
|
||||||
|
- `lib/providers/capture_work_queue_provider.dart`
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `lib/app.dart` — new 3-tab shell, new routes, updated `_QuickCaptureBar`
|
||||||
|
- `lib/main.dart` — import `theme.dart`
|
||||||
|
- `lib/screens/chat/chat_screen.dart` — extract bubble widget
|
||||||
|
- `pubspec.yaml` — add `google_fonts`
|
||||||
|
|
||||||
|
**Deleted:**
|
||||||
|
- `lib/screens/notes/notes_list_screen.dart`
|
||||||
|
- `lib/screens/tasks/tasks_list_screen.dart`
|
||||||
|
- `lib/screens/projects/project_list_screen.dart`
|
||||||
|
- `lib/screens/chat/conversations_list_screen.dart`
|
||||||
|
- `lib/screens/quick_capture/quick_capture_screen.dart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- iOS support — Android only, as today
|
||||||
|
- Workspace view — web-only feature
|
||||||
|
- Graph view — web-only feature
|
||||||
|
- Note editing from Library (tap → NoteDetailScreen → edit button, as today)
|
||||||
|
- Push notification handling in-app — handled by the OS notification tray
|
||||||
+159
-153
@@ -4,24 +4,27 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'core/constants.dart';
|
import 'core/constants.dart';
|
||||||
import 'core/exceptions.dart';
|
import 'core/exceptions.dart';
|
||||||
|
import 'core/theme.dart';
|
||||||
import 'providers/api_client_provider.dart';
|
import 'providers/api_client_provider.dart';
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/capture_queue_provider.dart';
|
import 'providers/capture_queue_provider.dart';
|
||||||
|
import 'providers/capture_work_queue_provider.dart';
|
||||||
import 'providers/notes_provider.dart';
|
import 'providers/notes_provider.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/update_provider.dart';
|
import 'providers/update_provider.dart';
|
||||||
import 'providers/tasks_provider.dart';
|
import 'providers/tasks_provider.dart';
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
|
import 'screens/briefing/briefing_screen.dart';
|
||||||
|
import 'screens/library/project_tasks_screen.dart';
|
||||||
import 'screens/chat/chat_screen.dart';
|
import 'screens/chat/chat_screen.dart';
|
||||||
import 'screens/chat/conversations_list_screen.dart';
|
import 'screens/chat/conversations_tab_screen.dart';
|
||||||
|
import 'screens/library/library_screen.dart';
|
||||||
import 'screens/notes/note_detail_screen.dart';
|
import 'screens/notes/note_detail_screen.dart';
|
||||||
import 'screens/notes/note_edit_screen.dart';
|
import 'screens/notes/note_edit_screen.dart';
|
||||||
import 'screens/notes/notes_list_screen.dart';
|
|
||||||
import 'screens/settings/settings_screen.dart';
|
import 'screens/settings/settings_screen.dart';
|
||||||
import 'screens/setup/setup_screen.dart';
|
import 'screens/setup/setup_screen.dart';
|
||||||
import 'screens/splash/splash_screen.dart';
|
import 'screens/splash/splash_screen.dart';
|
||||||
import 'screens/tasks/task_edit_screen.dart';
|
import 'screens/tasks/task_edit_screen.dart';
|
||||||
import 'screens/tasks/tasks_list_screen.dart';
|
|
||||||
|
|
||||||
// ChangeNotifier that fires when auth or server URL changes,
|
// ChangeNotifier that fires when auth or server URL changes,
|
||||||
// used as GoRouter.refreshListenable so the router re-evaluates redirects
|
// used as GoRouter.refreshListenable so the router re-evaluates redirects
|
||||||
@@ -93,7 +96,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.taskNew,
|
path: Routes.taskNew,
|
||||||
builder: (_, _) => const TaskEditScreen(),
|
builder: (_, state) => TaskEditScreen(
|
||||||
|
initialProjectId: state.uri.queryParameters['projectId'] != null
|
||||||
|
? int.tryParse(state.uri.queryParameters['projectId']!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.taskEdit,
|
path: Routes.taskEdit,
|
||||||
@@ -101,6 +108,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
taskId: int.parse(state.pathParameters['id']!),
|
taskId: int.parse(state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projectTasks,
|
||||||
|
builder: (_, state) => ProjectTasksScreen(
|
||||||
|
projectId: int.parse(state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.chat,
|
path: Routes.chat,
|
||||||
builder: (_, state) => ChatScreen(
|
builder: (_, state) => ChatScreen(
|
||||||
@@ -111,16 +124,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state, child) => _Shell(child: child),
|
builder: (context, state, child) => _Shell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.notes,
|
path: Routes.briefing,
|
||||||
builder: (_, _) => const NotesListScreen(),
|
builder: (_, _) => const BriefingScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.tasks,
|
path: Routes.library,
|
||||||
builder: (_, _) => const TasksListScreen(),
|
builder: (_, _) => const LibraryScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.conversations,
|
path: Routes.conversations,
|
||||||
builder: (_, _) => const ConversationsListScreen(),
|
builder: (_, _) => const ConversationsTabScreen(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -137,15 +150,23 @@ class _Shell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShellState extends ConsumerState<_Shell> {
|
class _ShellState extends ConsumerState<_Shell> {
|
||||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
|
static const _tabs = [
|
||||||
|
Routes.briefing,
|
||||||
|
Routes.library,
|
||||||
|
Routes.conversations,
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Silent update check on first app load.
|
// Silent update check — only if we haven't already checked this session.
|
||||||
|
// Skipping when status is not idle/error prevents the dialog from
|
||||||
|
// re-appearing every time the shell re-mounts (e.g. after visiting settings).
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
if (repoUrl == null || repoUrl.isEmpty) return;
|
||||||
|
final status = ref.read(updateProvider).status;
|
||||||
|
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
|
||||||
ref.read(updateProvider.notifier).check(repoUrl);
|
ref.read(updateProvider.notifier).check(repoUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -171,7 +192,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Version ${state.latestVersion} is ready to install.'),
|
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||||
if (state.currentVersion != null)
|
if (state.currentVersion != null)
|
||||||
Text(
|
Text(
|
||||||
'Installed: v${state.currentVersion}',
|
'Installed: v${state.currentVersion}',
|
||||||
@@ -191,6 +212,16 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (state.status == UpdateStatus.error &&
|
||||||
|
state.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
state.errorMessage!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -198,7 +229,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('Later'),
|
child: const Text('Later'),
|
||||||
),
|
),
|
||||||
if (!isDownloading)
|
if (!isDownloading && state.downloadUrl != null)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(updateProvider.notifier)
|
.read(updateProvider.notifier)
|
||||||
@@ -225,8 +256,6 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
final index = _tabIndex(location);
|
final index = _tabIndex(location);
|
||||||
final child = widget.child;
|
final child = widget.child;
|
||||||
// Use NavigationRail whenever the screen is wide enough — covers both
|
|
||||||
// phone landscape and tablets in either orientation (600 dp breakpoint).
|
|
||||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
@@ -244,14 +273,14 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.note_outlined),
|
icon: Icon(Icons.wb_sunny_outlined),
|
||||||
selectedIcon: Icon(Icons.note),
|
selectedIcon: Icon(Icons.wb_sunny),
|
||||||
label: Text('Notes'),
|
label: Text('Briefing'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.check_box_outlined),
|
icon: Icon(Icons.library_books_outlined),
|
||||||
selectedIcon: Icon(Icons.check_box),
|
selectedIcon: Icon(Icons.library_books),
|
||||||
label: Text('Tasks'),
|
label: Text('Library'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
@@ -275,17 +304,34 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const _QuickCaptureBar(),
|
const _QuickCaptureBar(),
|
||||||
Expanded(child: child),
|
Expanded(
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
|
|
||||||
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.chat_bubble), label: 'Chat'),
|
icon: Icon(Icons.wb_sunny_outlined),
|
||||||
|
selectedIcon: Icon(Icons.wb_sunny),
|
||||||
|
label: 'Briefing',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.library_books_outlined),
|
||||||
|
selectedIcon: Icon(Icons.library_books),
|
||||||
|
label: 'Library',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
|
label: 'Chat',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -301,13 +347,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
bool _busy = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Retry any offline-queued captures from previous sessions.
|
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -316,61 +360,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
void _submit() {
|
||||||
final text = _controller.text.trim();
|
final text = _controller.text.trim();
|
||||||
if (text.isEmpty || _busy) return;
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
setState(() => _busy = true);
|
setState(() {}); // clear suffix icon
|
||||||
|
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||||
// Capture the messenger before the async gap so we can show a snackbar
|
|
||||||
// even if the user has navigated to a deeper view by the time it resolves.
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await ref.read(quickCaptureApiProvider).capture(text);
|
|
||||||
|
|
||||||
switch (result.type) {
|
|
||||||
case 'note':
|
|
||||||
ref.invalidate(notesProvider);
|
|
||||||
case 'task':
|
|
||||||
case 'todo':
|
|
||||||
ref.invalidate(tasksProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
final msg = result.message.isNotEmpty
|
|
||||||
? result.message
|
|
||||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
|
|
||||||
);
|
|
||||||
_drainQueue(); // Silently flush any offline-queued captures.
|
|
||||||
} on NetworkException {
|
|
||||||
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
"You're offline — capture saved and will retry automatically."),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on AppException catch (e) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Capture failed. Please try again.'),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _busy = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _drainQueue() async {
|
Future<void> _drainOfflineQueue() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final queue = ref.read(captureQueueProvider);
|
final queue = ref.read(captureQueueProvider);
|
||||||
if (queue.isEmpty) return;
|
if (queue.isEmpty) return;
|
||||||
@@ -379,8 +377,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
if (!mounted) break;
|
if (!mounted) break;
|
||||||
try {
|
try {
|
||||||
final result = await api.capture(text);
|
final result = await api.capture(text);
|
||||||
if (!mounted) break;
|
// Dequeue before the mounted check — SharedPreferences doesn't need
|
||||||
|
// the widget alive, and skipping this would leave a ghost item.
|
||||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
|
if (!mounted) break;
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case 'note':
|
case 'note':
|
||||||
ref.invalidate(notesProvider);
|
ref.invalidate(notesProvider);
|
||||||
@@ -389,24 +389,18 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
ref.invalidate(tasksProvider);
|
ref.invalidate(tasksProvider);
|
||||||
}
|
}
|
||||||
} on NetworkException {
|
} on NetworkException {
|
||||||
break; // Still offline — stop draining.
|
break;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Server/parse error — remove to avoid infinite retries.
|
// Server error or unexpected failure — drop from queue to prevent
|
||||||
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
// ghost items that can never be cleared.
|
||||||
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _typeLabel(String type) => switch (type) {
|
|
||||||
'note' => 'Note',
|
|
||||||
'task' => 'Task',
|
|
||||||
'event' => 'Event',
|
|
||||||
'todo' => 'To-do',
|
|
||||||
_ => type,
|
|
||||||
};
|
|
||||||
|
|
||||||
String _hintForLocation(String location) {
|
String _hintForLocation(String location) {
|
||||||
if (location.startsWith(Routes.tasks)) return 'Add a task…';
|
if (location.startsWith(Routes.library) &&
|
||||||
|
location.contains('tasks')) { return 'Add a task…'; }
|
||||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||||
return 'Capture a note…';
|
return 'Capture a note…';
|
||||||
}
|
}
|
||||||
@@ -414,61 +408,82 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
final queueCount = ref.watch(captureQueueProvider).length;
|
final offlineQueueCount = ref.watch(captureQueueProvider).length;
|
||||||
|
final workQueue = ref.watch(captureWorkQueueProvider);
|
||||||
|
final isWorking = workQueue.isNotEmpty;
|
||||||
|
final totalPending = workQueue.length + offlineQueueCount;
|
||||||
|
|
||||||
|
// Show snackbar when a result is published.
|
||||||
|
ref.listen(captureResultProvider, (_, result) {
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(result.message),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||||
child: TextField(
|
child: Row(
|
||||||
controller: _controller,
|
children: [
|
||||||
enabled: !_busy,
|
Expanded(
|
||||||
textInputAction: TextInputAction.send,
|
child: TextField(
|
||||||
onSubmitted: (_) => _submit(),
|
controller: _controller,
|
||||||
onChanged: (_) => setState(() {}),
|
textInputAction: TextInputAction.send,
|
||||||
decoration: InputDecoration(
|
onSubmitted: (_) => _submit(),
|
||||||
hintText: _hintForLocation(location),
|
onChanged: (_) => setState(() {}),
|
||||||
isDense: true,
|
decoration: InputDecoration(
|
||||||
contentPadding:
|
hintText: _hintForLocation(location),
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
isDense: true,
|
||||||
border: OutlineInputBorder(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(24),
|
horizontal: 14, vertical: 10),
|
||||||
),
|
prefixIcon: totalPending > 0
|
||||||
prefixIcon: _busy
|
|
||||||
? const Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: queueCount > 0
|
|
||||||
? Badge(
|
? Badge(
|
||||||
label: Text('$queueCount'),
|
label: Text('$totalPending'),
|
||||||
child:
|
child: const Icon(Icons.cloud_upload_outlined),
|
||||||
const Icon(Icons.cloud_upload_outlined),
|
|
||||||
)
|
)
|
||||||
: const Icon(Icons.auto_awesome_outlined),
|
: isWorking
|
||||||
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
|
? const Padding(
|
||||||
? IconButton(
|
padding: EdgeInsets.all(12),
|
||||||
icon: const Icon(Icons.send),
|
child: SizedBox(
|
||||||
onPressed: _submit,
|
width: 16,
|
||||||
tooltip: 'Capture',
|
height: 16,
|
||||||
)
|
child: CircularProgressIndicator(
|
||||||
: null,
|
strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.auto_awesome_outlined),
|
||||||
|
suffixIcon: _controller.text.trim().isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: _submit,
|
||||||
|
tooltip: 'Capture',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
tooltip: 'Settings',
|
||||||
|
onPressed: () => context.push(Routes.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.settings_outlined),
|
// Thin progress bar while the work queue is draining.
|
||||||
tooltip: 'Settings',
|
if (isWorking)
|
||||||
onPressed: () => context.push(Routes.settings),
|
const LinearProgressIndicator(minHeight: 2)
|
||||||
),
|
else
|
||||||
],
|
const SizedBox(height: 2),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -485,17 +500,8 @@ class FabledApp extends ConsumerWidget {
|
|||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Fabled',
|
title: 'Fabled',
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
theme: ThemeData(
|
theme: fabledLightTheme(),
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
darkTheme: fabledDarkTheme(),
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
darkTheme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: Colors.indigo,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ abstract class Routes {
|
|||||||
static const tasks = '/tasks';
|
static const tasks = '/tasks';
|
||||||
static const taskNew = '/tasks/new';
|
static const taskNew = '/tasks/new';
|
||||||
static const taskEdit = '/tasks/:id/edit';
|
static const taskEdit = '/tasks/:id/edit';
|
||||||
|
static const projects = '/projects';
|
||||||
static const conversations = '/chat';
|
static const conversations = '/chat';
|
||||||
static const chat = '/chat/:id';
|
static const chat = '/chat/:id';
|
||||||
static const quickCapture = '/quick-capture';
|
static const quickCapture = '/quick-capture';
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
|
static const briefing = '/briefing';
|
||||||
|
static const library = '/library';
|
||||||
|
static const projectTasks = '/projects/:id/tasks';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _darkBackground = Color(0xFF111113);
|
||||||
|
const _darkSurface = Color(0xFF18181C);
|
||||||
|
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||||
|
const _darkPrimary = Color(0xFF6366F1);
|
||||||
|
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||||
|
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||||
|
const _darkOutline = Color(0xFF2E2E3A);
|
||||||
|
|
||||||
|
const _lightBackground = Color(0xFFF4F4F8);
|
||||||
|
const _lightSurface = Color(0xFFFFFFFF);
|
||||||
|
const _lightSurfaceVar = Color(0xFFF0F0F5);
|
||||||
|
const _lightPrimary = Color(0xFF4F46E5);
|
||||||
|
const _lightOnSurface = Color(0xFF18181C);
|
||||||
|
const _lightOnSurfaceVar = Color(0xFF6B6B88);
|
||||||
|
const _lightOutline = Color(0xFFD4D4E4);
|
||||||
|
|
||||||
|
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TextTheme _buildTextTheme(TextTheme base) {
|
||||||
|
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||||
|
return base.copyWith(
|
||||||
|
// Headings / titles use Fraunces
|
||||||
|
headlineLarge: fraunces.headlineLarge,
|
||||||
|
headlineMedium: fraunces.headlineMedium,
|
||||||
|
headlineSmall: fraunces.headlineSmall,
|
||||||
|
titleLarge: fraunces.titleLarge,
|
||||||
|
titleMedium: fraunces.titleMedium,
|
||||||
|
// Body / labels remain system default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ThemeData fabledDarkTheme() {
|
||||||
|
final cs = ColorScheme(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primary: _darkPrimary,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: const Color(0xFF3730A3),
|
||||||
|
onPrimaryContainer: _darkOnSurface,
|
||||||
|
secondary: _darkPrimary,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
secondaryContainer: _darkSurfaceVar,
|
||||||
|
onSecondaryContainer: _darkOnSurface,
|
||||||
|
tertiary: _darkPrimary,
|
||||||
|
onTertiary: Colors.white,
|
||||||
|
tertiaryContainer: _darkSurfaceVar,
|
||||||
|
onTertiaryContainer: _darkOnSurface,
|
||||||
|
error: const Color(0xFFEF4444),
|
||||||
|
onError: Colors.white,
|
||||||
|
errorContainer: const Color(0xFF7F1D1D),
|
||||||
|
onErrorContainer: const Color(0xFFFEE2E2),
|
||||||
|
surface: _darkSurface,
|
||||||
|
onSurface: _darkOnSurface,
|
||||||
|
surfaceContainerHighest: _darkSurfaceVar,
|
||||||
|
onSurfaceVariant: _darkOnSurfaceVar,
|
||||||
|
outline: _darkOutline,
|
||||||
|
outlineVariant: _darkOutline,
|
||||||
|
shadow: Colors.black,
|
||||||
|
scrim: Colors.black,
|
||||||
|
inverseSurface: _darkOnSurface,
|
||||||
|
onInverseSurface: _darkSurface,
|
||||||
|
inversePrimary: _lightPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: cs,
|
||||||
|
scaffoldBackgroundColor: _darkBackground,
|
||||||
|
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: _darkSurface,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: _darkSurface,
|
||||||
|
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
|
backgroundColor: _darkSurface,
|
||||||
|
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: _darkSurfaceVar,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: _darkSurfaceVar,
|
||||||
|
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
|
||||||
|
side: BorderSide(color: _darkOutline),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData fabledLightTheme() {
|
||||||
|
final cs = ColorScheme(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primary: _lightPrimary,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: const Color(0xFFE0E0FF),
|
||||||
|
onPrimaryContainer: _lightOnSurface,
|
||||||
|
secondary: _lightPrimary,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
secondaryContainer: _lightSurfaceVar,
|
||||||
|
onSecondaryContainer: _lightOnSurface,
|
||||||
|
tertiary: _lightPrimary,
|
||||||
|
onTertiary: Colors.white,
|
||||||
|
tertiaryContainer: _lightSurfaceVar,
|
||||||
|
onTertiaryContainer: _lightOnSurface,
|
||||||
|
error: const Color(0xFFDC2626),
|
||||||
|
onError: Colors.white,
|
||||||
|
errorContainer: const Color(0xFFFEE2E2),
|
||||||
|
onErrorContainer: const Color(0xFF7F1D1D),
|
||||||
|
surface: _lightSurface,
|
||||||
|
onSurface: _lightOnSurface,
|
||||||
|
surfaceContainerHighest: _lightSurfaceVar,
|
||||||
|
onSurfaceVariant: _lightOnSurfaceVar,
|
||||||
|
outline: _lightOutline,
|
||||||
|
outlineVariant: _lightOutline,
|
||||||
|
shadow: Colors.black,
|
||||||
|
scrim: Colors.black,
|
||||||
|
inverseSurface: _lightOnSurface,
|
||||||
|
onInverseSurface: _lightSurface,
|
||||||
|
inversePrimary: _darkPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: cs,
|
||||||
|
scaffoldBackgroundColor: _lightBackground,
|
||||||
|
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: _lightSurface,
|
||||||
|
elevation: 1,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: _lightSurface,
|
||||||
|
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
|
backgroundColor: _lightSurface,
|
||||||
|
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: _lightSurfaceVar,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: _lightSurfaceVar,
|
||||||
|
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
|
||||||
|
side: BorderSide(color: _lightOutline),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||||
|
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||||
|
|
||||||
|
class GradientButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const GradientButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final disabled = onPressed == null;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: disabled ? 0.45 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: disabled
|
||||||
|
? null
|
||||||
|
: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||||
|
),
|
||||||
|
color: disabled ? const Color(0xFF6366F1) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: disabled
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(padding: padding, child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
|
|||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err.type == DioExceptionType.receiveTimeout ||
|
||||||
|
err.type == DioExceptionType.sendTimeout) {
|
||||||
|
handler.reject(DioException(
|
||||||
|
requestOptions: err.requestOptions,
|
||||||
|
error: const AppException('Request timed out. The server is taking too long to respond.'),
|
||||||
|
type: err.type,
|
||||||
|
response: err.response,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../models/briefing_conversation.dart';
|
||||||
|
import '../models/message.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class BriefingApi {
|
||||||
|
final Dio _dio;
|
||||||
|
const BriefingApi(this._dio);
|
||||||
|
|
||||||
|
/// GET /api/briefing/conversations/today
|
||||||
|
/// Returns (or creates) today's briefing conversation with messages embedded.
|
||||||
|
Future<BriefingConversation> getToday() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/briefing/conversations/today');
|
||||||
|
return BriefingConversation.fromJson(
|
||||||
|
response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/briefing/conversations
|
||||||
|
/// Returns list of past briefing conversations (no messages embedded).
|
||||||
|
Future<List<BriefingConversation>> getHistory() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/briefing/conversations');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['conversations'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/briefing/conversations/`<id>`/messages
|
||||||
|
Future<List<Message>> getMessages(int convId) async {
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _dio.get('/api/briefing/conversations/$convId/messages');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['messages'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/briefing/trigger body: {"slot": slot}
|
||||||
|
/// slot: "compilation" | "morning" | "midday" | "afternoon"
|
||||||
|
Future<void> triggerSlot(String slot) async {
|
||||||
|
try {
|
||||||
|
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../models/milestone.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class MilestonesApi {
|
||||||
|
final Dio _dio;
|
||||||
|
const MilestonesApi(this._dio);
|
||||||
|
|
||||||
|
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/projects/$projectId/milestones',
|
||||||
|
queryParameters: status != null ? {'status': status} : null,
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['milestones'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Milestone.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Milestone> create(
|
||||||
|
int projectId, {
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
int orderIndex = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/projects/$projectId/milestones',
|
||||||
|
data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null && description.isNotEmpty)
|
||||||
|
'description': description,
|
||||||
|
'order_index': orderIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Milestone> update(
|
||||||
|
int projectId,
|
||||||
|
int milestoneId,
|
||||||
|
Map<String, dynamic> fields,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.patch(
|
||||||
|
'/api/projects/$projectId/milestones/$milestoneId',
|
||||||
|
data: fields,
|
||||||
|
);
|
||||||
|
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int projectId, int milestoneId) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/projects/$projectId/milestones/$milestoneId');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,11 +27,18 @@ class NotesApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> create(String title, String body) async {
|
Future<Note> create(
|
||||||
|
String title,
|
||||||
|
String body, {
|
||||||
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post('/api/notes', data: {
|
final response = await _dio.post('/api/notes', data: {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
if (projectId != null) 'project_id': projectId,
|
||||||
});
|
});
|
||||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -39,11 +46,20 @@ class NotesApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> update(int id, String title, String body) async {
|
Future<Note> update(
|
||||||
|
int id,
|
||||||
|
String title,
|
||||||
|
String body, {
|
||||||
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
bool clearProject = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.put('/api/notes/$id', data: {
|
final response = await _dio.put('/api/notes/$id', data: {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId,
|
||||||
});
|
});
|
||||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../models/project.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class ProjectsApi {
|
||||||
|
final Dio _dio;
|
||||||
|
const ProjectsApi(this._dio);
|
||||||
|
|
||||||
|
Future<List<Project>> getAll({String? status}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/projects',
|
||||||
|
queryParameters: status != null ? {'status': status} : null,
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['projects'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Project.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/projects/$id');
|
||||||
|
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> create({
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
String? goal,
|
||||||
|
String? color,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post('/api/projects', data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null && description.isNotEmpty)
|
||||||
|
'description': description,
|
||||||
|
if (goal != null && goal.isNotEmpty) 'goal': goal,
|
||||||
|
if (color != null) 'color': color,
|
||||||
|
});
|
||||||
|
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.patch('/api/projects/$id', data: fields);
|
||||||
|
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/projects/$id');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ class QuickCaptureApi {
|
|||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
'/api/quick-capture',
|
'/api/quick-capture',
|
||||||
data: {'text': text},
|
data: {'text': text},
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 120)),
|
||||||
);
|
);
|
||||||
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class TasksApi {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
|
int? parentId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post('/api/tasks', data: {
|
final response = await _dio.post('/api/tasks', data: {
|
||||||
@@ -41,6 +43,8 @@ class TasksApi {
|
|||||||
'status': status.value,
|
'status': status.value,
|
||||||
'priority': priority.value,
|
'priority': priority.value,
|
||||||
'due_date': dueDate?.toIso8601String(),
|
'due_date': dueDate?.toIso8601String(),
|
||||||
|
if (projectId != null) 'project_id': projectId,
|
||||||
|
if (parentId != null) 'parent_id': parentId,
|
||||||
});
|
});
|
||||||
return Task.fromJson(response.data as Map<String, dynamic>);
|
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -48,6 +52,38 @@ class TasksApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getByProject(int projectId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/notes',
|
||||||
|
queryParameters: {
|
||||||
|
'project_id': projectId,
|
||||||
|
'is_task': 'true',
|
||||||
|
'limit': 500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['notes'] as List<dynamic>;
|
||||||
|
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/notes',
|
||||||
|
queryParameters: {'parent_id': parentId, 'is_task': 'true', 'limit': 100},
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['notes'] as List<dynamic>;
|
||||||
|
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.put('/api/tasks/$id', data: fields);
|
final response = await _dio.put('/api/tasks/$id', data: fields);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'message.dart';
|
||||||
|
|
||||||
|
class BriefingConversation {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String? briefingDate; // YYYY-MM-DD or null
|
||||||
|
final List<Message> messages;
|
||||||
|
|
||||||
|
const BriefingConversation({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.briefingDate,
|
||||||
|
required this.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||||
|
return BriefingConversation(
|
||||||
|
id: json['id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
briefingDate: json['briefing_date'] as String?,
|
||||||
|
messages: rawMessages
|
||||||
|
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BriefingConversation copyWith({List<Message>? messages}) =>
|
||||||
|
BriefingConversation(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
briefingDate: briefingDate,
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
class Milestone {
|
||||||
|
final int id;
|
||||||
|
final int projectId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final String status; // active | completed | archived
|
||||||
|
final int orderIndex;
|
||||||
|
final int total;
|
||||||
|
final int completed;
|
||||||
|
final double pct;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const Milestone({
|
||||||
|
required this.id,
|
||||||
|
required this.projectId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
required this.status,
|
||||||
|
required this.orderIndex,
|
||||||
|
required this.total,
|
||||||
|
required this.completed,
|
||||||
|
required this.pct,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Milestone.fromJson(Map<String, dynamic> json) => Milestone(
|
||||||
|
id: json['id'] as int,
|
||||||
|
projectId: json['project_id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
status: json['status'] as String? ?? 'active',
|
||||||
|
orderIndex: json['order_index'] as int? ?? 0,
|
||||||
|
total: json['total'] as int? ?? 0,
|
||||||
|
completed: json['completed'] as int? ?? 0,
|
||||||
|
pct: (json['pct'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ class Note {
|
|||||||
final int id;
|
final int id;
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
|
final List<String> tags;
|
||||||
|
final int? projectId;
|
||||||
|
final int? milestoneId;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
@@ -9,6 +12,9 @@ class Note {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.body,
|
required this.body,
|
||||||
|
required this.tags,
|
||||||
|
this.projectId,
|
||||||
|
this.milestoneId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -17,6 +23,12 @@ class Note {
|
|||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
body: json['body'] as String? ?? '',
|
body: json['body'] as String? ?? '',
|
||||||
|
tags: (json['tags'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
projectId: json['project_id'] as int?,
|
||||||
|
milestoneId: json['milestone_id'] as int?,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
);
|
);
|
||||||
@@ -24,13 +36,32 @@ class Note {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
'project_id': projectId,
|
||||||
|
'milestone_id': milestoneId,
|
||||||
};
|
};
|
||||||
|
|
||||||
Note copyWith({String? title, String? body}) => Note(
|
Note copyWith({
|
||||||
|
String? title,
|
||||||
|
String? body,
|
||||||
|
List<String>? tags,
|
||||||
|
Object? projectId = _undefined,
|
||||||
|
Object? milestoneId = _undefined,
|
||||||
|
}) =>
|
||||||
|
Note(
|
||||||
id: id,
|
id: id,
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
body: body ?? this.body,
|
body: body ?? this.body,
|
||||||
|
tags: tags ?? this.tags,
|
||||||
|
projectId: identical(projectId, _undefined)
|
||||||
|
? this.projectId
|
||||||
|
: projectId as int?,
|
||||||
|
milestoneId: identical(milestoneId, _undefined)
|
||||||
|
? this.milestoneId
|
||||||
|
: milestoneId as int?,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const _undefined = Object();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
class Project {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final String? goal;
|
||||||
|
final String status; // active | completed | archived
|
||||||
|
final String? color;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const Project({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.goal,
|
||||||
|
required this.status,
|
||||||
|
this.color,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
||||||
|
id: json['id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
goal: json['goal'] as String?,
|
||||||
|
status: json['status'] as String? ?? 'active',
|
||||||
|
color: json['color'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'goal': goal,
|
||||||
|
'status': status,
|
||||||
|
'color': color,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ class Task {
|
|||||||
final TaskStatus status;
|
final TaskStatus status;
|
||||||
final TaskPriority priority;
|
final TaskPriority priority;
|
||||||
final DateTime? dueDate;
|
final DateTime? dueDate;
|
||||||
|
final int? projectId;
|
||||||
|
final int? milestoneId;
|
||||||
|
final int? parentId;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
@@ -62,6 +65,9 @@ class Task {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.priority,
|
required this.priority,
|
||||||
this.dueDate,
|
this.dueDate,
|
||||||
|
this.projectId,
|
||||||
|
this.milestoneId,
|
||||||
|
this.parentId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -75,6 +81,9 @@ class Task {
|
|||||||
dueDate: json['due_date'] != null
|
dueDate: json['due_date'] != null
|
||||||
? DateTime.parse(json['due_date'] as String)
|
? DateTime.parse(json['due_date'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
projectId: json['project_id'] as int?,
|
||||||
|
milestoneId: json['milestone_id'] as int?,
|
||||||
|
parentId: json['parent_id'] as int?,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
);
|
);
|
||||||
@@ -85,6 +94,9 @@ class Task {
|
|||||||
'status': status.value,
|
'status': status.value,
|
||||||
'priority': priority.value,
|
'priority': priority.value,
|
||||||
'due_date': dueDate?.toIso8601String(),
|
'due_date': dueDate?.toIso8601String(),
|
||||||
|
'project_id': projectId,
|
||||||
|
'milestone_id': milestoneId,
|
||||||
|
'parent_id': parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
Task copyWith({
|
Task copyWith({
|
||||||
@@ -93,6 +105,9 @@ class Task {
|
|||||||
TaskStatus? status,
|
TaskStatus? status,
|
||||||
TaskPriority? priority,
|
TaskPriority? priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
Object? projectId = _undefined,
|
||||||
|
Object? milestoneId = _undefined,
|
||||||
|
Object? parentId = _undefined,
|
||||||
}) =>
|
}) =>
|
||||||
Task(
|
Task(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -101,7 +116,18 @@ class Task {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
priority: priority ?? this.priority,
|
priority: priority ?? this.priority,
|
||||||
dueDate: dueDate ?? this.dueDate,
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
projectId: identical(projectId, _undefined)
|
||||||
|
? this.projectId
|
||||||
|
: projectId as int?,
|
||||||
|
milestoneId: identical(milestoneId, _undefined)
|
||||||
|
? this.milestoneId
|
||||||
|
: milestoneId as int?,
|
||||||
|
parentId: identical(parentId, _undefined)
|
||||||
|
? this.parentId
|
||||||
|
: parentId as int?,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const _undefined = Object();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import '../api/milestones_api.dart';
|
||||||
|
import '../models/milestone.dart';
|
||||||
|
|
||||||
|
class MilestonesRepository {
|
||||||
|
final MilestonesApi _api;
|
||||||
|
const MilestonesRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
||||||
|
_api.getAll(projectId, status: status);
|
||||||
|
|
||||||
|
Future<Milestone> create(
|
||||||
|
int projectId, {
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
int orderIndex = 0,
|
||||||
|
}) =>
|
||||||
|
_api.create(projectId,
|
||||||
|
title: title, description: description, orderIndex: orderIndex);
|
||||||
|
|
||||||
|
Future<Milestone> update(
|
||||||
|
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
||||||
|
_api.update(projectId, milestoneId, fields);
|
||||||
|
|
||||||
|
Future<void> delete(int projectId, int milestoneId) =>
|
||||||
|
_api.delete(projectId, milestoneId);
|
||||||
|
}
|
||||||
@@ -7,9 +7,25 @@ class NotesRepository {
|
|||||||
|
|
||||||
Future<List<Note>> getAll() => _api.getAll();
|
Future<List<Note>> getAll() => _api.getAll();
|
||||||
Future<Note> getOne(int id) => _api.getOne(id);
|
Future<Note> getOne(int id) => _api.getOne(id);
|
||||||
Future<Note> create(String title, String body) =>
|
|
||||||
_api.create(title, body);
|
Future<Note> create(
|
||||||
Future<Note> update(int id, String title, String body) =>
|
String title,
|
||||||
_api.update(id, title, body);
|
String body, {
|
||||||
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
}) =>
|
||||||
|
_api.create(title, body, tags: tags, projectId: projectId);
|
||||||
|
|
||||||
|
Future<Note> update(
|
||||||
|
int id,
|
||||||
|
String title,
|
||||||
|
String body, {
|
||||||
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
bool clearProject = false,
|
||||||
|
}) =>
|
||||||
|
_api.update(id, title, body,
|
||||||
|
tags: tags, projectId: projectId, clearProject: clearProject);
|
||||||
|
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
Future<void> delete(int id) => _api.delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import '../api/projects_api.dart';
|
||||||
|
import '../models/project.dart';
|
||||||
|
|
||||||
|
class ProjectsRepository {
|
||||||
|
final ProjectsApi _api;
|
||||||
|
const ProjectsRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
|
||||||
|
Future<Project> getOne(int id) => _api.getOne(id);
|
||||||
|
Future<Project> create({
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
String? goal,
|
||||||
|
String? color,
|
||||||
|
}) =>
|
||||||
|
_api.create(
|
||||||
|
title: title, description: description, goal: goal, color: color);
|
||||||
|
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||||
|
_api.update(id, fields);
|
||||||
|
Future<void> delete(int id) => _api.delete(id);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ class TasksRepository {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
|
int? parentId,
|
||||||
}) =>
|
}) =>
|
||||||
_api.create(
|
_api.create(
|
||||||
title: title,
|
title: title,
|
||||||
@@ -21,8 +23,13 @@ class TasksRepository {
|
|||||||
status: status,
|
status: status,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
|
projectId: projectId,
|
||||||
|
parentId: parentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||||
_api.update(id, fields);
|
_api.update(id, fields);
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../data/api/api_client.dart';
|
import '../data/api/api_client.dart';
|
||||||
import '../data/api/auth_api.dart';
|
import '../data/api/auth_api.dart';
|
||||||
|
import '../data/api/briefing_api.dart';
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
|
import '../data/api/milestones_api.dart';
|
||||||
import '../data/api/notes_api.dart';
|
import '../data/api/notes_api.dart';
|
||||||
|
import '../data/api/projects_api.dart';
|
||||||
import '../data/api/quick_capture_api.dart';
|
import '../data/api/quick_capture_api.dart';
|
||||||
import '../data/api/tasks_api.dart';
|
import '../data/api/tasks_api.dart';
|
||||||
import '../data/repositories/auth_repository.dart';
|
import '../data/repositories/auth_repository.dart';
|
||||||
import '../data/repositories/chat_repository.dart';
|
import '../data/repositories/chat_repository.dart';
|
||||||
|
import '../data/repositories/milestones_repository.dart';
|
||||||
import '../data/repositories/notes_repository.dart';
|
import '../data/repositories/notes_repository.dart';
|
||||||
|
import '../data/repositories/projects_repository.dart';
|
||||||
import '../data/repositories/tasks_repository.dart';
|
import '../data/repositories/tasks_repository.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
@@ -45,6 +50,10 @@ final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
|
|||||||
return QuickCaptureApi(ref.watch(dioProvider));
|
return QuickCaptureApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||||
|
return ProjectsApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
return AuthRepository(ref.watch(authApiProvider));
|
return AuthRepository(ref.watch(authApiProvider));
|
||||||
});
|
});
|
||||||
@@ -60,3 +69,19 @@ final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
|||||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||||
return ChatRepository(ref.watch(chatApiProvider));
|
return ChatRepository(ref.watch(chatApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||||
|
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||||
|
return MilestonesApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||||
|
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||||
|
return BriefingApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,18 +4,15 @@ import 'api_client_provider.dart';
|
|||||||
|
|
||||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||||
|
|
||||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
|
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||||
return AuthNotifier(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
class AuthNotifier extends StateNotifier<AuthStatus> {
|
class AuthNotifier extends Notifier<AuthStatus> {
|
||||||
final Ref _ref;
|
@override
|
||||||
|
AuthStatus build() => AuthStatus.unknown;
|
||||||
AuthNotifier(this._ref) : super(AuthStatus.unknown);
|
|
||||||
|
|
||||||
Future<void> verify() async {
|
Future<void> verify() async {
|
||||||
try {
|
try {
|
||||||
final repo = _ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
final ok = await repo.verify();
|
final ok = await repo.verify();
|
||||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -24,14 +21,14 @@ class AuthNotifier extends StateNotifier<AuthStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> login(String username, String password) async {
|
Future<void> login(String username, String password) async {
|
||||||
final repo = _ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
await repo.login(username, password);
|
await repo.login(username, password);
|
||||||
state = AuthStatus.authenticated;
|
state = AuthStatus.authenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
final repo = _ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
await repo.logout();
|
await repo.logout();
|
||||||
} finally {
|
} finally {
|
||||||
state = AuthStatus.unauthenticated;
|
state = AuthStatus.unauthenticated;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/models/briefing_conversation.dart';
|
||||||
|
import '../data/models/message.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
|
/// Drives the loading indicator in BriefingScreen's reply area.
|
||||||
|
final isBriefingStreamingProvider =
|
||||||
|
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||||
|
|
||||||
|
class _BoolNotifier extends Notifier<bool> {
|
||||||
|
@override
|
||||||
|
bool build() => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final briefingProvider =
|
||||||
|
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
||||||
|
BriefingNotifier.new);
|
||||||
|
|
||||||
|
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||||
|
@override
|
||||||
|
Future<BriefingConversation> build() async {
|
||||||
|
return ref.read(briefingApiProvider).getToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
||||||
|
Future<void> refresh(String slot) async {
|
||||||
|
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a reply to today's briefing conversation.
|
||||||
|
///
|
||||||
|
/// Mirrors MessagesNotifier.sendMessage():
|
||||||
|
/// 1. Optimistic UI update
|
||||||
|
/// 2. POST message to chat endpoint
|
||||||
|
/// 3. SSE stream (best-effort)
|
||||||
|
/// 4. Poll until complete
|
||||||
|
Future<void> sendReply(String content) async {
|
||||||
|
final conv = state.value;
|
||||||
|
if (conv == null) return;
|
||||||
|
final convId = conv.id;
|
||||||
|
final chatApi = ref.read(chatApiProvider);
|
||||||
|
|
||||||
|
final previous = conv.messages;
|
||||||
|
final userMsg = Message(
|
||||||
|
conversationId: convId,
|
||||||
|
role: MessageRole.user,
|
||||||
|
content: content,
|
||||||
|
);
|
||||||
|
final placeholder = Message(
|
||||||
|
conversationId: convId,
|
||||||
|
role: MessageRole.assistant,
|
||||||
|
content: '',
|
||||||
|
status: 'generating',
|
||||||
|
);
|
||||||
|
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||||
|
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chatApi.sendMessage(convId, content);
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncData(conv.copyWith(messages: previous));
|
||||||
|
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE stream (best-effort)
|
||||||
|
bool streamedContent = false;
|
||||||
|
try {
|
||||||
|
await for (final chunk in chatApi.streamGeneration(convId)) {
|
||||||
|
streamedContent = true;
|
||||||
|
final current = state.value;
|
||||||
|
if (current == null) break;
|
||||||
|
final msgs = current.messages;
|
||||||
|
if (msgs.isEmpty) continue;
|
||||||
|
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
|
||||||
|
state = AsyncData(current.copyWith(
|
||||||
|
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to polling.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until complete (max 20 attempts, 2s apart)
|
||||||
|
try {
|
||||||
|
for (var attempt = 0; attempt < 20; attempt++) {
|
||||||
|
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||||
|
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
|
||||||
|
final done = fresh.any(
|
||||||
|
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||||
|
);
|
||||||
|
final hasContent = fresh.any(
|
||||||
|
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||||
|
);
|
||||||
|
final current = state.value;
|
||||||
|
if (current != null && (!streamedContent || done || hasContent)) {
|
||||||
|
state = AsyncData(current.copyWith(messages: fresh));
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Clear the generating placeholder so UI doesn't spin forever.
|
||||||
|
final current = state.value;
|
||||||
|
if (current != null) {
|
||||||
|
final msgs = current.messages;
|
||||||
|
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||||
|
state = AsyncData(current.copyWith(messages: [
|
||||||
|
...msgs.sublist(0, msgs.length - 1),
|
||||||
|
msgs.last.copyWith(status: 'complete'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,20 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
final captureQueueProvider =
|
final captureQueueProvider =
|
||||||
StateNotifierProvider<CaptureQueueNotifier, List<String>>(
|
NotifierProvider<CaptureQueueNotifier, List<String>>(
|
||||||
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
|
CaptureQueueNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
class CaptureQueueNotifier extends StateNotifier<List<String>> {
|
class CaptureQueueNotifier extends Notifier<List<String>> {
|
||||||
static const _key = 'capture_queue';
|
static const _key = 'capture_queue';
|
||||||
final SharedPreferences _prefs;
|
|
||||||
|
|
||||||
CaptureQueueNotifier(this._prefs)
|
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||||
: super(_prefs.getStringList(_key) ?? []);
|
|
||||||
|
@override
|
||||||
|
List<String> build() {
|
||||||
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
|
return prefs.getStringList(_key) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> enqueue(String text) async {
|
Future<void> enqueue(String text) async {
|
||||||
final updated = [...state, text];
|
final updated = [...state, text];
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/exceptions.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
import 'capture_queue_provider.dart';
|
||||||
|
import 'notes_provider.dart';
|
||||||
|
import 'tasks_provider.dart';
|
||||||
|
|
||||||
|
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||||
|
class CaptureResult {
|
||||||
|
final String message;
|
||||||
|
final bool isError;
|
||||||
|
const CaptureResult(this.message, {this.isError = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent capture result. UI watches this to show snackbars.
|
||||||
|
/// Reset to null by the notifier before each new item so listeners always fire.
|
||||||
|
final captureResultProvider =
|
||||||
|
NotifierProvider<_CaptureResultNotifier, CaptureResult?>(
|
||||||
|
_CaptureResultNotifier.new);
|
||||||
|
|
||||||
|
class _CaptureResultNotifier extends Notifier<CaptureResult?> {
|
||||||
|
@override
|
||||||
|
CaptureResult? build() => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory sequential work queue for quick captures.
|
||||||
|
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||||
|
final captureWorkQueueProvider =
|
||||||
|
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||||
|
CaptureWorkQueueNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||||
|
bool _running = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> build() => [];
|
||||||
|
|
||||||
|
/// Add text to the queue and start the drain loop if not already running.
|
||||||
|
void enqueue(String text) {
|
||||||
|
state = [...state, text];
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drain() async {
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
while (state.isNotEmpty) {
|
||||||
|
final text = state.first;
|
||||||
|
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||||
|
ref.read(captureResultProvider.notifier).state = null;
|
||||||
|
try {
|
||||||
|
final api = ref.read(quickCaptureApiProvider);
|
||||||
|
final result = await api.capture(text);
|
||||||
|
|
||||||
|
// Dequeue on success.
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
|
||||||
|
// Invalidate content providers so lists refresh.
|
||||||
|
switch (result.type) {
|
||||||
|
case 'note':
|
||||||
|
ref.invalidate(notesProvider);
|
||||||
|
case 'task':
|
||||||
|
case 'todo':
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish result for snackbar.
|
||||||
|
final msg = result.message.isNotEmpty
|
||||||
|
? result.message
|
||||||
|
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||||
|
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||||
|
} on NetworkException catch (_) {
|
||||||
|
// Persist to offline queue and stop draining — still offline.
|
||||||
|
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||||
|
"You're offline — capture saved and will retry automatically.",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} on AppException catch (e) {
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
ref.read(captureResultProvider.notifier).state =
|
||||||
|
CaptureResult(e.message, isError: true);
|
||||||
|
} catch (_) {
|
||||||
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
ref.read(captureResultProvider.notifier).state =
|
||||||
|
CaptureResult('Capture failed. Please try again.', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _typeLabel(String type) => switch (type) {
|
||||||
|
'note' => 'Note',
|
||||||
|
'task' => 'Task',
|
||||||
|
'event' => 'Event',
|
||||||
|
'todo' => 'To-do',
|
||||||
|
_ => type,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,8 +4,19 @@ import '../data/models/conversation.dart';
|
|||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
import 'api_client_provider.dart';
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
// Separate StateProvider so UI re-builds immediately when streaming starts/stops.
|
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
|
||||||
final isStreamingProvider = StateProvider.family<bool, int>((ref, _) => false);
|
final isStreamingProvider =
|
||||||
|
NotifierProvider.family<_IsStreamingNotifier, bool, int>(
|
||||||
|
(convId) => _IsStreamingNotifier(convId),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _IsStreamingNotifier extends Notifier<bool> {
|
||||||
|
// ignore: avoid_unused_constructor_parameters
|
||||||
|
_IsStreamingNotifier(int convId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool build() => false;
|
||||||
|
}
|
||||||
|
|
||||||
final conversationsProvider =
|
final conversationsProvider =
|
||||||
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
||||||
@@ -20,14 +31,14 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|||||||
Future<Conversation> create(String title) async {
|
Future<Conversation> create(String title) async {
|
||||||
final conv =
|
final conv =
|
||||||
await ref.read(chatRepositoryProvider).createConversation(title);
|
await ref.read(chatRepositoryProvider).createConversation(title);
|
||||||
state = AsyncData([conv, ...state.valueOrNull ?? []]);
|
state = AsyncData([conv, ...state.value ?? []]);
|
||||||
return conv;
|
return conv;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(int id) async {
|
Future<void> delete(int id) async {
|
||||||
await ref.read(chatRepositoryProvider).deleteConversation(id);
|
await ref.read(chatRepositoryProvider).deleteConversation(id);
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final c in state.valueOrNull ?? [])
|
for (final c in state.value ?? [])
|
||||||
if (c.id != id) c,
|
if (c.id != id) c,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -35,7 +46,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|||||||
// Called after a message is sent to patch the server-generated title
|
// Called after a message is sent to patch the server-generated title
|
||||||
// in-place without triggering a full reload or loading state.
|
// in-place without triggering a full reload or loading state.
|
||||||
void patchConversation(Conversation updated) {
|
void patchConversation(Conversation updated) {
|
||||||
final list = state.valueOrNull;
|
final list = state.value;
|
||||||
if (list == null) return;
|
if (list == null) return;
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final c in list)
|
for (final c in list)
|
||||||
@@ -44,21 +55,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier,
|
final messagesProvider =
|
||||||
List<Message>, int>(MessagesNotifier.new);
|
AsyncNotifierProvider.family<MessagesNotifier, List<Message>, int>(
|
||||||
|
(convId) => MessagesNotifier(convId),
|
||||||
|
);
|
||||||
|
|
||||||
|
class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||||
|
final int _convId;
|
||||||
|
MessagesNotifier(this._convId);
|
||||||
|
|
||||||
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
|
||||||
@override
|
@override
|
||||||
Future<List<Message>> build(int arg) async {
|
Future<List<Message>> build() async {
|
||||||
final (_, messages) =
|
final (_, messages) =
|
||||||
await ref.watch(chatRepositoryProvider).getMessages(arg);
|
await ref.watch(chatRepositoryProvider).getMessages(_convId);
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String content) async {
|
Future<void> sendMessage(String content) async {
|
||||||
final convId = arg;
|
final convId = _convId;
|
||||||
final repo = ref.read(chatRepositoryProvider);
|
final repo = ref.read(chatRepositoryProvider);
|
||||||
final previousMessages = state.valueOrNull ?? [];
|
final previousMessages = state.value ?? [];
|
||||||
|
|
||||||
// Optimistic UI: show the user message + assistant placeholder immediately.
|
// Optimistic UI: show the user message + assistant placeholder immediately.
|
||||||
final userMsg = Message(
|
final userMsg = Message(
|
||||||
@@ -132,7 +148,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Polling failed entirely — clear the generating placeholder so the UI
|
// Polling failed entirely — clear the generating placeholder so the UI
|
||||||
// doesn't spin forever.
|
// doesn't spin forever.
|
||||||
final msgs = state.valueOrNull;
|
final msgs = state.value;
|
||||||
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
|
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
...msgs.sublist(0, msgs.length - 1),
|
...msgs.sublist(0, msgs.length - 1),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/models/milestone.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
|
/// Fetches active milestones for a given project ID.
|
||||||
|
/// Keyed by projectId so each project gets its own cached list.
|
||||||
|
final projectMilestonesProvider =
|
||||||
|
FutureProvider.family<List<Milestone>, int>((ref, projectId) {
|
||||||
|
return ref.watch(milestonesRepositoryProvider).getAll(projectId);
|
||||||
|
});
|
||||||
@@ -12,18 +12,37 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
|||||||
return ref.watch(notesRepositoryProvider).getAll();
|
return ref.watch(notesRepositoryProvider).getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> create(String title, String body) async {
|
Future<Note> create(
|
||||||
final note =
|
String title,
|
||||||
await ref.read(notesRepositoryProvider).create(title, body);
|
String body, {
|
||||||
state = AsyncData([...state.valueOrNull ?? [], note]);
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
}) async {
|
||||||
|
final note = await ref
|
||||||
|
.read(notesRepositoryProvider)
|
||||||
|
.create(title, body, tags: tags, projectId: projectId);
|
||||||
|
state = AsyncData([...state.value ?? [], note]);
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> updateNote(int id, String title, String body) async {
|
Future<Note> updateNote(
|
||||||
final updated =
|
int id,
|
||||||
await ref.read(notesRepositoryProvider).update(id, title, body);
|
String title,
|
||||||
|
String body, {
|
||||||
|
List<String> tags = const [],
|
||||||
|
int? projectId,
|
||||||
|
bool clearProject = false,
|
||||||
|
}) async {
|
||||||
|
final updated = await ref.read(notesRepositoryProvider).update(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tags: tags,
|
||||||
|
projectId: projectId,
|
||||||
|
clearProject: clearProject,
|
||||||
|
);
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final n in state.valueOrNull ?? [])
|
for (final n in state.value ?? [])
|
||||||
if (n.id == id) updated else n,
|
if (n.id == id) updated else n,
|
||||||
]);
|
]);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -32,7 +51,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
|||||||
Future<void> delete(int id) async {
|
Future<void> delete(int id) async {
|
||||||
await ref.read(notesRepositoryProvider).delete(id);
|
await ref.read(notesRepositoryProvider).delete(id);
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final n in state.valueOrNull ?? [])
|
for (final n in state.value ?? [])
|
||||||
if (n.id != id) n,
|
if (n.id != id) n,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/models/project.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
|
final projectsProvider =
|
||||||
|
AsyncNotifierProvider<ProjectsNotifier, List<Project>>(
|
||||||
|
ProjectsNotifier.new);
|
||||||
|
|
||||||
|
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||||
|
@override
|
||||||
|
Future<List<Project>> build() async {
|
||||||
|
return ref.watch(projectsRepositoryProvider).getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> create({
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
String? goal,
|
||||||
|
String? color,
|
||||||
|
}) async {
|
||||||
|
final project = await ref.read(projectsRepositoryProvider).create(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
goal: goal,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
state = AsyncData([...state.value ?? [], project]);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> updateProject(int id, Map<String, dynamic> fields) async {
|
||||||
|
final updated =
|
||||||
|
await ref.read(projectsRepositoryProvider).update(id, fields);
|
||||||
|
state = AsyncData([
|
||||||
|
for (final p in state.value ?? [])
|
||||||
|
if (p.id == id) updated else p,
|
||||||
|
]);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await ref.read(projectsRepositoryProvider).delete(id);
|
||||||
|
state = AsyncData([
|
||||||
|
for (final p in state.value ?? [])
|
||||||
|
if (p.id != id) p,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,16 +15,14 @@ final cookiesPathProvider = Provider<String>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final themeModeProvider =
|
final themeModeProvider =
|
||||||
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
|
NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
|
||||||
final prefs = ref.watch(sharedPreferencesProvider);
|
|
||||||
return ThemeModeNotifier(prefs);
|
|
||||||
});
|
|
||||||
|
|
||||||
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
class ThemeModeNotifier extends Notifier<ThemeMode> {
|
||||||
final SharedPreferences _prefs;
|
@override
|
||||||
|
ThemeMode build() {
|
||||||
ThemeModeNotifier(this._prefs)
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
: super(_fromString(_prefs.getString(_kThemeMode)));
|
return _fromString(prefs.getString(_kThemeMode));
|
||||||
|
}
|
||||||
|
|
||||||
static ThemeMode _fromString(String? value) => switch (value) {
|
static ThemeMode _fromString(String? value) => switch (value) {
|
||||||
'light' => ThemeMode.light,
|
'light' => ThemeMode.light,
|
||||||
@@ -38,47 +36,49 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
|||||||
ThemeMode.dark => 'dark',
|
ThemeMode.dark => 'dark',
|
||||||
_ => 'system',
|
_ => 'system',
|
||||||
};
|
};
|
||||||
await _prefs.setString(_kThemeMode, value);
|
await ref.read(sharedPreferencesProvider).setString(_kThemeMode, value);
|
||||||
state = mode;
|
state = mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final forgejoRepoUrlProvider =
|
final forgejoRepoUrlProvider =
|
||||||
StateNotifierProvider<ForgejoRepoUrlNotifier, String?>((ref) {
|
NotifierProvider<ForgejoRepoUrlNotifier, String?>(
|
||||||
return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider));
|
ForgejoRepoUrlNotifier.new);
|
||||||
});
|
|
||||||
|
|
||||||
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
class ForgejoRepoUrlNotifier extends Notifier<String?> {
|
||||||
final SharedPreferences _prefs;
|
@override
|
||||||
ForgejoRepoUrlNotifier(this._prefs)
|
String? build() {
|
||||||
: super(_prefs.getString(_kForgejoRepoUrl));
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
|
return prefs.getString(_kForgejoRepoUrl) ??
|
||||||
|
'https://git.fabledsword.com/bvandeusen/FabledApp';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setUrl(String url) async {
|
Future<void> setUrl(String url) async {
|
||||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
await _prefs.setString(_kForgejoRepoUrl, clean);
|
await ref.read(sharedPreferencesProvider).setString(_kForgejoRepoUrl, clean);
|
||||||
state = clean;
|
state = clean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
|
final serverUrlProvider =
|
||||||
final prefs = ref.watch(sharedPreferencesProvider);
|
NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
|
||||||
return ServerUrlNotifier(prefs);
|
|
||||||
});
|
|
||||||
|
|
||||||
class ServerUrlNotifier extends StateNotifier<String?> {
|
class ServerUrlNotifier extends Notifier<String?> {
|
||||||
final SharedPreferences _prefs;
|
@override
|
||||||
|
String? build() {
|
||||||
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
|
return prefs.getString(_kServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setUrl(String url) async {
|
Future<void> setUrl(String url) async {
|
||||||
// Strip trailing slash
|
// Strip trailing slash
|
||||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
await _prefs.setString(_kServerUrl, clean);
|
await ref.read(sharedPreferencesProvider).setString(_kServerUrl, clean);
|
||||||
state = clean;
|
state = clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await _prefs.remove(_kServerUrl);
|
await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
|
|||||||
final tasksProvider =
|
final tasksProvider =
|
||||||
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
||||||
|
|
||||||
|
final projectTasksProvider =
|
||||||
|
FutureProvider.family<List<Task>, int>((ref, projectId) {
|
||||||
|
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||||
@override
|
@override
|
||||||
Future<List<Task>> build() async {
|
Future<List<Task>> build() async {
|
||||||
@@ -18,6 +23,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
}) async {
|
}) async {
|
||||||
final task = await ref.read(tasksRepositoryProvider).create(
|
final task = await ref.read(tasksRepositoryProvider).create(
|
||||||
title: title,
|
title: title,
|
||||||
@@ -25,15 +31,16 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
|||||||
status: status,
|
status: status,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
|
projectId: projectId,
|
||||||
);
|
);
|
||||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
state = AsyncData([...state.value ?? [], task]);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
|
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
|
||||||
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
|
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final t in state.valueOrNull ?? [])
|
for (final t in state.value ?? [])
|
||||||
if (t.id == id) updated else t,
|
if (t.id == id) updated else t,
|
||||||
]);
|
]);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -42,7 +49,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
|||||||
Future<void> delete(int id) async {
|
Future<void> delete(int id) async {
|
||||||
await ref.read(tasksRepositoryProvider).delete(id);
|
await ref.read(tasksRepositoryProvider).delete(id);
|
||||||
state = AsyncData([
|
state = AsyncData([
|
||||||
for (final t in state.valueOrNull ?? [])
|
for (final t in state.value ?? [])
|
||||||
if (t.id != id) t,
|
if (t.id != id) t,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:open_file/open_file.dart';
|
import 'package:open_file/open_file.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||||
|
|
||||||
@@ -51,7 +52,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
state = state.copyWith(status: UpdateStatus.checking);
|
state = state.copyWith(status: UpdateStatus.checking);
|
||||||
try {
|
try {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
final currentVersion = packageInfo.version;
|
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
||||||
|
final currentVersion =
|
||||||
|
'${packageInfo.version}.${packageInfo.buildNumber}';
|
||||||
|
|
||||||
// Parse repo URL → Forgejo API endpoint
|
// Parse repo URL → Forgejo API endpoint
|
||||||
final uri = Uri.parse(repoUrl);
|
final uri = Uri.parse(repoUrl);
|
||||||
@@ -100,6 +103,22 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
|
|
||||||
Future<void> downloadAndInstall() async {
|
Future<void> downloadAndInstall() async {
|
||||||
if (state.downloadUrl == null) return;
|
if (state.downloadUrl == null) return;
|
||||||
|
|
||||||
|
// Android 8+ requires explicit per-app "Install unknown apps" approval
|
||||||
|
// beyond the manifest declaration. Check and redirect to Settings if needed.
|
||||||
|
final installPermission = await Permission.requestInstallPackages.status;
|
||||||
|
if (!installPermission.isGranted) {
|
||||||
|
final result = await Permission.requestInstallPackages.request();
|
||||||
|
if (!result.isGranted) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: UpdateStatus.error,
|
||||||
|
errorMessage:
|
||||||
|
'Grant "Install unknown apps" permission for Fabled in Settings, then try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -117,16 +136,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await OpenFile.open(
|
final result = await OpenFile.open(
|
||||||
path,
|
path,
|
||||||
type: 'application/vnd.android.package-archive',
|
type: 'application/vnd.android.package-archive',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return to available so the user can retry install if they dismissed it.
|
if (result.type == ResultType.done) {
|
||||||
state = state.copyWith(
|
// Installer launched — reset to idle so the dialog closes naturally.
|
||||||
status: UpdateStatus.available,
|
state = const UpdateState();
|
||||||
downloadProgress: 1.0,
|
} else {
|
||||||
);
|
state = state.copyWith(
|
||||||
|
status: UpdateStatus.error,
|
||||||
|
errorMessage: 'Could not open installer: ${result.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: UpdateStatus.error,
|
status: UpdateStatus.error,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
if (mounted) context.go(Routes.notes);
|
if (mounted) context.go(Routes.briefing);
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
cookieJar: ref.read(cookieJarProvider),
|
cookieJar: ref.read(cookieJarProvider),
|
||||||
onSuccess: () async {
|
onSuccess: () async {
|
||||||
await ref.read(authProvider.notifier).verify();
|
await ref.read(authProvider.notifier).verify();
|
||||||
if (mounted) context.go(Routes.notes);
|
if (mounted) context.go(Routes.briefing);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../data/models/briefing_conversation.dart';
|
||||||
|
import '../../data/models/message.dart';
|
||||||
|
import '../../providers/api_client_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
|
||||||
|
class BriefingHistoryScreen extends ConsumerWidget {
|
||||||
|
const BriefingHistoryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final historyAsync = ref.watch(_briefingHistoryProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Past Briefings')),
|
||||||
|
body: historyAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) =>
|
||||||
|
const Center(child: Text('Could not load briefing history.')),
|
||||||
|
data: (convs) {
|
||||||
|
if (convs.isEmpty) {
|
||||||
|
return const Center(child: Text('No past briefings.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: convs.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final conv = convs[i];
|
||||||
|
final label = conv.briefingDate ?? conv.title;
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.wb_sunny_outlined),
|
||||||
|
title: Text(label),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => _BriefingDetailScreen(conv: conv),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazily loads and displays all messages for a past briefing.
|
||||||
|
class _BriefingDetailScreen extends ConsumerWidget {
|
||||||
|
final BriefingConversation conv;
|
||||||
|
const _BriefingDetailScreen({required this.conv});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
|
||||||
|
body: messagesAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) => const Center(child: Text('Could not load messages.')),
|
||||||
|
data: (messages) {
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return const Center(child: Text('No messages.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
|
itemCount: messages.length,
|
||||||
|
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||||
|
|
||||||
|
final _briefingHistoryProvider =
|
||||||
|
FutureProvider<List<BriefingConversation>>((ref) async {
|
||||||
|
return ref.watch(briefingApiProvider).getHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
final _briefingMessagesProvider =
|
||||||
|
FutureProvider.family<List<Message>, int>((ref, convId) async {
|
||||||
|
return ref.watch(briefingApiProvider).getMessages(convId);
|
||||||
|
});
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../providers/briefing_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
import 'briefing_history_screen.dart';
|
||||||
|
|
||||||
|
class BriefingScreen extends ConsumerStatefulWidget {
|
||||||
|
const BriefingScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendReply() async {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
_controller.clear();
|
||||||
|
try {
|
||||||
|
await ref.read(briefingProvider.notifier).sendReply(text);
|
||||||
|
} on AppException catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Failed to send reply.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
setState(() => _refreshing = true);
|
||||||
|
try {
|
||||||
|
await ref.read(briefingProvider.notifier).refresh('compilation');
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not generate briefing.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _refreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final briefingAsync = ref.watch(briefingProvider);
|
||||||
|
final isStreaming = ref.watch(isBriefingStreamingProvider);
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change
|
||||||
|
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
Text(
|
||||||
|
_todayLabel(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_refreshing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
|
tooltip: 'Generate briefing',
|
||||||
|
onPressed: _refresh,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'history') {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => const BriefingHistoryScreen(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'history',
|
||||||
|
child: Text('View past briefings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: briefingAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text("Could not load today's briefing."),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => ref.invalidate(briefingProvider),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (conv) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
if (conv.messages.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'No briefing yet today.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _refresh,
|
||||||
|
child: const Text('Generate now'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 8),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: conv.messages.length,
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
ChatMessageBubble(message: conv.messages[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress bar while streaming
|
||||||
|
if (isStreaming)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
minHeight: 2,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Reply bar
|
||||||
|
const Divider(height: 1),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Reply to your briefing…',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 10),
|
||||||
|
),
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
enabled: !isStreaming,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_GradientSendButton(
|
||||||
|
onPressed: isStreaming ? null : _sendReply,
|
||||||
|
isStreaming: isStreaming,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _todayLabel() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
const days = [
|
||||||
|
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||||
|
'Friday', 'Saturday', 'Sunday'
|
||||||
|
];
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GradientSendButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool isStreaming;
|
||||||
|
|
||||||
|
const _GradientSendButton({
|
||||||
|
required this.onPressed,
|
||||||
|
required this.isStreaming,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final disabled = onPressed == null;
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: disabled
|
||||||
|
? null
|
||||||
|
: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||||
|
),
|
||||||
|
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: isStreaming
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.send,
|
||||||
|
color: disabled
|
||||||
|
? scheme.onSurface.withValues(alpha: 0.38)
|
||||||
|
: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import 'dart:math' show min;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
import '../../data/models/message.dart';
|
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
|
||||||
class ChatScreen extends ConsumerStatefulWidget {
|
class ChatScreen extends ConsumerStatefulWidget {
|
||||||
final int conversationId;
|
final int conversationId;
|
||||||
@@ -73,7 +70,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
|
|
||||||
final convTitle = ref
|
final convTitle = ref
|
||||||
.watch(conversationsProvider)
|
.watch(conversationsProvider)
|
||||||
.valueOrNull
|
.value
|
||||||
?.where((c) => c.id == widget.conversationId)
|
?.where((c) => c.id == widget.conversationId)
|
||||||
.firstOrNull
|
.firstOrNull
|
||||||
?.title;
|
?.title;
|
||||||
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
horizontal: 8, vertical: 12),
|
horizontal: 8, vertical: 12),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, i) =>
|
itemBuilder: (context, i) =>
|
||||||
_MessageBubble(message: messages[i]),
|
ChatMessageBubble(message: messages[i]),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -153,42 +150,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MessageBubble extends StatelessWidget {
|
|
||||||
final Message message;
|
|
||||||
const _MessageBubble({required this.message});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isUser = message.role == MessageRole.user;
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Align(
|
|
||||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480),
|
|
||||||
),
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: const Radius.circular(16),
|
|
||||||
topRight: const Radius.circular(16),
|
|
||||||
bottomLeft: Radius.circular(isUser ? 16 : 4),
|
|
||||||
bottomRight: Radius.circular(isUser ? 4 : 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isUser
|
|
||||||
? Text(
|
|
||||||
message.content,
|
|
||||||
style: TextStyle(color: scheme.onPrimary),
|
|
||||||
)
|
|
||||||
: MarkdownBody(
|
|
||||||
data: message.content.isEmpty ? '...' : message.content,
|
|
||||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
|
||||||
import '../../core/exceptions.dart';
|
|
||||||
import '../../providers/chat_provider.dart';
|
|
||||||
import 'chat_screen.dart';
|
|
||||||
|
|
||||||
class ConversationsListScreen extends ConsumerStatefulWidget {
|
|
||||||
const ConversationsListScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<ConversationsListScreen> createState() =>
|
|
||||||
_ConversationsListScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConversationsListScreenState
|
|
||||||
extends ConsumerState<ConversationsListScreen> {
|
|
||||||
int? _selectedConvId;
|
|
||||||
|
|
||||||
Future<void> _newConversation(bool isWide) async {
|
|
||||||
try {
|
|
||||||
final conv = await ref.read(conversationsProvider.notifier).create('');
|
|
||||||
if (!mounted) return;
|
|
||||||
if (isWide) {
|
|
||||||
setState(() => _selectedConvId = conv.id);
|
|
||||||
} else {
|
|
||||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
||||||
}
|
|
||||||
} on AppException catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final convsAsync = ref.watch(conversationsProvider);
|
|
||||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
|
||||||
|
|
||||||
// Clear stale selection when switching to narrow mode.
|
|
||||||
if (!isWide && _selectedConvId != null) {
|
|
||||||
_selectedConvId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: isWide
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 300,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildListPane(convsAsync, isWide)),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.add),
|
|
||||||
title: const Text('New conversation'),
|
|
||||||
onTap: () => _newConversation(isWide),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const VerticalDivider(width: 1),
|
|
||||||
Expanded(child: _buildDetailPane()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: _buildListPane(convsAsync, isWide),
|
|
||||||
floatingActionButton: isWide
|
|
||||||
? null
|
|
||||||
: FloatingActionButton(
|
|
||||||
heroTag: 'chat_fab',
|
|
||||||
onPressed: () => _newConversation(isWide),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailPane() {
|
|
||||||
if (_selectedConvId == null) {
|
|
||||||
return const Center(child: Text('Select a conversation to open it.'));
|
|
||||||
}
|
|
||||||
return ChatScreen(
|
|
||||||
key: ValueKey(_selectedConvId),
|
|
||||||
conversationId: _selectedConvId!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
|
|
||||||
return convsAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, _) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_off, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Could not load conversations.'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => ref.invalidate(conversationsProvider),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (convs) {
|
|
||||||
if (convs.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('No conversations yet. Tap + to start one.'));
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () => ref.refresh(conversationsProvider.future),
|
|
||||||
child: ListView.separated(
|
|
||||||
itemCount: convs.length,
|
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final conv = convs[i];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
|
||||||
title: Text(
|
|
||||||
conv.title.isNotEmpty ? conv.title : 'New conversation',
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
conv.updatedAt.toLocal().toString().substring(0, 16),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
selected: isWide && _selectedConvId == conv.id,
|
|
||||||
selectedTileColor:
|
|
||||||
Theme.of(context).colorScheme.secondaryContainer,
|
|
||||||
onTap: () {
|
|
||||||
if (isWide) {
|
|
||||||
setState(() => _selectedConvId = conv.id);
|
|
||||||
} else {
|
|
||||||
context.push(
|
|
||||||
Routes.chat.replaceFirst(':id', '${conv.id}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: () async {
|
|
||||||
final confirm = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => AlertDialog(
|
|
||||||
title: const Text('Delete conversation?'),
|
|
||||||
content: Text(
|
|
||||||
conv.title.isNotEmpty
|
|
||||||
? conv.title
|
|
||||||
: 'New conversation',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(dialogContext, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(dialogContext, true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirm == true) {
|
|
||||||
await ref
|
|
||||||
.read(conversationsProvider.notifier)
|
|
||||||
.delete(conv.id);
|
|
||||||
if (mounted && _selectedConvId == conv.id) {
|
|
||||||
setState(() => _selectedConvId = null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
|
import '../../providers/chat_provider.dart';
|
||||||
|
|
||||||
|
class ConversationsTabScreen extends ConsumerWidget {
|
||||||
|
const ConversationsTabScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final convsAsync = ref.watch(conversationsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: 'New conversation',
|
||||||
|
onPressed: () async {
|
||||||
|
final conv = await ref
|
||||||
|
.read(conversationsProvider.notifier)
|
||||||
|
.create('New conversation');
|
||||||
|
if (context.mounted) {
|
||||||
|
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: convsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (convs) {
|
||||||
|
if (convs.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.chat_bubble_outline,
|
||||||
|
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('No conversations yet',
|
||||||
|
style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Start a conversation'),
|
||||||
|
onPressed: () async {
|
||||||
|
final conv = await ref
|
||||||
|
.read(conversationsProvider.notifier)
|
||||||
|
.create('New conversation');
|
||||||
|
if (context.mounted) {
|
||||||
|
context.push(
|
||||||
|
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(conversationsProvider),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: convs.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final c = convs[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
|
title: Text(c.title,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
_relativeTime(c.updatedAt),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () =>
|
||||||
|
_confirmDelete(context, ref, c.id, c.title),
|
||||||
|
),
|
||||||
|
onTap: () =>
|
||||||
|
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context, WidgetRef ref, int id, String title) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Delete conversation?'),
|
||||||
|
content: Text('"$title" will be permanently deleted.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
await ref.read(conversationsProvider.notifier).delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relativeTime(DateTime dt) {
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inMinutes < 1) return 'just now';
|
||||||
|
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return '${dt.day}/${dt.month}/${dt.year}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
|
import '../../data/models/note.dart';
|
||||||
|
import '../../data/models/task.dart';
|
||||||
|
import '../../providers/notes_provider.dart';
|
||||||
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../widgets/library_item_card.dart';
|
||||||
|
|
||||||
|
enum _LibraryFilter { all, notes, tasks, projects }
|
||||||
|
|
||||||
|
enum _TaskStatusFilter { all, todo, inProgress, done }
|
||||||
|
|
||||||
|
class LibraryScreen extends ConsumerStatefulWidget {
|
||||||
|
const LibraryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
|
||||||
|
_LibraryFilter _filter = _LibraryFilter.all;
|
||||||
|
_TaskStatusFilter _taskStatus = _TaskStatusFilter.all;
|
||||||
|
bool _searchActive = false;
|
||||||
|
String _searchQuery = '';
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesSearch(String text) {
|
||||||
|
if (_searchQuery.isEmpty) return true;
|
||||||
|
return text.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final notesAsync = ref.watch(notesProvider);
|
||||||
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
|
final projectsAsync = ref.watch(projectsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: _searchActive
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Search…',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (q) => setState(() => _searchQuery = q),
|
||||||
|
)
|
||||||
|
: Text('Library', style: theme.textTheme.titleLarge),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_searchActive = !_searchActive;
|
||||||
|
if (!_searchActive) {
|
||||||
|
_searchQuery = '';
|
||||||
|
_searchController.clear();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// ── Filter pills ──────────────────────────────────────────────────
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||||
|
child: Row(
|
||||||
|
children: _LibraryFilter.values.map((f) {
|
||||||
|
final label = switch (f) {
|
||||||
|
_LibraryFilter.all => 'All',
|
||||||
|
_LibraryFilter.notes => 'Notes',
|
||||||
|
_LibraryFilter.tasks => 'Tasks',
|
||||||
|
_LibraryFilter.projects => 'Projects',
|
||||||
|
};
|
||||||
|
final selected = _filter == f;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => setState(() {
|
||||||
|
_filter = f;
|
||||||
|
_taskStatus = _TaskStatusFilter.all;
|
||||||
|
}),
|
||||||
|
selectedColor:
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.18),
|
||||||
|
checkmarkColor: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Task status sub-filter (Tasks pill only) ───────────────────
|
||||||
|
if (_filter == _LibraryFilter.tasks)
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||||
|
child: Row(
|
||||||
|
children: _TaskStatusFilter.values.map((s) {
|
||||||
|
final label = switch (s) {
|
||||||
|
_TaskStatusFilter.all => 'All',
|
||||||
|
_TaskStatusFilter.todo => 'To Do',
|
||||||
|
_TaskStatusFilter.inProgress => 'In Progress',
|
||||||
|
_TaskStatusFilter.done => 'Done',
|
||||||
|
};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: _taskStatus == s,
|
||||||
|
onSelected: (_) => setState(() => _taskStatus = s),
|
||||||
|
selectedColor:
|
||||||
|
theme.colorScheme.secondary.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// ── Content ───────────────────────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: switch (_filter) {
|
||||||
|
_LibraryFilter.notes => _buildNotesList(notesAsync),
|
||||||
|
_LibraryFilter.tasks => _buildTasksList(tasksAsync),
|
||||||
|
_LibraryFilter.projects => _buildProjectsList(projectsAsync),
|
||||||
|
_LibraryFilter.all => _buildAllList(notesAsync, tasksAsync),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showCreateSheet(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotesList(AsyncValue<List<Note>> notesAsync) {
|
||||||
|
return notesAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (notes) {
|
||||||
|
final filtered = notes
|
||||||
|
.where((n) => _matchesSearch(n.title) || _matchesSearch(n.body))
|
||||||
|
.toList();
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return const Center(child: Text('No notes found'));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(notesProvider),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTasksList(AsyncValue<List<Task>> tasksAsync) {
|
||||||
|
return tasksAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (tasks) {
|
||||||
|
var filtered = tasks.where((t) => _matchesSearch(t.title)).toList();
|
||||||
|
if (_taskStatus != _TaskStatusFilter.all) {
|
||||||
|
final status = switch (_taskStatus) {
|
||||||
|
_TaskStatusFilter.todo => TaskStatus.todo,
|
||||||
|
_TaskStatusFilter.inProgress => TaskStatus.inProgress,
|
||||||
|
_TaskStatusFilter.done => TaskStatus.done,
|
||||||
|
_ => TaskStatus.todo,
|
||||||
|
};
|
||||||
|
filtered = filtered.where((t) => t.status == status).toList();
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return const Center(child: Text('No tasks found'));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(tasksProvider),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProjectsList(AsyncValue<List<dynamic>> projectsAsync) {
|
||||||
|
return projectsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (projects) {
|
||||||
|
if (projects.isEmpty) {
|
||||||
|
return const Center(child: Text('No projects'));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(projectsProvider),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: projects.length,
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
ProjectLibraryCard(project: projects[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAllList(
|
||||||
|
AsyncValue<List<Note>> notesAsync,
|
||||||
|
AsyncValue<List<Task>> tasksAsync,
|
||||||
|
) {
|
||||||
|
final notes = notesAsync.value ?? [];
|
||||||
|
final tasks = tasksAsync.value ?? [];
|
||||||
|
|
||||||
|
// Merge and sort by updatedAt desc
|
||||||
|
final items = <(DateTime, Widget)>[];
|
||||||
|
for (final n in notes) {
|
||||||
|
if (_matchesSearch(n.title) || _matchesSearch(n.body)) {
|
||||||
|
items.add((n.updatedAt, NoteLibraryCard(note: n)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final t in tasks) {
|
||||||
|
if (_matchesSearch(t.title)) {
|
||||||
|
items.add((t.updatedAt, TaskLibraryCard(task: t)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => b.$1.compareTo(a.$1));
|
||||||
|
|
||||||
|
if (notesAsync.isLoading || tasksAsync.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const Center(child: Text('Nothing here yet'));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
ref.invalidate(notesProvider);
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (_, i) => items[i].$2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateSheet(BuildContext context) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.article_outlined),
|
||||||
|
title: const Text('New note'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push(Routes.noteNew);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.check_box_outlined),
|
||||||
|
title: const Text('New task'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push(Routes.taskNew);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
|
import '../../data/models/milestone.dart';
|
||||||
|
import '../../data/models/project.dart';
|
||||||
|
import '../../data/models/task.dart';
|
||||||
|
import '../../providers/api_client_provider.dart';
|
||||||
|
import '../../providers/milestones_provider.dart';
|
||||||
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
|
||||||
|
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||||
|
final int projectId;
|
||||||
|
const ProjectTasksScreen({super.key, required this.projectId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||||
|
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
|
||||||
|
final Map<int, TaskStatus> _pendingStatus = {};
|
||||||
|
|
||||||
|
TaskStatus _effectiveStatus(Task task) =>
|
||||||
|
_pendingStatus[task.id] ?? task.status;
|
||||||
|
|
||||||
|
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
|
||||||
|
TaskStatus.todo => TaskStatus.inProgress,
|
||||||
|
TaskStatus.inProgress => TaskStatus.done,
|
||||||
|
TaskStatus.done => TaskStatus.todo,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> _cycleStatus(Task task) async {
|
||||||
|
final current = _effectiveStatus(task);
|
||||||
|
final next = _nextStatus(current);
|
||||||
|
setState(() => _pendingStatus[task.id] = next);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(tasksRepositoryProvider)
|
||||||
|
.update(task.id, {'status': next.value});
|
||||||
|
// Sync the global tasks list so the library view stays consistent.
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
} catch (_) {
|
||||||
|
// Revert optimistic change on error.
|
||||||
|
setState(() => _pendingStatus.remove(task.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseColor(String? hex) {
|
||||||
|
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
||||||
|
try {
|
||||||
|
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||||
|
} catch (_) {
|
||||||
|
return const Color(0xFF6366F1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
|
||||||
|
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
|
||||||
|
final project = ref.watch(projectsProvider).value
|
||||||
|
?.whereType<Project>()
|
||||||
|
.where((p) => p.id == widget.projectId)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
final color = _parseColor(project?.color);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
project?.title ?? 'Project',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (project?.description?.isNotEmpty == true)
|
||||||
|
Text(
|
||||||
|
project!.description!,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: tasksAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
|
||||||
|
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(
|
||||||
|
BuildContext context,
|
||||||
|
List<Task> tasks,
|
||||||
|
AsyncValue<List<Milestone>> milestonesAsync,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
final milestones = (milestonesAsync.value ?? []).toList()
|
||||||
|
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||||
|
|
||||||
|
// Group tasks by milestoneId.
|
||||||
|
final byMilestone = <int?, List<Task>>{};
|
||||||
|
for (final t in tasks) {
|
||||||
|
(byMilestone[t.milestoneId] ??= []).add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
final unassigned = byMilestone[null] ?? [];
|
||||||
|
|
||||||
|
if (tasks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_box_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No tasks in this project yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
setState(() => _pendingStatus.clear());
|
||||||
|
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||||
|
ref.invalidate(projectMilestonesProvider(widget.projectId));
|
||||||
|
},
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// Top colour strip.
|
||||||
|
SliverToBoxAdapter(child: Container(height: 4, color: color)),
|
||||||
|
|
||||||
|
// Milestone sections.
|
||||||
|
for (final ms in milestones) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _MilestoneHeader(
|
||||||
|
milestone: ms,
|
||||||
|
tasks: byMilestone[ms.id] ?? [],
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((byMilestone[ms.id] ?? []).isNotEmpty)
|
||||||
|
SliverList.builder(
|
||||||
|
itemCount: byMilestone[ms.id]!.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final task = byMilestone[ms.id]![i];
|
||||||
|
return _TaskRow(
|
||||||
|
task: task,
|
||||||
|
effectiveStatus: _effectiveStatus(task),
|
||||||
|
onStatusTap: () => _cycleStatus(task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unassigned tasks.
|
||||||
|
if (unassigned.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||||
|
child: Text(
|
||||||
|
'No milestone',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList.builder(
|
||||||
|
itemCount: unassigned.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final task = unassigned[i];
|
||||||
|
return _TaskRow(
|
||||||
|
task: task,
|
||||||
|
effectiveStatus: _effectiveStatus(task),
|
||||||
|
onStatusTap: () => _cycleStatus(task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestone section header ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _MilestoneHeader extends StatelessWidget {
|
||||||
|
final Milestone milestone;
|
||||||
|
final List<Task> tasks;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _MilestoneHeader({
|
||||||
|
required this.milestone,
|
||||||
|
required this.tasks,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final done = tasks.where((t) => t.status == TaskStatus.done).length;
|
||||||
|
final total = tasks.length;
|
||||||
|
final pct = total > 0 ? done / total : 0.0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
milestone.title,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'$done / $total',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (total > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: pct,
|
||||||
|
minHeight: 2,
|
||||||
|
color: color,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task row ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TaskRow extends StatelessWidget {
|
||||||
|
final Task task;
|
||||||
|
final TaskStatus effectiveStatus;
|
||||||
|
final VoidCallback onStatusTap;
|
||||||
|
|
||||||
|
const _TaskRow({
|
||||||
|
required this.task,
|
||||||
|
required this.effectiveStatus,
|
||||||
|
required this.onStatusTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
IconData get _statusIcon => switch (effectiveStatus) {
|
||||||
|
TaskStatus.done => Icons.check_circle,
|
||||||
|
TaskStatus.inProgress => Icons.timelapse,
|
||||||
|
TaskStatus.todo => Icons.radio_button_unchecked,
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _statusColor(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return switch (effectiveStatus) {
|
||||||
|
TaskStatus.done => const Color(0xFF22C55E),
|
||||||
|
TaskStatus.inProgress => cs.primary,
|
||||||
|
TaskStatus.todo => cs.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _priorityColor(BuildContext context) => switch (task.priority) {
|
||||||
|
TaskPriority.high => const Color(0xFFEF4444),
|
||||||
|
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||||
|
_ => Colors.transparent,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context
|
||||||
|
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||||
|
onPressed: onStatusTap,
|
||||||
|
tooltip: 'Cycle status',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
decoration: effectiveStatus == TaskStatus.done
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
color: effectiveStatus == TaskStatus.done
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (task.dueDate != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Due ${_formatDate(task.dueDate!)}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: task.dueDate!.isBefore(DateTime.now()) &&
|
||||||
|
effectiveStatus != TaskStatus.done
|
||||||
|
? const Color(0xFFEF4444)
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.priority == TaskPriority.high ||
|
||||||
|
task.priority == TaskPriority.medium)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _priorityColor(context),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
return '${months[dt.month - 1]} ${dt.day}';
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ class NoteDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final noteAsync = ref.watch(noteDetailProvider(noteId));
|
final noteAsync = ref.watch(noteDetailProvider(noteId));
|
||||||
final allNotes = ref.watch(notesProvider).valueOrNull ?? [];
|
final allNotes = ref.watch(notesProvider).value ?? [];
|
||||||
|
|
||||||
void navigateByTitle(String title) {
|
void navigateByTitle(String title) {
|
||||||
final matches = allNotes.where(
|
final matches = allNotes.where(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import '../../core/exceptions.dart';
|
|||||||
import '../../core/wikilink_syntax.dart';
|
import '../../core/wikilink_syntax.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/notes_provider.dart';
|
import '../../providers/notes_provider.dart';
|
||||||
|
import '../../widgets/project_selector.dart';
|
||||||
|
|
||||||
class NoteEditScreen extends ConsumerStatefulWidget {
|
class NoteEditScreen extends ConsumerStatefulWidget {
|
||||||
final int? noteId;
|
final int? noteId;
|
||||||
@@ -19,10 +20,12 @@ class NoteEditScreen extends ConsumerStatefulWidget {
|
|||||||
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||||
final _titleController = TextEditingController();
|
final _titleController = TextEditingController();
|
||||||
final _contentController = TextEditingController();
|
final _contentController = TextEditingController();
|
||||||
|
final _tagController = TextEditingController();
|
||||||
|
List<String> _tags = [];
|
||||||
|
int? _projectId;
|
||||||
bool _preview = false;
|
bool _preview = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
// Future is created once in initState so FutureBuilder never restarts it.
|
|
||||||
late final Future<void> _initFuture;
|
late final Future<void> _initFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_contentController.dispose();
|
_contentController.dispose();
|
||||||
|
_tagController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,24 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
||||||
_titleController.text = note.title;
|
_titleController.text = note.title;
|
||||||
_contentController.text = note.body;
|
_contentController.text = note.body;
|
||||||
|
_tags = List<String>.from(note.tags);
|
||||||
|
_projectId = note.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addTag(String raw) {
|
||||||
|
final tag = raw.trim().replaceAll(',', '').toLowerCase();
|
||||||
|
if (tag.isEmpty || _tags.contains(tag)) {
|
||||||
|
_tagController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_tags = [..._tags, tag];
|
||||||
|
_tagController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeTag(String tag) {
|
||||||
|
setState(() => _tags = _tags.where((t) => t != tag).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
@@ -81,12 +103,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
if (widget.noteId == null) {
|
if (widget.noteId == null) {
|
||||||
await ref.read(notesProvider.notifier).create(title, body);
|
await ref.read(notesProvider.notifier).create(
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tags: _tags,
|
||||||
|
projectId: _projectId,
|
||||||
|
);
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await ref.read(notesProvider.notifier).updateNote(
|
||||||
.read(notesProvider.notifier)
|
widget.noteId!,
|
||||||
.updateNote(widget.noteId!, title, body);
|
title,
|
||||||
|
body,
|
||||||
|
tags: _tags,
|
||||||
|
projectId: _projectId,
|
||||||
|
clearProject: _projectId == null,
|
||||||
|
);
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
}
|
}
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
@@ -147,7 +179,23 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: _TagInput(
|
||||||
|
tags: _tags,
|
||||||
|
controller: _tagController,
|
||||||
|
onAdd: _addTag,
|
||||||
|
onRemove: _removeTag,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: ProjectSelector(
|
||||||
|
value: _projectId,
|
||||||
|
onChanged: (id) => setState(() => _projectId = id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _preview
|
child: _preview
|
||||||
? Markdown(
|
? Markdown(
|
||||||
@@ -177,3 +225,57 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TagInput extends StatelessWidget {
|
||||||
|
final List<String> tags;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onAdd;
|
||||||
|
final ValueChanged<String> onRemove;
|
||||||
|
|
||||||
|
const _TagInput({
|
||||||
|
required this.tags,
|
||||||
|
required this.controller,
|
||||||
|
required this.onAdd,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
...tags.map(
|
||||||
|
(tag) => Chip(
|
||||||
|
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 14),
|
||||||
|
onDeleted: () => onRemove(tag),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Add tag…',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(vertical: 4),
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: onAdd,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v.endsWith(',') || v.endsWith(' ')) {
|
||||||
|
onAdd(v.replaceAll(RegExp(r'[, ]+$'), ''));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
|
||||||
import '../../data/models/note.dart';
|
|
||||||
import '../../providers/notes_provider.dart';
|
|
||||||
import 'note_detail_screen.dart';
|
|
||||||
|
|
||||||
class NotesListScreen extends ConsumerStatefulWidget {
|
|
||||||
const NotesListScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<NotesListScreen> createState() => _NotesListScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotesListScreenState extends ConsumerState<NotesListScreen> {
|
|
||||||
bool _showSearch = false;
|
|
||||||
String _search = '';
|
|
||||||
int? _selectedNoteId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final notesAsync = ref.watch(notesProvider);
|
|
||||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
|
||||||
|
|
||||||
// Clear stale selection when switching to narrow mode.
|
|
||||||
if (!isWide && _selectedNoteId != null) {
|
|
||||||
_selectedNoteId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: isWide
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 300,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildListPane(notesAsync, isWide)),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.add),
|
|
||||||
title: const Text('New note'),
|
|
||||||
onTap: () => context.push(Routes.noteNew),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const VerticalDivider(width: 1),
|
|
||||||
Expanded(child: _buildDetailPane()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: _buildListPane(notesAsync, isWide),
|
|
||||||
floatingActionButton: isWide
|
|
||||||
? null
|
|
||||||
: FloatingActionButton(
|
|
||||||
heroTag: 'notes_fab',
|
|
||||||
onPressed: () => context.push(Routes.noteNew),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailPane() {
|
|
||||||
if (_selectedNoteId == null) {
|
|
||||||
return const Center(child: Text('Select a note to read it.'));
|
|
||||||
}
|
|
||||||
return NoteDetailScreen(
|
|
||||||
key: ValueKey(_selectedNoteId),
|
|
||||||
noteId: _selectedNoteId!,
|
|
||||||
onDeleted: () => setState(() => _selectedNoteId = null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildListPane(AsyncValue<List<Note>> notesAsync, bool isWide) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
if (_showSearch)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Search notes…',
|
|
||||||
border: InputBorder.none,
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
),
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _search = v.trim().toLowerCase()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: 'Close search',
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_showSearch = false;
|
|
||||||
_search = '';
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: notesAsync.when(
|
|
||||||
loading: () =>
|
|
||||||
const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, _) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_off, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Could not load notes.'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => ref.invalidate(notesProvider),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (notes) {
|
|
||||||
final filtered = _search.isEmpty
|
|
||||||
? notes
|
|
||||||
: notes
|
|
||||||
.where((n) =>
|
|
||||||
n.title.toLowerCase().contains(_search) ||
|
|
||||||
n.body.toLowerCase().contains(_search))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (filtered.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
_search.isEmpty
|
|
||||||
? 'No notes yet. Tap + to create one.'
|
|
||||||
: 'No notes match "$_search".',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () => ref.refresh(notesProvider.future),
|
|
||||||
child: ListView.separated(
|
|
||||||
itemCount: filtered.length,
|
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final note = filtered[i];
|
|
||||||
final preview = note.body
|
|
||||||
.split('\n')
|
|
||||||
.firstWhere((l) => l.trim().isNotEmpty,
|
|
||||||
orElse: () => '')
|
|
||||||
.trim();
|
|
||||||
return ListTile(
|
|
||||||
title: Text(note.title),
|
|
||||||
subtitle: Text(
|
|
||||||
preview.isNotEmpty
|
|
||||||
? preview
|
|
||||||
: note.updatedAt
|
|
||||||
.toLocal()
|
|
||||||
.toString()
|
|
||||||
.substring(0, 16),
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodySmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
selected: isWide && _selectedNoteId == note.id,
|
|
||||||
selectedTileColor: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.secondaryContainer,
|
|
||||||
onTap: () {
|
|
||||||
if (isWide) {
|
|
||||||
setState(() => _selectedNoteId = note.id);
|
|
||||||
} else {
|
|
||||||
context.push(
|
|
||||||
Routes.noteDetail
|
|
||||||
.replaceFirst(':id', '${note.id}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Floating search button — only visible when search is closed.
|
|
||||||
if (!_showSearch)
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
tooltip: 'Search',
|
|
||||||
onPressed: () => setState(() => _showSearch = true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
|
||||||
import '../../providers/api_client_provider.dart';
|
|
||||||
import '../../providers/notes_provider.dart';
|
|
||||||
import '../../providers/tasks_provider.dart';
|
|
||||||
|
|
||||||
class QuickCaptureScreen extends ConsumerStatefulWidget {
|
|
||||||
const QuickCaptureScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<QuickCaptureScreen> createState() =>
|
|
||||||
_QuickCaptureScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuickCaptureScreenState extends ConsumerState<QuickCaptureScreen> {
|
|
||||||
final _controller = TextEditingController();
|
|
||||||
final _focusNode = FocusNode();
|
|
||||||
bool _loading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _send() async {
|
|
||||||
final text = _controller.text.trim();
|
|
||||||
if (text.isEmpty) return;
|
|
||||||
setState(() => _loading = true);
|
|
||||||
try {
|
|
||||||
final result =
|
|
||||||
await ref.read(quickCaptureApiProvider).capture(text);
|
|
||||||
// Invalidate the relevant provider so the list screen re-fetches.
|
|
||||||
switch (result.type) {
|
|
||||||
case 'note':
|
|
||||||
ref.invalidate(notesProvider);
|
|
||||||
case 'task':
|
|
||||||
case 'todo':
|
|
||||||
ref.invalidate(tasksProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
// Use the server's human-readable message if available, else compose one.
|
|
||||||
final msg = result.message.isNotEmpty
|
|
||||||
? result.message
|
|
||||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(msg),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
} on AppException catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.message),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _typeLabel(String type) => switch (type) {
|
|
||||||
'note' => 'Note',
|
|
||||||
'task' => 'Task',
|
|
||||||
'event' => 'Event',
|
|
||||||
'todo' => 'To-do',
|
|
||||||
_ => type,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Quick Capture')),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'What\'s on your mind?',
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Describe a note, task, event, or research item — Fabled will figure out the rest.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: _controller,
|
|
||||||
focusNode: _focusNode,
|
|
||||||
autofocus: true,
|
|
||||||
enabled: !_loading,
|
|
||||||
maxLines: 6,
|
|
||||||
minLines: 3,
|
|
||||||
keyboardType: TextInputType.multiline,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText:
|
|
||||||
'e.g. "Remind me to call the dentist next Monday" or "Note: project meeting went well, key points were..."',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
suffixIcon: _controller.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
_controller.clear();
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: (_loading || _controller.text.trim().isEmpty)
|
|
||||||
? null
|
|
||||||
: _send,
|
|
||||||
icon: _loading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.auto_awesome),
|
|
||||||
label: Text(_loading ? 'Processing...' : 'Capture'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,19 +36,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 5)));
|
final dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
// Accept any HTTP status — only network-level failures throw.
|
||||||
|
// A 401 or 200 both mean the server is reachable.
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
));
|
||||||
await dio.get('$url/api/auth/status');
|
await dio.get('$url/api/auth/status');
|
||||||
// 401 is fine — server is reachable
|
} on DioException catch (e) {
|
||||||
} on DioException catch (_) {
|
final msg = switch (e.type) {
|
||||||
|
DioExceptionType.badCertificate =>
|
||||||
|
'SSL certificate error. If using a self-signed cert, it must be trusted on this device.',
|
||||||
|
DioExceptionType.connectionTimeout ||
|
||||||
|
DioExceptionType.receiveTimeout =>
|
||||||
|
'Connection timed out. The server may be down or unreachable.',
|
||||||
|
DioExceptionType.connectionError =>
|
||||||
|
'Cannot connect: ${e.message ?? "network error"}',
|
||||||
|
_ => 'Error: ${e.message ?? e.type.name}',
|
||||||
|
};
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = false;
|
_testing = false;
|
||||||
_error = 'Could not reach server. Check the URL and try again.';
|
_error = msg;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = false;
|
_testing = false;
|
||||||
_error = 'Could not reach server. Check the URL and try again.';
|
_error = 'Unexpected error: $e';
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final status = ref.read(authProvider);
|
final status = ref.read(authProvider);
|
||||||
if (status == AuthStatus.authenticated) {
|
if (status == AuthStatus.authenticated) {
|
||||||
context.go(Routes.notes);
|
context.go(Routes.briefing);
|
||||||
} else {
|
} else {
|
||||||
context.go(Routes.login);
|
context.go(Routes.login);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
import '../../data/models/task.dart';
|
import '../../data/models/task.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../widgets/project_selector.dart';
|
||||||
|
|
||||||
class TaskEditScreen extends ConsumerStatefulWidget {
|
class TaskEditScreen extends ConsumerStatefulWidget {
|
||||||
final int? taskId;
|
final int? taskId;
|
||||||
const TaskEditScreen({super.key, this.taskId});
|
final int? initialProjectId;
|
||||||
|
const TaskEditScreen({super.key, this.taskId, this.initialProjectId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
||||||
@@ -22,14 +25,16 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
TaskStatus _status = TaskStatus.todo;
|
TaskStatus _status = TaskStatus.todo;
|
||||||
TaskPriority _priority = TaskPriority.medium;
|
TaskPriority _priority = TaskPriority.medium;
|
||||||
DateTime? _dueDate;
|
DateTime? _dueDate;
|
||||||
|
int? _projectId;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
List<Task> _subTasks = [];
|
||||||
|
|
||||||
// Future is created once in initState so FutureBuilder never restarts it.
|
|
||||||
late final Future<void> _initFuture;
|
late final Future<void> _initFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_projectId = widget.initialProjectId;
|
||||||
_initFuture =
|
_initFuture =
|
||||||
widget.taskId != null ? _loadExisting() : Future.value();
|
widget.taskId != null ? _loadExisting() : Future.value();
|
||||||
}
|
}
|
||||||
@@ -42,13 +47,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadExisting() async {
|
Future<void> _loadExisting() async {
|
||||||
final task =
|
final repo = ref.read(tasksRepositoryProvider);
|
||||||
await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
|
final task = await repo.getOne(widget.taskId!);
|
||||||
_titleController.text = task.title;
|
_titleController.text = task.title;
|
||||||
_descController.text = task.description ?? '';
|
_descController.text = task.description ?? '';
|
||||||
_status = task.status;
|
_status = task.status;
|
||||||
_priority = task.priority;
|
_priority = task.priority;
|
||||||
_dueDate = task.dueDate;
|
_dueDate = task.dueDate;
|
||||||
|
_projectId = task.projectId;
|
||||||
|
// Fetch sub-tasks
|
||||||
|
final api = ref.read(tasksApiProvider);
|
||||||
|
final subs = await api.getSubTasks(widget.taskId!);
|
||||||
|
setState(() => _subTasks = subs);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@@ -64,16 +74,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
status: _status,
|
status: _status,
|
||||||
priority: _priority,
|
priority: _priority,
|
||||||
dueDate: _dueDate,
|
dueDate: _dueDate,
|
||||||
|
projectId: _projectId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
||||||
'title': _titleController.text.trim(),
|
'title': _titleController.text.trim(),
|
||||||
'description': _descController.text.trim().isEmpty
|
'body': _descController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _descController.text.trim(),
|
: _descController.text.trim(),
|
||||||
'status': _status.value,
|
'status': _status.value,
|
||||||
'priority': _priority.value,
|
'priority': _priority.value,
|
||||||
'due_date': _dueDate?.toIso8601String(),
|
'due_date': _dueDate?.toIso8601String(),
|
||||||
|
'project_id': _projectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
@@ -119,6 +131,54 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
if (date != null) setState(() => _dueDate = date);
|
if (date != null) setState(() => _dueDate = date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addSubTask() async {
|
||||||
|
final titleCtrl = TextEditingController();
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Add sub-task'),
|
||||||
|
content: TextField(
|
||||||
|
controller: titleCtrl,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, titleCtrl.text.trim()),
|
||||||
|
child: const Text('Add')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
titleCtrl.dispose();
|
||||||
|
if (result == null || result.isEmpty || !mounted) return;
|
||||||
|
try {
|
||||||
|
final api = ref.read(tasksApiProvider);
|
||||||
|
final sub = await api.create(
|
||||||
|
title: result,
|
||||||
|
status: TaskStatus.todo,
|
||||||
|
priority: TaskPriority.none,
|
||||||
|
projectId: _projectId,
|
||||||
|
parentId: widget.taskId,
|
||||||
|
);
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
setState(() => _subTasks = [..._subTasks, sub]);
|
||||||
|
} on AppException catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder(
|
return FutureBuilder(
|
||||||
@@ -155,70 +215,90 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Title',
|
labelText: 'Title',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
(v == null || v.trim().isEmpty)
|
||||||
|
? 'Required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskStatus>(
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
value: _status,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Status',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskStatus.values
|
||||||
|
.map((s) => DropdownMenuItem(
|
||||||
|
value: s, child: Text(s.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _status = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskPriority>(
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
value: _priority,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Priority',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskPriority.values
|
||||||
|
.map((p) => DropdownMenuItem(
|
||||||
|
value: p, child: Text(p.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _priority = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(_dueDate == null
|
||||||
|
? 'No due date'
|
||||||
|
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||||
|
leading: const Icon(Icons.calendar_today),
|
||||||
|
trailing: _dueDate != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _dueDate = null),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _pickDate,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ProjectSelector(
|
||||||
|
value: _projectId,
|
||||||
|
onChanged: (id) =>
|
||||||
|
setState(() => _projectId = id),
|
||||||
|
),
|
||||||
|
// Sub-tasks (only when editing an existing task)
|
||||||
|
if (widget.taskId != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_SubTasksSection(
|
||||||
|
subTasks: _subTasks,
|
||||||
|
onAdd: _addSubTask,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
TextFormField(
|
|
||||||
controller: _descController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Description (optional)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DropdownButtonFormField<TaskStatus>(
|
|
||||||
initialValue: _status,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Status',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: TaskStatus.values
|
|
||||||
.map((s) => DropdownMenuItem(
|
|
||||||
value: s, child: Text(s.label)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => _status = v!),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DropdownButtonFormField<TaskPriority>(
|
|
||||||
initialValue: _priority,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Priority',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: TaskPriority.values
|
|
||||||
.map((p) => DropdownMenuItem(
|
|
||||||
value: p, child: Text(p.label)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => _priority = v!),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(_dueDate == null
|
|
||||||
? 'No due date'
|
|
||||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
|
||||||
leading: const Icon(Icons.calendar_today),
|
|
||||||
trailing: _dueDate != null
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _dueDate = null),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: _pickDate,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -226,3 +306,91 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sub-tasks section ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SubTasksSection extends ConsumerWidget {
|
||||||
|
final List<Task> subTasks;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
const _SubTasksSection({required this.subTasks, required this.onAdd});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Sub-tasks',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onAdd,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
textStyle: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (subTasks.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'No sub-tasks yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...subTasks.map((sub) => _SubTaskTile(task: sub, ref: ref)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubTaskTile extends StatelessWidget {
|
||||||
|
final Task task;
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _SubTaskTile({required this.task, required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDone = task.status == TaskStatus.done;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Checkbox(
|
||||||
|
value: isDone,
|
||||||
|
onChanged: (_) async {
|
||||||
|
final newStatus =
|
||||||
|
isDone ? TaskStatus.todo : TaskStatus.done;
|
||||||
|
await ref.read(tasksProvider.notifier).updateTask(
|
||||||
|
task.id, {'status': newStatus.value});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.title,
|
||||||
|
style: TextStyle(
|
||||||
|
decoration: isDone ? TextDecoration.lineThrough : null,
|
||||||
|
color: isDone
|
||||||
|
? Theme.of(context).colorScheme.outline
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => context.push(
|
||||||
|
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
|
||||||
import '../../data/models/task.dart';
|
|
||||||
import '../../providers/tasks_provider.dart';
|
|
||||||
|
|
||||||
class TasksListScreen extends ConsumerStatefulWidget {
|
|
||||||
const TasksListScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final TabController _tabs;
|
|
||||||
bool _showSearch = false;
|
|
||||||
String _search = '';
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabs = TabController(length: 3, vsync: this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabs.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final tasksAsync = ref.watch(tasksProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
// Search field — appears above the tabs when active
|
|
||||||
if (_showSearch)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Search tasks…',
|
|
||||||
border: InputBorder.none,
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
),
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _search = v.trim().toLowerCase()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: 'Close search',
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_showSearch = false;
|
|
||||||
_search = '';
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Tab bar row — search icon sits to the right of the tabs
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TabBar(
|
|
||||||
controller: _tabs,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'To Do'),
|
|
||||||
Tab(text: 'In Progress'),
|
|
||||||
Tab(text: 'Done'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!_showSearch)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
tooltip: 'Search',
|
|
||||||
onPressed: () => setState(() => _showSearch = true),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Content
|
|
||||||
Expanded(
|
|
||||||
child: tasksAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, _) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_off, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Could not load tasks.'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => ref.invalidate(tasksProvider),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (tasks) {
|
|
||||||
final filtered = _search.isEmpty
|
|
||||||
? tasks
|
|
||||||
: tasks
|
|
||||||
.where((t) =>
|
|
||||||
t.title.toLowerCase().contains(_search) ||
|
|
||||||
(t.description ?? '')
|
|
||||||
.toLowerCase()
|
|
||||||
.contains(_search))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final todo = filtered
|
|
||||||
.where((t) => t.status == TaskStatus.todo)
|
|
||||||
.toList();
|
|
||||||
final inProgress = filtered
|
|
||||||
.where((t) => t.status == TaskStatus.inProgress)
|
|
||||||
.toList();
|
|
||||||
final done = filtered
|
|
||||||
.where((t) => t.status == TaskStatus.done)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () => ref.refresh(tasksProvider.future),
|
|
||||||
child: TabBarView(
|
|
||||||
controller: _tabs,
|
|
||||||
children: [
|
|
||||||
_TaskList(tasks: todo, search: _search),
|
|
||||||
_TaskList(tasks: inProgress, search: _search),
|
|
||||||
_TaskList(tasks: done, search: _search),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
heroTag: 'tasks_fab',
|
|
||||||
onPressed: () => context.push(Routes.taskNew),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TaskList extends ConsumerWidget {
|
|
||||||
final List<Task> tasks;
|
|
||||||
final String search;
|
|
||||||
const _TaskList({required this.tasks, this.search = ''});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
if (tasks.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ListView.separated(
|
|
||||||
itemCount: tasks.length,
|
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final task = tasks[i];
|
|
||||||
return ListTile(
|
|
||||||
leading: _priorityIcon(task.priority),
|
|
||||||
title: Text(task.title),
|
|
||||||
subtitle: task.dueDate != null
|
|
||||||
? Text(
|
|
||||||
'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
|
|
||||||
: null,
|
|
||||||
onTap: () => context.push(
|
|
||||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _priorityIcon(TaskPriority p) {
|
|
||||||
final color = switch (p) {
|
|
||||||
TaskPriority.high => Colors.red,
|
|
||||||
TaskPriority.medium => Colors.orange,
|
|
||||||
TaskPriority.low => Colors.green,
|
|
||||||
TaskPriority.none => Colors.grey,
|
|
||||||
};
|
|
||||||
return Icon(Icons.flag, color: color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
|
import '../data/models/message.dart';
|
||||||
|
|
||||||
|
class BriefingDigestCard extends StatefulWidget {
|
||||||
|
/// The first assistant message from today's briefing, or null if none yet.
|
||||||
|
final Message? message;
|
||||||
|
|
||||||
|
/// Called when the user taps "Generate now".
|
||||||
|
final VoidCallback? onGenerateNow;
|
||||||
|
|
||||||
|
const BriefingDigestCard({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
this.onGenerateNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_todayLabel(),
|
||||||
|
style: textTheme.labelMedium?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if (widget.message == null) ...[
|
||||||
|
Text(
|
||||||
|
'No briefing yet today.',
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (widget.onGenerateNow != null)
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: widget.onGenerateNow,
|
||||||
|
child: const Text('Generate now'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _expanded
|
||||||
|
? MarkdownBody(data: widget.message!.content)
|
||||||
|
: _TruncatedMarkdown(
|
||||||
|
data: widget.message!.content,
|
||||||
|
maxLines: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: Text(
|
||||||
|
_expanded ? 'Show less ↑' : 'Show more ↓',
|
||||||
|
style: TextStyle(
|
||||||
|
color: scheme.primary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _todayLabel() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
const days = [
|
||||||
|
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||||
|
'Friday', 'Saturday', 'Sunday'
|
||||||
|
];
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders Markdown truncated to [maxLines] visible lines.
|
||||||
|
class _TruncatedMarkdown extends StatelessWidget {
|
||||||
|
final String data;
|
||||||
|
final int maxLines;
|
||||||
|
const _TruncatedMarkdown({required this.data, required this.maxLines});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxLines * 20.0),
|
||||||
|
child: ClipRect(child: MarkdownBody(data: data)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'dart:math' show min;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
|
import '../data/models/message.dart';
|
||||||
|
|
||||||
|
class ChatMessageBubble extends StatelessWidget {
|
||||||
|
final Message message;
|
||||||
|
const ChatMessageBubble({super.key, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isUser = message.role == MessageRole.user;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isGenerating = message.status == 'generating';
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||||
|
decoration: isUser
|
||||||
|
? BoxDecoration(
|
||||||
|
// Ghost style: transparent bg, thin border
|
||||||
|
color: Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: scheme.primary.withValues(alpha: 0.35),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(16),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(16),
|
||||||
|
bottomRight: Radius.circular(4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: BoxDecoration(
|
||||||
|
// Assistant: elevated surface + left accent border
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(color: scheme.primary, width: 2),
|
||||||
|
),
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(4),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(4),
|
||||||
|
bottomRight: Radius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: isGenerating && message.content.isEmpty
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: MarkdownBody(
|
||||||
|
data: message.content.isEmpty ? '…' : message.content,
|
||||||
|
styleSheet: MarkdownStyleSheet(
|
||||||
|
p: TextStyle(
|
||||||
|
color: isUser
|
||||||
|
? scheme.onSurface.withValues(alpha: 0.75)
|
||||||
|
: scheme.onSurface,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../core/constants.dart';
|
||||||
|
import '../data/models/note.dart';
|
||||||
|
import '../data/models/project.dart';
|
||||||
|
import '../data/models/task.dart';
|
||||||
|
import '../providers/tasks_provider.dart';
|
||||||
|
|
||||||
|
// ── Note card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NoteLibraryCard extends StatelessWidget {
|
||||||
|
final Note note;
|
||||||
|
const NoteLibraryCard({super.key, required this.note});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context
|
||||||
|
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.article_outlined,
|
||||||
|
size: 15, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
note.title.isNotEmpty ? note.title : 'Untitled',
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_relativeTime(note.updatedAt),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (note.body.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
note.body.replaceAll('\n', ' '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (note.tags.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 2,
|
||||||
|
children: note.tags
|
||||||
|
.take(4)
|
||||||
|
.map((t) => Chip(
|
||||||
|
label: Text(t),
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TaskLibraryCard extends ConsumerWidget {
|
||||||
|
final Task task;
|
||||||
|
const TaskLibraryCard({super.key, required this.task});
|
||||||
|
|
||||||
|
Color _priorityColor(BuildContext context) {
|
||||||
|
return switch (task.priority) {
|
||||||
|
TaskPriority.high => const Color(0xFFEF4444),
|
||||||
|
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||||
|
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get _statusIcon => switch (task.status) {
|
||||||
|
TaskStatus.done => Icons.check_circle,
|
||||||
|
TaskStatus.inProgress => Icons.timelapse,
|
||||||
|
_ => Icons.radio_button_unchecked,
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _statusColor(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return switch (task.status) {
|
||||||
|
TaskStatus.done => const Color(0xFF22C55E),
|
||||||
|
TaskStatus.inProgress => cs.primary,
|
||||||
|
_ => cs.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskStatus get _nextStatus => switch (task.status) {
|
||||||
|
TaskStatus.todo => TaskStatus.inProgress,
|
||||||
|
TaskStatus.inProgress => TaskStatus.done,
|
||||||
|
_ => TaskStatus.todo,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context
|
||||||
|
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status cycle button
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(tasksProvider.notifier)
|
||||||
|
.updateTask(task.id, {'status': _nextStatus.value}),
|
||||||
|
tooltip: 'Cycle status',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
decoration: task.status == TaskStatus.done
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (task.dueDate != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Due ${_formatDate(task.dueDate!)}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: task.dueDate!.isBefore(DateTime.now()) &&
|
||||||
|
task.status != TaskStatus.done
|
||||||
|
? const Color(0xFFEF4444)
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.priority != TaskPriority.none &&
|
||||||
|
task.priority != TaskPriority.low)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _priorityColor(context),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project card ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProjectLibraryCard extends StatelessWidget {
|
||||||
|
final Project project;
|
||||||
|
const ProjectLibraryCard({super.key, required this.project});
|
||||||
|
|
||||||
|
Color _parseColor(String? hex) {
|
||||||
|
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
||||||
|
try {
|
||||||
|
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||||
|
} catch (_) {
|
||||||
|
return const Color(0xFF6366F1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = _parseColor(project.color);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context
|
||||||
|
.push('/projects/${project.id}/tasks'),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Colour strip
|
||||||
|
Container(width: 6, height: 64, color: color),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
project.title,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (project.description?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
project.description!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
String _relativeTime(DateTime dt) {
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inMinutes < 1) return 'just now';
|
||||||
|
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return _formatDate(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||||
|
];
|
||||||
|
return '${months[dt.month - 1]} ${dt.day}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../providers/projects_provider.dart';
|
||||||
|
|
||||||
|
/// Dropdown for picking a project. Pass [value] as the current project id
|
||||||
|
/// (null = no project) and [onChanged] to receive updates.
|
||||||
|
class ProjectSelector extends ConsumerWidget {
|
||||||
|
final int? value;
|
||||||
|
final ValueChanged<int?> onChanged;
|
||||||
|
final InputDecoration? decoration;
|
||||||
|
|
||||||
|
const ProjectSelector({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.decoration,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final projectsAsync = ref.watch(projectsProvider);
|
||||||
|
|
||||||
|
return projectsAsync.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
data: (projects) {
|
||||||
|
final active =
|
||||||
|
projects.where((p) => p.status == 'active').toList();
|
||||||
|
|
||||||
|
return DropdownButtonFormField<int?>(
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
value: value,
|
||||||
|
decoration: decoration ??
|
||||||
|
const InputDecoration(
|
||||||
|
labelText: 'Project (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('No project'),
|
||||||
|
),
|
||||||
|
...active.map(
|
||||||
|
(p) => DropdownMenuItem<int?>(
|
||||||
|
value: p.id,
|
||||||
|
child: Text(p.title, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
-26
@@ -1,6 +1,22 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "93.0.0"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -49,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.4"
|
||||||
|
cli_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_config
|
||||||
|
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.0"
|
||||||
cli_util:
|
cli_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
cookie_jar:
|
cookie_jar:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.9"
|
version: "4.0.9"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -109,26 +149,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dio
|
name: dio
|
||||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.9.1"
|
version: "5.9.2"
|
||||||
dio_cookie_manager:
|
dio_cookie_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dio_cookie_manager
|
name: dio_cookie_manager
|
||||||
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
|
sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.4.0"
|
||||||
dio_web_adapter:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dio_web_adapter
|
name: dio_web_adapter
|
||||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -238,22 +278,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
flutter_markdown:
|
flutter_markdown_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_markdown
|
name: flutter_markdown_plus
|
||||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7+1"
|
version: "1.0.7"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_riverpod
|
name: flutter_riverpod
|
||||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.3.1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -264,6 +304,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
frontend_server_client:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: frontend_server_client
|
||||||
|
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -276,18 +324,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
version: "17.1.0"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.2"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -296,6 +352,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -312,6 +376,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -372,10 +444,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -404,10 +476,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.4"
|
version: "0.17.5"
|
||||||
|
node_preamble:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: node_preamble
|
||||||
|
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -480,14 +560,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.3"
|
version: "0.0.3"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.1"
|
version: "9.0.0"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -576,6 +664,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
posix:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -596,10 +692,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod
|
name: riverpod
|
||||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.2.1"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -656,11 +752,59 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shelf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_packages_handler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_packages_handler
|
||||||
|
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
shelf_static:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_static
|
||||||
|
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.3"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_map_stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_map_stack_trace
|
||||||
|
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
source_maps:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_maps
|
||||||
|
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.13"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -709,14 +853,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
|
test:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test
|
||||||
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.10"
|
||||||
|
test_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_core
|
||||||
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.16"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -749,6 +909,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -757,6 +925,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
|
webkit_inspection_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webkit_inspection_protocol
|
||||||
|
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+7
-5
@@ -2,7 +2,7 @@ name: fabled_app
|
|||||||
description: "FabledAssistant mobile client for Android."
|
description: "FabledAssistant mobile client for Android."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.0+1
|
version: 0.0.0+0 # overridden at build time via --build-name / --build-number
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
@@ -12,18 +12,20 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_riverpod: ^2.5.1
|
flutter_riverpod: ^3.3.1
|
||||||
go_router: ^14.2.0
|
go_router: ^17.1.0
|
||||||
dio: ^5.6.0
|
dio: ^5.6.0
|
||||||
cookie_jar: ^4.0.8
|
cookie_jar: ^4.0.8
|
||||||
dio_cookie_manager: ^3.1.1
|
dio_cookie_manager: ^3.1.1
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
flutter_markdown: ^0.7.3
|
|
||||||
markdown: ^7.2.2
|
markdown: ^7.2.2
|
||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^9.0.0
|
||||||
open_file: ^3.3.2
|
open_file: ^3.3.2
|
||||||
|
permission_handler: ^11.3.1
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
google_fonts: ^8.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user