Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,128 @@
|
||||
# CI runs first; build only proceeds if analyze and test pass.
|
||||
#
|
||||
# Push to dev: analyze + test → build APK → upload artifact (fabledapp-dev-<sha>)
|
||||
# Push to main: analyze + test only (no build — wait for release tag)
|
||||
# Tag v26.03.11: analyze + test → build APK → upload artifact + create Forgejo Release
|
||||
# Pull request: analyze + test only
|
||||
#
|
||||
# To cut a release:
|
||||
# git tag v26.03.11 && git push origin v26.03.11
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||
name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
- "pubspec.lock"
|
||||
- "analysis_options.yaml"
|
||||
- "android/**"
|
||||
- "assets/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
- "pubspec.lock"
|
||||
- "analysis_options.yaml"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze & test
|
||||
runs-on: py3.12-node22
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
build:
|
||||
name: Build release APK
|
||||
needs: [analyze]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run CI for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production APK.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
runs-on: py3.12-node22
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Set artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
echo "name=fabledapp-dev-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=fabledapp-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish Forgejo release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- **Notes** — create, edit, and browse markdown notes
|
||||
- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority
|
||||
- **Chat** — streaming AI conversations with real-time SSE response display
|
||||
- **Quick Capture** — FAB shortcut to create a note or task from anywhere
|
||||
- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server
|
||||
- **Session persistence** — stays logged in across app restarts via a persistent cookie jar
|
||||
- **Home screen widget** — tap to open the chat screen directly from the Android launcher
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
|
||||
- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
|
||||
- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
|
||||
- **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
|
||||
|
||||
- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted)
|
||||
- A running FabledAssistant server (self-hosted)
|
||||
- Android 5.0+ (API 21)
|
||||
|
||||
## Getting Started
|
||||
@@ -38,20 +39,33 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # Entry point; resolves async deps before runApp
|
||||
├── app.dart # GoRouter + auth redirect guards + shell nav
|
||||
├── main.dart # Entry point; resolves async deps before runApp
|
||||
├── app.dart # GoRouter + auth redirect guards + 3-tab shell
|
||||
├── core/
|
||||
│ ├── constants.dart # Route name constants
|
||||
│ └── exceptions.dart # AppException hierarchy
|
||||
│ ├── constants.dart # Route name constants
|
||||
│ ├── exceptions.dart # AppException hierarchy
|
||||
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
|
||||
├── data/
|
||||
│ ├── api/ # Dio HTTP layer (one class per resource)
|
||||
│ ├── models/ # Plain Dart models with fromJson/toJson
|
||||
│ └── repositories/ # Thin wrappers over API classes
|
||||
└── providers/ # Riverpod providers (state + dependency wiring)
|
||||
screens/ # Flutter UI screens
|
||||
│ ├── api/ # Dio HTTP layer (one class per resource)
|
||||
│ ├── models/ # Plain Dart models with fromJson/toJson
|
||||
│ └── repositories/ # Thin wrappers over API classes
|
||||
├── providers/ # Riverpod providers (state + dependency wiring)
|
||||
│ ├── 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
|
||||
|
||||
@@ -59,4 +73,25 @@ lib/
|
||||
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.
|
||||
|
||||
+2
-4
@@ -7,8 +7,6 @@ gradle-wrapper.jar
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
fabled-release-key.jks
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,12 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fabledapp.fabled_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -20,21 +29,37 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
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
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} 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.versionName}.${variant.versionCode}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<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" />
|
||||
|
||||
<application
|
||||
android:label="fabled_app"
|
||||
android:label="Fabled"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
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
|
||||
+124
-147
@@ -4,24 +4,26 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/notes_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_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_edit_screen.dart';
|
||||
import 'screens/notes/notes_list_screen.dart';
|
||||
import 'screens/settings/settings_screen.dart';
|
||||
import 'screens/setup/setup_screen.dart';
|
||||
import 'screens/splash/splash_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,
|
||||
// used as GoRouter.refreshListenable so the router re-evaluates redirects
|
||||
@@ -93,7 +95,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.taskNew,
|
||||
builder: (_, _) => const TaskEditScreen(),
|
||||
builder: (_, state) => TaskEditScreen(
|
||||
initialProjectId: state.uri.queryParameters['projectId'] != null
|
||||
? int.tryParse(state.uri.queryParameters['projectId']!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.taskEdit,
|
||||
@@ -111,16 +117,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.notes,
|
||||
builder: (_, _) => const NotesListScreen(),
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tasks,
|
||||
builder: (_, _) => const TasksListScreen(),
|
||||
path: Routes.library,
|
||||
builder: (_, _) => const LibraryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsListScreen(),
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -137,7 +143,11 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.library,
|
||||
Routes.conversations,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -225,8 +235,6 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
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;
|
||||
|
||||
if (isWide) {
|
||||
@@ -244,14 +252,14 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.note_outlined),
|
||||
selectedIcon: Icon(Icons.note),
|
||||
label: Text('Notes'),
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.check_box_outlined),
|
||||
selectedIcon: Icon(Icons.check_box),
|
||||
label: Text('Tasks'),
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: Text('Library'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
@@ -282,10 +290,21 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
|
||||
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
||||
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 +320,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
|
||||
|
||||
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
final _controller = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Retry any offline-queued captures from previous sessions.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -316,61 +333,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
void _submit() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _busy) return;
|
||||
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
setState(() => _busy = true);
|
||||
|
||||
// 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);
|
||||
}
|
||||
setState(() {}); // clear suffix icon
|
||||
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||
}
|
||||
|
||||
Future<void> _drainQueue() async {
|
||||
Future<void> _drainOfflineQueue() async {
|
||||
if (!mounted) return;
|
||||
final queue = ref.read(captureQueueProvider);
|
||||
if (queue.isEmpty) return;
|
||||
@@ -389,24 +360,18 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break; // Still offline — stop draining.
|
||||
break;
|
||||
} catch (_) {
|
||||
// Server/parse error — remove to avoid infinite retries.
|
||||
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
if (mounted) {
|
||||
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) {
|
||||
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…';
|
||||
return 'Capture a note…';
|
||||
}
|
||||
@@ -414,61 +379,82 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
prefixIcon: _busy
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: queueCount > 0
|
||||
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('$queueCount'),
|
||||
child:
|
||||
const Icon(Icons.cloud_upload_outlined),
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
: 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -485,17 +471,8 @@ class FabledApp extends ConsumerWidget {
|
||||
return MaterialApp.router(
|
||||
title: 'Fabled',
|
||||
themeMode: themeMode,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: fabledLightTheme(),
|
||||
darkTheme: fabledDarkTheme(),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ abstract class Routes {
|
||||
static const tasks = '/tasks';
|
||||
static const taskNew = '/tasks/new';
|
||||
static const taskEdit = '/tasks/:id/edit';
|
||||
static const projects = '/projects';
|
||||
static const conversations = '/chat';
|
||||
static const chat = '/chat/:id';
|
||||
static const quickCapture = '/quick-capture';
|
||||
static const settings = '/settings';
|
||||
static const briefing = '/briefing';
|
||||
static const library = '/library';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
final response = await _dio.post('/api/notes', data: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
if (projectId != null) 'project_id': projectId,
|
||||
});
|
||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||
} 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 {
|
||||
final response = await _dio.put('/api/notes/$id', data: {
|
||||
'title': title,
|
||||
'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>);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class TasksApi {
|
||||
required TaskStatus status,
|
||||
required TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/tasks', data: {
|
||||
@@ -41,6 +43,8 @@ class TasksApi {
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'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>);
|
||||
} on DioException catch (e) {
|
||||
@@ -48,6 +52,20 @@ class TasksApi {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
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 String title;
|
||||
final String body;
|
||||
final List<String> tags;
|
||||
final int? projectId;
|
||||
final int? milestoneId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -9,6 +12,9 @@ class Note {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.tags,
|
||||
this.projectId,
|
||||
this.milestoneId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -17,6 +23,12 @@ class Note {
|
||||
id: json['id'] as int,
|
||||
title: json['title'] 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),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
@@ -24,13 +36,32 @@ class Note {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'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,
|
||||
title: title ?? this.title,
|
||||
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,
|
||||
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 TaskPriority priority;
|
||||
final DateTime? dueDate;
|
||||
final int? projectId;
|
||||
final int? milestoneId;
|
||||
final int? parentId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -62,6 +65,9 @@ class Task {
|
||||
required this.status,
|
||||
required this.priority,
|
||||
this.dueDate,
|
||||
this.projectId,
|
||||
this.milestoneId,
|
||||
this.parentId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -75,6 +81,9 @@ class Task {
|
||||
dueDate: json['due_date'] != null
|
||||
? DateTime.parse(json['due_date'] as String)
|
||||
: 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),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
@@ -85,6 +94,9 @@ class Task {
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'due_date': dueDate?.toIso8601String(),
|
||||
'project_id': projectId,
|
||||
'milestone_id': milestoneId,
|
||||
'parent_id': parentId,
|
||||
};
|
||||
|
||||
Task copyWith({
|
||||
@@ -93,6 +105,9 @@ class Task {
|
||||
TaskStatus? status,
|
||||
TaskPriority? priority,
|
||||
DateTime? dueDate,
|
||||
Object? projectId = _undefined,
|
||||
Object? milestoneId = _undefined,
|
||||
Object? parentId = _undefined,
|
||||
}) =>
|
||||
Task(
|
||||
id: id,
|
||||
@@ -101,7 +116,18 @@ class Task {
|
||||
status: status ?? this.status,
|
||||
priority: priority ?? this.priority,
|
||||
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,
|
||||
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<Note> getOne(int id) => _api.getOne(id);
|
||||
Future<Note> create(String title, String body) =>
|
||||
_api.create(title, body);
|
||||
Future<Note> update(int id, String title, String body) =>
|
||||
_api.update(id, title, body);
|
||||
|
||||
Future<Note> create(
|
||||
String title,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title,
|
||||
@@ -21,8 +23,12 @@ class TasksRepository {
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||
|
||||
Future<Task> update(int id, Map<String, dynamic> 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/auth_api.dart';
|
||||
import '../data/api/briefing_api.dart';
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/notes_api.dart';
|
||||
import '../data/api/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import '../data/api/tasks_api.dart';
|
||||
import '../data/repositories/auth_repository.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
import '../data/repositories/milestones_repository.dart';
|
||||
import '../data/repositories/notes_repository.dart';
|
||||
import '../data/repositories/projects_repository.dart';
|
||||
import '../data/repositories/tasks_repository.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
@@ -45,6 +50,10 @@ final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
|
||||
return QuickCaptureApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||
return ProjectsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository(ref.watch(authApiProvider));
|
||||
});
|
||||
@@ -60,3 +69,19 @@ final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 = StateProvider<bool>((ref) => 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.valueOrNull;
|
||||
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.valueOrNull;
|
||||
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.valueOrNull;
|
||||
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.valueOrNull;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 = 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.",
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,16 +12,35 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
return ref.watch(notesRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<Note> create(String title, String body) async {
|
||||
final note =
|
||||
await ref.read(notesRepositoryProvider).create(title, body);
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
}) async {
|
||||
final note = await ref
|
||||
.read(notesRepositoryProvider)
|
||||
.create(title, body, tags: tags, projectId: projectId);
|
||||
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||
return note;
|
||||
}
|
||||
|
||||
Future<Note> updateNote(int id, String title, String body) async {
|
||||
final updated =
|
||||
await ref.read(notesRepositoryProvider).update(id, title, body);
|
||||
Future<Note> updateNote(
|
||||
int id,
|
||||
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([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
if (n.id == id) updated else 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.valueOrNull ?? [], 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.valueOrNull ?? [])
|
||||
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.valueOrNull ?? [])
|
||||
if (p.id != id) p,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ final forgejoRepoUrlProvider =
|
||||
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
||||
final SharedPreferences _prefs;
|
||||
ForgejoRepoUrlNotifier(this._prefs)
|
||||
: super(_prefs.getString(_kForgejoRepoUrl));
|
||||
: super(_prefs.getString(_kForgejoRepoUrl) ??
|
||||
'https://git.fabledsword.com/bvandeusen/FabledApp');
|
||||
|
||||
Future<void> setUrl(String url) async {
|
||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
@@ -18,6 +18,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
required TaskStatus status,
|
||||
required TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
}) async {
|
||||
final task = await ref.read(tasksRepositoryProvider).create(
|
||||
title: title,
|
||||
@@ -25,6 +26,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
||||
return task;
|
||||
|
||||
@@ -51,7 +51,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
state = state.copyWith(status: UpdateStatus.checking);
|
||||
try {
|
||||
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
|
||||
final uri = Uri.parse(repoUrl);
|
||||
@@ -122,11 +124,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
// Return to available so the user can retry install if they dismissed it.
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
downloadProgress: 1.0,
|
||||
);
|
||||
// Transition to idle — the system installer is now open.
|
||||
// Going back to `available` would re-trigger the update dialog loop.
|
||||
state = const UpdateState();
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
|
||||
@@ -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,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/briefing_provider.dart';
|
||||
import '../../widgets/briefing_digest_card.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) {
|
||||
// First assistant message for the digest card (null if none yet)
|
||||
final Message? firstAssistant = conv.messages
|
||||
.where((m) => m.role == MessageRole.assistant)
|
||||
.toList()
|
||||
.firstOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Digest card header
|
||||
BriefingDigestCard(
|
||||
message: firstAssistant,
|
||||
onGenerateNow: _refresh,
|
||||
),
|
||||
|
||||
// Divider + label
|
||||
if (conv.messages.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Conversation',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
]),
|
||||
],
|
||||
|
||||
// Message list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
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_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final int conversationId;
|
||||
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
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.valueOrNull ?? [];
|
||||
final tasks = tasksAsync.valueOrNull ?? [];
|
||||
|
||||
// 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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:go_router/go_router.dart';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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:go_router/go_router.dart';
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../core/exceptions.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
import '../../widgets/project_selector.dart';
|
||||
|
||||
class NoteEditScreen extends ConsumerStatefulWidget {
|
||||
final int? noteId;
|
||||
@@ -19,10 +20,12 @@ class NoteEditScreen extends ConsumerStatefulWidget {
|
||||
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _contentController = TextEditingController();
|
||||
final _tagController = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
int? _projectId;
|
||||
bool _preview = false;
|
||||
bool _saving = false;
|
||||
|
||||
// Future is created once in initState so FutureBuilder never restarts it.
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
@@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
_tagController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -44,6 +48,24 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
||||
_titleController.text = note.title;
|
||||
_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 {
|
||||
@@ -81,12 +103,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
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();
|
||||
} else {
|
||||
await ref
|
||||
.read(notesProvider.notifier)
|
||||
.updateNote(widget.noteId!, title, body);
|
||||
await ref.read(notesProvider.notifier).updateNote(
|
||||
widget.noteId!,
|
||||
title,
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
clearProject: _projectId == null,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
@@ -147,7 +179,23 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
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(
|
||||
child: _preview
|
||||
? 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);
|
||||
|
||||
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');
|
||||
// 401 is fine — server is reachable
|
||||
} on DioException catch (_) {
|
||||
} on DioException catch (e) {
|
||||
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(() {
|
||||
_testing = false;
|
||||
_error = 'Could not reach server. Check the URL and try again.';
|
||||
_error = msg;
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_testing = false;
|
||||
_error = 'Could not reach server. Check the URL and try again.';
|
||||
_error = 'Unexpected error: $e';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@ 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 '../../data/api/tasks_api.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../widgets/project_selector.dart';
|
||||
|
||||
class TaskEditScreen extends ConsumerStatefulWidget {
|
||||
final int? taskId;
|
||||
const TaskEditScreen({super.key, this.taskId});
|
||||
final int? initialProjectId;
|
||||
const TaskEditScreen({super.key, this.taskId, this.initialProjectId});
|
||||
|
||||
@override
|
||||
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
||||
@@ -22,14 +26,16 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
TaskStatus _status = TaskStatus.todo;
|
||||
TaskPriority _priority = TaskPriority.medium;
|
||||
DateTime? _dueDate;
|
||||
int? _projectId;
|
||||
bool _saving = false;
|
||||
List<Task> _subTasks = [];
|
||||
|
||||
// Future is created once in initState so FutureBuilder never restarts it.
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_projectId = widget.initialProjectId;
|
||||
_initFuture =
|
||||
widget.taskId != null ? _loadExisting() : Future.value();
|
||||
}
|
||||
@@ -42,13 +48,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadExisting() async {
|
||||
final task =
|
||||
await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
|
||||
final repo = ref.read(tasksRepositoryProvider);
|
||||
final task = await repo.getOne(widget.taskId!);
|
||||
_titleController.text = task.title;
|
||||
_descController.text = task.description ?? '';
|
||||
_status = task.status;
|
||||
_priority = task.priority;
|
||||
_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 {
|
||||
@@ -64,16 +75,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
status: _status,
|
||||
priority: _priority,
|
||||
dueDate: _dueDate,
|
||||
projectId: _projectId,
|
||||
);
|
||||
} else {
|
||||
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
||||
'title': _titleController.text.trim(),
|
||||
'description': _descController.text.trim().isEmpty
|
||||
'body': _descController.text.trim().isEmpty
|
||||
? null
|
||||
: _descController.text.trim(),
|
||||
'status': _status.value,
|
||||
'priority': _priority.value,
|
||||
'due_date': _dueDate?.toIso8601String(),
|
||||
'project_id': _projectId,
|
||||
});
|
||||
}
|
||||
if (mounted) context.pop();
|
||||
@@ -119,6 +132,54 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
@@ -155,70 +216,88 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(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>(
|
||||
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>(
|
||||
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 +305,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,271 @@
|
||||
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: () {}, // No project detail screen in this app
|
||||
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,55 @@
|
||||
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?>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-8
@@ -238,14 +238,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_markdown:
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
name: flutter_markdown_plus
|
||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
version: "1.0.7"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -280,6 +280,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -372,10 +380,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -713,10 +721,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@ name: fabled_app
|
||||
description: "FabledAssistant mobile client for Android."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 26.03.02+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
@@ -19,11 +19,12 @@ dependencies:
|
||||
dio_cookie_manager: ^3.1.1
|
||||
path_provider: ^2.1.4
|
||||
shared_preferences: ^2.3.2
|
||||
flutter_markdown: ^0.7.3
|
||||
markdown: ^7.2.2
|
||||
package_info_plus: ^8.0.0
|
||||
open_file: ^3.3.2
|
||||
flutter_inappwebview: ^6.1.5
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
google_fonts: ^6.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user