Merge pull request 'v26.03.11.1' (#1) from dev into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-03-11 23:47:45 -04:00
30 changed files with 5405 additions and 1701 deletions
-85
View File
@@ -1,85 +0,0 @@
# Branch push (dev): builds APK, uploads as artifact named fabledapp-dev-<sha>
# Branch push (main): builds APK, uploads as artifact named fabledapp-<sha>
# Tag push (v26.03.09): builds APK, uploads artifact + creates Forgejo Release
#
# To cut a release:
# git tag v26.03.09 && git push origin v26.03.09
#
# Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
name: Build APK
on:
push:
branches: [main, dev]
tags: ["v*"]
paths:
- "lib/**"
- "pubspec.yaml"
- "pubspec.lock"
- "android/**"
- "assets/**"
- ".forgejo/workflows/build.yml"
jobs:
build:
name: Build release APK
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- uses: actions/checkout@v4
- 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@v4
with:
name: ${{ steps.artifact.outputs.name }}
path: build/app/outputs/flutter-apk/app-release.apk
retention-days: 30
- name: Create Forgejo release
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
echo "Creating release $TAG..."
RESPONSE=$(curl -s -X POST \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/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, uploading APK..."
curl -s -X POST \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — release $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+94 -3
View File
@@ -1,13 +1,29 @@
name: CI # 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: on:
push: push:
branches: [main, dev]
tags: ["v*"]
paths: paths:
- "lib/**" - "lib/**"
- "test/**" - "test/**"
- "pubspec.yaml" - "pubspec.yaml"
- "pubspec.lock" - "pubspec.lock"
- "analysis_options.yaml" - "analysis_options.yaml"
- "android/**"
- "assets/**"
- ".forgejo/workflows/ci.yml" - ".forgejo/workflows/ci.yml"
pull_request: pull_request:
paths: paths:
@@ -21,11 +37,11 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze & test name: Analyze & test
runs-on: ubuntu-latest runs-on: py3.12-node22
container: container:
image: ghcr.io/cirruslabs/flutter:stable image: ghcr.io/cirruslabs/flutter:stable
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
@@ -35,3 +51,78 @@ jobs:
- name: Test - name: Test
run: flutter 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"
+55 -20
View File
@@ -1,20 +1,21 @@
# Fabled — Android App # Fabled — Android App
Native Android client for [FabledAssistant](https://github.com/yourusername/fabledassistant), a self-hosted AI productivity assistant. Native Android client for FabledAssistant, a self-hosted AI second-brain and productivity assistant.
## Features ## Features
- **Notes** — create, edit, and browse markdown notes - **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu.
- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority - **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable.
- **Chat** — streaming AI conversations with real-time SSE response display - **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
- **Quick Capture** — FAB shortcut to create a note or task from anywhere - **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server - **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
- **Session persistence** — stays logged in across app restarts via a persistent cookie jar - **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
- **Home screen widget** — tap to open the chat screen directly from the Android launcher - **Session persistence** — stays logged in across restarts via a persistent cookie jar.
- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app.
## Requirements ## Requirements
- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted) - A running FabledAssistant server (self-hosted)
- Android 5.0+ (API 21) - Android 5.0+ (API 21)
## Getting Started ## Getting Started
@@ -38,20 +39,33 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
``` ```
lib/ lib/
├── main.dart # Entry point; resolves async deps before runApp ├── main.dart # Entry point; resolves async deps before runApp
├── app.dart # GoRouter + auth redirect guards + shell nav ├── app.dart # GoRouter + auth redirect guards + 3-tab shell
├── core/ ├── core/
│ ├── constants.dart # Route name constants │ ├── constants.dart # Route name constants
── exceptions.dart # AppException hierarchy ── exceptions.dart # AppException hierarchy
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
├── data/ ├── data/
│ ├── api/ # Dio HTTP layer (one class per resource) │ ├── api/ # Dio HTTP layer (one class per resource)
│ ├── models/ # Plain Dart models with fromJson/toJson │ ├── models/ # Plain Dart models with fromJson/toJson
│ └── repositories/ # Thin wrappers over API classes │ └── repositories/ # Thin wrappers over API classes
── providers/ # Riverpod providers (state + dependency wiring) ── providers/ # Riverpod providers (state + dependency wiring)
screens/ # Flutter UI screens │ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
├── screens/
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── tasks/ # TaskEditScreen
│ └── settings/ auth/ setup/ splash/
└── widgets/
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
├── briefing_digest_card.dart # Expandable first-message card
└── library_item_card.dart # Note / task / project row widgets
``` ```
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `flutter_inappwebview`, `flutter_markdown` **Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`
## Building a Release APK ## Building a Release APK
@@ -59,4 +73,25 @@ lib/
flutter build apk --release flutter build apk --release
``` ```
The signed APK will be at `build/app/outputs/flutter-apk/app-release.apk`. The APK will be at `build/app/outputs/flutter-apk/app-release.apk`.
## CI / CD
CI and build are defined in a single workflow (`.forgejo/workflows/ci.yml`) running on the shared `py3.12-node22` act runner with the `ghcr.io/cirruslabs/flutter:stable` container.
| Trigger | Analyze + Test | Build APK | Release |
|---------|---------------|-----------|---------|
| PR | ✓ | — | — |
| Push `dev` | ✓ | ✓ (artifact `fabledapp-dev-<sha>`) | — |
| Push `main` | ✓ | — | — |
| Tag `v*` | ✓ | ✓ (artifact `fabledapp-<sha>`) | ✓ Forgejo Release + APK attached |
The build job has `needs: [analyze]` — a failed analyze or test blocks the APK build.
To cut a release:
```bash
git tag v26.03.11 && git push origin v26.03.11
```
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
@@ -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 498523 (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
+119 -163
View File
@@ -4,27 +4,26 @@ import 'package:go_router/go_router.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'core/exceptions.dart'; import 'core/exceptions.dart';
import 'core/theme.dart';
import 'providers/api_client_provider.dart'; import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/capture_queue_provider.dart'; import 'providers/capture_queue_provider.dart';
import 'providers/milestones_provider.dart'; import 'providers/capture_work_queue_provider.dart';
import 'providers/notes_provider.dart'; import 'providers/notes_provider.dart';
import 'providers/projects_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/update_provider.dart'; import 'providers/update_provider.dart';
import 'providers/tasks_provider.dart'; import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart'; import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/chat/chat_screen.dart'; import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_list_screen.dart'; import 'screens/chat/conversations_tab_screen.dart';
import 'screens/library/library_screen.dart';
import 'screens/notes/note_detail_screen.dart'; import 'screens/notes/note_detail_screen.dart';
import 'screens/notes/note_edit_screen.dart'; import 'screens/notes/note_edit_screen.dart';
import 'screens/notes/notes_list_screen.dart';
import 'screens/projects/project_list_screen.dart';
import 'screens/settings/settings_screen.dart'; import 'screens/settings/settings_screen.dart';
import 'screens/setup/setup_screen.dart'; import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart'; import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart'; import 'screens/tasks/task_edit_screen.dart';
import 'screens/tasks/tasks_list_screen.dart';
// ChangeNotifier that fires when auth or server URL changes, // ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects // used as GoRouter.refreshListenable so the router re-evaluates redirects
@@ -118,20 +117,16 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child), builder: (context, state, child) => _Shell(child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.notes, path: Routes.briefing,
builder: (_, _) => const NotesListScreen(), builder: (_, _) => const BriefingScreen(),
), ),
GoRoute( GoRoute(
path: Routes.tasks, path: Routes.library,
builder: (_, _) => const TasksListScreen(), builder: (_, _) => const LibraryScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectListScreen(),
), ),
GoRoute( GoRoute(
path: Routes.conversations, path: Routes.conversations,
builder: (_, _) => const ConversationsListScreen(), builder: (_, _) => const ConversationsTabScreen(),
), ),
], ],
), ),
@@ -148,7 +143,11 @@ class _Shell extends ConsumerStatefulWidget {
} }
class _ShellState extends ConsumerState<_Shell> { class _ShellState extends ConsumerState<_Shell> {
static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations]; static const _tabs = [
Routes.briefing,
Routes.library,
Routes.conversations,
];
@override @override
void initState() { void initState() {
@@ -236,8 +235,6 @@ class _ShellState extends ConsumerState<_Shell> {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location); final index = _tabIndex(location);
final child = widget.child; final child = widget.child;
// Use NavigationRail whenever the screen is wide enough — covers both
// phone landscape and tablets in either orientation (600 dp breakpoint).
final isWide = MediaQuery.of(context).size.width >= 600; final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) { if (isWide) {
@@ -255,19 +252,14 @@ class _ShellState extends ConsumerState<_Shell> {
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: const [
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.note_outlined), icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.note), selectedIcon: Icon(Icons.wb_sunny),
label: Text('Notes'), label: Text('Briefing'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.check_box_outlined), icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.check_box), selectedIcon: Icon(Icons.library_books),
label: Text('Tasks'), label: Text('Library'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline), icon: Icon(Icons.chat_bubble_outline),
@@ -298,11 +290,21 @@ class _ShellState extends ConsumerState<_Shell> {
selectedIndex: index, selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]), onDestinationSelected: (i) => context.go(_tabs[i]),
destinations: const [ destinations: const [
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.chat_bubble), label: 'Chat'), icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
], ],
), ),
); );
@@ -318,13 +320,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController(); final _controller = TextEditingController();
bool _busy = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Retry any offline-queued captures from previous sessions. WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
} }
@override @override
@@ -333,63 +333,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
super.dispose(); super.dispose();
} }
Future<void> _submit() async { void _submit() {
final text = _controller.text.trim(); final text = _controller.text.trim();
if (text.isEmpty || _busy) return; if (text.isEmpty) return;
_controller.clear(); _controller.clear();
setState(() => _busy = true); setState(() {}); // clear suffix icon
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
// Capture the messenger before the async gap so we can show a snackbar
// even if the user has navigated to a deeper view by the time it resolves.
final messenger = ScaffoldMessenger.of(context);
try {
final result = await ref.read(quickCaptureApiProvider).capture(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
ref.invalidate(projectsProvider);
ref.invalidate(projectMilestonesProvider);
}
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
messenger.showSnackBar(
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
);
_drainQueue(); // Silently flush any offline-queued captures.
} on NetworkException {
await ref.read(captureQueueProvider.notifier).enqueue(text);
messenger.showSnackBar(
const SnackBar(
content: Text(
"You're offline — capture saved and will retry automatically."),
behavior: SnackBarBehavior.floating,
),
);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
);
} catch (_) {
messenger.showSnackBar(
const SnackBar(
content: Text('Capture failed. Please try again.'),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (mounted) setState(() => _busy = false);
}
} }
Future<void> _drainQueue() async { Future<void> _drainOfflineQueue() async {
if (!mounted) return; if (!mounted) return;
final queue = ref.read(captureQueueProvider); final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return; if (queue.isEmpty) return;
@@ -406,28 +358,20 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
case 'task': case 'task':
case 'todo': case 'todo':
ref.invalidate(tasksProvider); ref.invalidate(tasksProvider);
ref.invalidate(projectsProvider);
ref.invalidate(projectMilestonesProvider);
} }
} on NetworkException { } on NetworkException {
break; // Still offline — stop draining. break;
} catch (_) { } catch (_) {
// Server/parse error — remove to avoid infinite retries. if (mounted) {
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text); await ref.read(captureQueueProvider.notifier).dequeue(text);
}
} }
} }
} }
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
String _hintForLocation(String location) { String _hintForLocation(String location) {
if (location.startsWith(Routes.tasks)) return 'Add a task…'; if (location.startsWith(Routes.library) &&
location.contains('tasks')) return 'Add a task…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…'; return 'Capture a note…';
} }
@@ -435,61 +379,82 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final queueCount = ref.watch(captureQueueProvider).length; final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) {
if (result == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
behavior: SnackBarBehavior.floating,
),
);
});
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Padding( child: Column(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4), mainAxisSize: MainAxisSize.min,
child: Row( children: [
children: [ Padding(
Expanded( padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: TextField( child: Row(
controller: _controller, children: [
enabled: !_busy, Expanded(
textInputAction: TextInputAction.send, child: TextField(
onSubmitted: (_) => _submit(), controller: _controller,
onChanged: (_) => setState(() {}), textInputAction: TextInputAction.send,
decoration: InputDecoration( onSubmitted: (_) => _submit(),
hintText: _hintForLocation(location), onChanged: (_) => setState(() {}),
isDense: true, decoration: InputDecoration(
contentPadding: hintText: _hintForLocation(location),
const EdgeInsets.symmetric(horizontal: 14, vertical: 10), isDense: true,
border: OutlineInputBorder( contentPadding: const EdgeInsets.symmetric(
borderRadius: BorderRadius.circular(24), horizontal: 14, vertical: 10),
), prefixIcon: totalPending > 0
prefixIcon: _busy
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: queueCount > 0
? Badge( ? Badge(
label: Text('$queueCount'), label: Text('$totalPending'),
child: child: const Icon(Icons.cloud_upload_outlined),
const Icon(Icons.cloud_upload_outlined),
) )
: const Icon(Icons.auto_awesome_outlined), : isWorking
suffixIcon: _controller.text.trim().isNotEmpty && !_busy ? const Padding(
? IconButton( padding: EdgeInsets.all(12),
icon: const Icon(Icons.send), child: SizedBox(
onPressed: _submit, width: 16,
tooltip: 'Capture', height: 16,
) child: CircularProgressIndicator(
: null, strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
), ),
), IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
), ),
IconButton( ),
icon: const Icon(Icons.settings_outlined), // Thin progress bar while the work queue is draining.
tooltip: 'Settings', if (isWorking)
onPressed: () => context.push(Routes.settings), const LinearProgressIndicator(minHeight: 2)
), else
], const SizedBox(height: 2),
), ],
), ),
); );
} }
@@ -506,17 +471,8 @@ class FabledApp extends ConsumerWidget {
return MaterialApp.router( return MaterialApp.router(
title: 'Fabled', title: 'Fabled',
themeMode: themeMode, themeMode: themeMode,
theme: ThemeData( theme: fabledLightTheme(),
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), darkTheme: fabledDarkTheme(),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
routerConfig: router, routerConfig: router,
); );
} }
+2
View File
@@ -14,4 +14,6 @@ abstract class Routes {
static const chat = '/chat/:id'; static const chat = '/chat/:id';
static const quickCapture = '/quick-capture'; static const quickCapture = '/quick-capture';
static const settings = '/settings'; static const settings = '/settings';
static const briefing = '/briefing';
static const library = '/library';
} }
+246
View File
@@ -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),
),
),
),
);
}
}
+62
View File
@@ -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,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,
);
}
+5
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart'; import '../data/api/api_client.dart';
import '../data/api/auth_api.dart'; import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart'; import '../data/api/chat_api.dart';
import '../data/api/milestones_api.dart'; import '../data/api/milestones_api.dart';
import '../data/api/notes_api.dart'; import '../data/api/notes_api.dart';
@@ -80,3 +81,7 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) { final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(ref.watch(milestonesApiProvider)); return MilestonesRepository(ref.watch(milestonesApiProvider));
}); });
final briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
});
+113
View File
@@ -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,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);
});
+299
View File
@@ -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,
),
);
}
}
+2 -44
View File
@@ -1,12 +1,9 @@
import 'dart:math' show min;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class ChatScreen extends ConsumerStatefulWidget { class ChatScreen extends ConsumerStatefulWidget {
final int conversationId; final int conversationId;
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
horizontal: 8, vertical: 12), horizontal: 8, vertical: 12),
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (context, i) => itemBuilder: (context, i) =>
_MessageBubble(message: messages[i]), ChatMessageBubble(message: messages[i]),
); );
}, },
), ),
@@ -153,42 +150,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
} }
} }
class _MessageBubble extends StatelessWidget {
final Message message;
const _MessageBubble({required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isUser ? 16 : 4),
bottomRight: Radius.circular(isUser ? 4 : 16),
),
),
child: isUser
? Text(
message.content,
style: TextStyle(color: scheme.onPrimary),
)
: MarkdownBody(
data: message.content.isEmpty ? '...' : message.content,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
),
),
);
}
}
@@ -1,185 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsListScreen extends ConsumerStatefulWidget {
const ConversationsListScreen({super.key});
@override
ConsumerState<ConversationsListScreen> createState() =>
_ConversationsListScreenState();
}
class _ConversationsListScreenState
extends ConsumerState<ConversationsListScreen> {
int? _selectedConvId;
Future<void> _newConversation(bool isWide) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override
Widget build(BuildContext context) {
final convsAsync = ref.watch(conversationsProvider);
final isWide = MediaQuery.of(context).size.width >= 600;
// Clear stale selection when switching to narrow mode.
if (!isWide && _selectedConvId != null) {
_selectedConvId = null;
}
return Scaffold(
body: isWide
? Row(
children: [
SizedBox(
width: 300,
child: Column(
children: [
Expanded(child: _buildListPane(convsAsync, isWide)),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.add),
title: const Text('New conversation'),
onTap: () => _newConversation(isWide),
),
],
),
),
const VerticalDivider(width: 1),
Expanded(child: _buildDetailPane()),
],
)
: _buildListPane(convsAsync, isWide),
floatingActionButton: isWide
? null
: FloatingActionButton(
heroTag: 'chat_fab',
onPressed: () => _newConversation(isWide),
child: const Icon(Icons.add),
),
);
}
Widget _buildDetailPane() {
if (_selectedConvId == null) {
return const Center(child: Text('Select a conversation to open it.'));
}
return ChatScreen(
key: ValueKey(_selectedConvId),
conversationId: _selectedConvId!,
);
}
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
return convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
const Text('Could not load conversations.'),
const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(conversationsProvider),
child: const Text('Retry'),
),
],
),
),
data: (convs) {
if (convs.isEmpty) {
return const Center(
child: Text('No conversations yet. Tap + to start one.'));
}
return RefreshIndicator(
onRefresh: () => ref.refresh(conversationsProvider.future),
child: ListView.separated(
itemCount: convs.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final conv = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(
conv.title.isNotEmpty ? conv.title : 'New conversation',
),
subtitle: Text(
conv.updatedAt.toLocal().toString().substring(0, 16),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
selected: isWide && _selectedConvId == conv.id,
selectedTileColor:
Theme.of(context).colorScheme.secondaryContainer,
onTap: () {
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'),
);
}
},
onLongPress: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text(
conv.title.isNotEmpty
? conv.title
: 'New conversation',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await ref
.read(conversationsProvider.notifier)
.delete(conv.id);
if (mounted && _selectedConvId == conv.id) {
setState(() => _selectedConvId = null);
}
}
},
);
},
),
);
},
);
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/chat_provider.dart';
class ConversationsTabScreen extends ConsumerWidget {
const ConversationsTabScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'New conversation',
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
body: convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (convs) {
if (convs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No conversations yet',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
FilledButton.icon(
icon: const Icon(Icons.add),
label: const Text('Start a conversation'),
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
_confirmDelete(context, ref, c.id, c.title),
),
onTap: () =>
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
);
},
),
);
},
),
);
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
}
}
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.day}/${dt.month}/${dt.year}';
}
+294
View File
@@ -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);
},
),
],
),
),
);
}
}
-216
View File
@@ -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,631 +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 '../../data/models/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
class ProjectListScreen extends ConsumerWidget {
const ProjectListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Projects')),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateDialog(context, ref),
tooltip: 'New project',
child: const Icon(Icons.add),
),
body: 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 yet.\nTap + to create one.',
textAlign: TextAlign.center,
),
);
}
final active =
projects.where((p) => p.status == 'active').toList();
final other =
projects.where((p) => p.status != 'active').toList();
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
if (active.isNotEmpty) ...[
_SectionHeader(title: 'Active (${active.length})'),
...active.map((p) => _ProjectExpansionTile(project: p)),
],
if (other.isNotEmpty) ...[
_SectionHeader(title: 'Other'),
...other.map((p) => _ProjectExpansionTile(project: p)),
],
],
);
},
),
);
}
void _showCreateDialog(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (dialogContext) => _CreateProjectDialog(
onCreate: (title, description, goal) async {
try {
await ref
.read(projectsProvider.notifier)
.create(title: title, description: description, goal: goal);
} on AppException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
}
},
),
);
}
}
// ─── Section header ────────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.primary),
),
);
}
}
// ─── Project expansion tile ────────────────────────────────────────────────────
class _ProjectExpansionTile extends ConsumerStatefulWidget {
final Project project;
const _ProjectExpansionTile({required this.project});
@override
ConsumerState<_ProjectExpansionTile> createState() =>
_ProjectExpansionTileState();
}
class _ProjectExpansionTileState
extends ConsumerState<_ProjectExpansionTile> {
bool _expanded = false;
Color _statusColor(BuildContext context) => switch (widget.project.status) {
'completed' => Colors.green,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
void _showOptions(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: const Text('Mark completed'),
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
widget.project.id,
{'status': 'completed'},
);
},
),
ListTile(
leading: const Icon(Icons.archive_outlined),
title: const Text('Archive'),
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
widget.project.id,
{'status': 'archived'},
);
},
),
ListTile(
leading: Icon(Icons.delete_outline,
color: Theme.of(context).colorScheme.error),
title: Text('Delete',
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
onTap: () async {
Navigator.pop(context);
final confirm = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete project?'),
content: const Text(
'Notes and tasks will be unlinked, not deleted.'),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true && context.mounted) {
await ref
.read(projectsProvider.notifier)
.delete(widget.project.id);
}
},
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final statusColor = _statusColor(context);
final tasksAsync = ref.watch(tasksProvider);
final unfinished = tasksAsync.valueOrNull
?.where((t) =>
t.projectId == widget.project.id &&
t.status != TaskStatus.done)
.toList() ??
[];
final subtitle = unfinished.isEmpty
? (widget.project.description != null &&
widget.project.description!.isNotEmpty
? widget.project.description!
: null)
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
return ExpansionTile(
key: PageStorageKey('project-${widget.project.id}'),
initiallyExpanded: false,
onExpansionChanged: (v) => setState(() => _expanded = v),
leading: CircleAvatar(
backgroundColor: statusColor.withValues(alpha: 0.15),
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
),
title: GestureDetector(
onLongPress: () => _showOptions(context),
child: Text(widget.project.title),
),
subtitle: subtitle != null
? Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
: null,
trailing: _StatusChip(status: widget.project.status),
children: [
if (_expanded)
_ProjectTaskList(
project: widget.project,
unfinishedTasks: unfinished,
),
],
);
}
}
// ─── Expanded task list grouped by milestone ───────────────────────────────────
class _ProjectTaskList extends ConsumerWidget {
final Project project;
final List<Task> unfinishedTasks;
const _ProjectTaskList({
required this.project,
required this.unfinishedTasks,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final milestonesAsync =
ref.watch(projectMilestonesProvider(project.id));
return milestonesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Error loading milestones: $e',
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
),
data: (milestones) {
// Only active milestones in order
final activeMilestones = milestones
.where((m) => m.status == 'active')
.toList()
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
if (unfinishedTasks.isEmpty) {
return _EmptyProjectContent(project: project);
}
// Build milestone → tasks map
final Map<int?, List<Task>> grouped = {};
for (final task in unfinishedTasks) {
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
}
final widgets = <Widget>[];
// Milestone groups (in order)
for (final ms in activeMilestones) {
final tasks = grouped[ms.id];
if (tasks == null || tasks.isEmpty) continue;
widgets.add(_MilestoneHeader(milestone: ms));
for (final task in tasks) {
widgets.add(_TaskRow(task: task));
}
}
// No-milestone group
final noMilestoneTasks = grouped[null] ?? [];
if (noMilestoneTasks.isNotEmpty) {
if (activeMilestones.isNotEmpty) {
widgets.add(const _NoMilestoneHeader());
}
for (final task in noMilestoneTasks) {
widgets.add(_TaskRow(task: task));
}
}
widgets.add(_AddTaskRow(project: project));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widgets,
);
},
);
}
}
class _EmptyProjectContent extends StatelessWidget {
final Project project;
const _EmptyProjectContent({required this.project});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'No open tasks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
),
_AddTaskRow(project: project),
],
);
}
}
// ─── Milestone header ──────────────────────────────────────────────────────────
class _MilestoneHeader extends StatelessWidget {
final Milestone milestone;
const _MilestoneHeader({required this.milestone});
@override
Widget build(BuildContext context) {
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.flag_outlined, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
milestone.title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.secondary,
fontWeight: FontWeight.w600,
),
),
),
Text(
'${milestone.completed}/${milestone.total}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: pct,
minHeight: 3,
borderRadius: BorderRadius.circular(2),
backgroundColor:
colorScheme.secondaryContainer.withValues(alpha: 0.4),
valueColor:
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
),
],
),
);
}
}
class _NoMilestoneHeader extends StatelessWidget {
const _NoMilestoneHeader();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
child: Text(
'No milestone',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
);
}
}
// ─── Task row ──────────────────────────────────────────────────────────────────
class _TaskRow extends StatelessWidget {
final Task task;
const _TaskRow({required this.task});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isInProgress = task.status == TaskStatus.inProgress;
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
final priorityColor = switch (task.priority) {
TaskPriority.high => Colors.red,
TaskPriority.medium => Colors.orange,
TaskPriority.low => Colors.blue,
_ => null,
};
return ListTile(
dense: true,
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
leading: Icon(
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 18,
color: dotColor,
),
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: task.dueDate != null
? Text(
_formatDue(task.dueDate!),
style: TextStyle(
fontSize: 11,
color: _isDueOverdue(task.dueDate!)
? colorScheme.error
: colorScheme.outline,
),
)
: null,
trailing: priorityColor != null
? Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: priorityColor,
shape: BoxShape.circle,
),
)
: null,
onTap: () => context.push(
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
),
);
}
String _formatDue(DateTime due) {
final now = DateTime.now();
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
if (diff == 0) return 'Due today';
if (diff == 1) return 'Due tomorrow';
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
if (diff < 7) return 'Due in $diff days';
return 'Due ${due.month}/${due.day}';
}
bool _isDueOverdue(DateTime due) {
final now = DateTime.now();
return due.isBefore(DateTime(now.year, now.month, now.day));
}
}
// ─── Add task row ──────────────────────────────────────────────────────────────
class _AddTaskRow extends StatelessWidget {
final Project project;
const _AddTaskRow({required this.project});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
child: TextButton.icon(
onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'),
icon: const Icon(Icons.add, size: 16),
label: const Text('New task'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.outline,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
textStyle: const TextStyle(fontSize: 13),
),
),
);
}
}
// ─── Status chip ───────────────────────────────────────────────────────────────
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
'completed' => ('Done', Colors.green),
'archived' => ('Archived', Colors.grey),
_ => ('Active', Theme.of(context).colorScheme.primary),
};
return Chip(
label: Text(label, style: const TextStyle(fontSize: 11)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: BorderSide(color: color.withValues(alpha: 0.4)),
backgroundColor: color.withValues(alpha: 0.1),
labelStyle: TextStyle(color: color),
);
}
}
// ─── Create project dialog ─────────────────────────────────────────────────────
class _CreateProjectDialog extends StatefulWidget {
final Future<void> Function(String title, String? description, String? goal)
onCreate;
const _CreateProjectDialog({required this.onCreate});
@override
State<_CreateProjectDialog> createState() => _CreateProjectDialogState();
}
class _CreateProjectDialogState extends State<_CreateProjectDialog> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
final _goalController = TextEditingController();
bool _saving = false;
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_goalController.dispose();
super.dispose();
}
Future<void> _submit() async {
final title = _titleController.text.trim();
if (title.isEmpty) return;
setState(() => _saving = true);
try {
await widget.onCreate(
title,
_descController.text.trim().isEmpty ? null : _descController.text.trim(),
_goalController.text.trim().isEmpty ? null : _goalController.text.trim(),
);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('New Project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 2,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _goalController,
decoration: const InputDecoration(
labelText: 'Goal (optional)',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
);
}
}
@@ -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'),
),
],
),
),
);
}
}
-199
View File
@@ -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);
}
}
+133
View File
@@ -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)),
);
}
}
+79
View File
@@ -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,
),
),
),
),
),
);
}
}
+271
View File
@@ -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}';
}
+12 -4
View File
@@ -280,6 +280,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.8.1" 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: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -372,10 +380,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -713,10 +721,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -24,6 +24,7 @@ dependencies:
open_file: ^3.3.2 open_file: ^3.3.2
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
flutter_markdown_plus: ^1.0.7 flutter_markdown_plus: ^1.0.7
google_fonts: ^6.2.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: