commit 4da36aa31d9500d3bae98681500a56c050442f6b Author: bvandeusen Date: Sat Feb 28 21:28:53 2026 -0500 Initial commit: Fabled Android app Flutter Android client for FabledAssistant with: - Session-cookie auth via persistent cookie jar (Dio + cookie_jar) - OAuth/SSO login via in-app WebView (flutter_inappwebview) - Notes: list, detail (markdown render), create/edit - Tasks: list with status tabs, create/edit with priority - Chat: SSE streaming bubbles, conversation management - Quick Capture FAB for rapid note/task creation - Settings screen (change server URL, logout) - Android home screen widget → opens chat - Riverpod state management, go_router navigation Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8554ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Environment / secrets +.env +.env.* +!.env.example + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..c0fe018 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: android + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: ios + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: linux + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: macos + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: web + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: windows + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d7d0d9 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Fabled — Android App + +Native Android client for [FabledAssistant](https://github.com/yourusername/fabledassistant), a self-hosted AI productivity assistant. + +## Features + +- **Notes** — create, edit, and browse markdown notes +- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority +- **Chat** — streaming AI conversations with real-time SSE response display +- **Quick Capture** — FAB shortcut to create a note or task from anywhere +- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server +- **Session persistence** — stays logged in across app restarts via a persistent cookie jar +- **Home screen widget** — tap to open the chat screen directly from the Android launcher + +## Requirements + +- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted) +- Android 5.0+ (API 21) + +## Getting Started + +### Prerequisites + +- Flutter 3.x SDK +- Android Studio (for Android SDK and emulator) +- JDK 17 + +### Setup + +```bash +flutter pub get +flutter run +``` + +On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.example.com`) and sign in. + +## Architecture + +``` +lib/ +├── main.dart # Entry point; resolves async deps before runApp +├── app.dart # GoRouter + auth redirect guards + shell nav +├── core/ +│ ├── constants.dart # Route name constants +│ └── exceptions.dart # AppException hierarchy +├── data/ +│ ├── api/ # Dio HTTP layer (one class per resource) +│ ├── models/ # Plain Dart models with fromJson/toJson +│ └── repositories/ # Thin wrappers over API classes +└── providers/ # Riverpod providers (state + dependency wiring) + screens/ # Flutter UI screens +``` + +**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `flutter_inappwebview`, `flutter_markdown` + +## Building a Release APK + +```bash +flutter build apk --release +``` + +The signed APK will be at `build/app/outputs/flutter-apk/app-release.apk`. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..22c6a64 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.fabledapp.fabled_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.fabledapp.fabled_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..214ae2e --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/fabledapp/fabled_app/ChatWidgetProvider.kt b/android/app/src/main/kotlin/com/fabledapp/fabled_app/ChatWidgetProvider.kt new file mode 100644 index 0000000..e67540d --- /dev/null +++ b/android/app/src/main/kotlin/com/fabledapp/fabled_app/ChatWidgetProvider.kt @@ -0,0 +1,43 @@ +package com.fabledapp.fabled_app + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.app.PendingIntent +import android.widget.RemoteViews + +class ChatWidgetProvider : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (widgetId in appWidgetIds) { + updateWidget(context, appWidgetManager, widgetId) + } + } + + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + widgetId: Int + ) { + val intent = Intent(context, MainActivity::class.java).apply { + action = "com.fabledapp.OPEN_CHAT" + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val views = RemoteViews(context.packageName, R.layout.chat_widget_layout) + views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) + + appWidgetManager.updateAppWidget(widgetId, views) + } +} diff --git a/android/app/src/main/kotlin/com/fabledapp/fabled_app/MainActivity.kt b/android/app/src/main/kotlin/com/fabledapp/fabled_app/MainActivity.kt new file mode 100644 index 0000000..86c2476 --- /dev/null +++ b/android/app/src/main/kotlin/com/fabledapp/fabled_app/MainActivity.kt @@ -0,0 +1,19 @@ +package com.fabledapp.fabled_app + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + private val channelName = "com.fabledapp.fabled_app/widget" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // If the app was launched by the home screen widget, notify Flutter. + if (intent?.action == "com.fabledapp.OPEN_CHAT") { + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .invokeMethod("openChat", null) + } + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout/chat_widget_layout.xml b/android/app/src/main/res/layout/chat_widget_layout.xml new file mode 100644 index 0000000..93ee93b --- /dev/null +++ b/android/app/src/main/res/layout/chat_widget_layout.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/chat_widget_info.xml b/android/app/src/main/res/xml/chat_widget_info.xml new file mode 100644 index 0000000..8b59393 --- /dev/null +++ b/android/app/src/main/res/xml/chat_widget_info.xml @@ -0,0 +1,11 @@ + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..fa88276 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,193 @@ +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/auth_provider.dart'; +import 'providers/settings_provider.dart'; +import 'screens/auth/login_screen.dart'; +import 'screens/chat/chat_screen.dart'; +import 'screens/chat/conversations_list_screen.dart'; +import 'screens/notes/note_detail_screen.dart'; +import 'screens/notes/note_edit_screen.dart'; +import 'screens/notes/notes_list_screen.dart'; +import 'screens/quick_capture/quick_capture_screen.dart'; +import 'screens/settings/settings_screen.dart'; +import 'screens/setup/setup_screen.dart'; +import 'screens/splash/splash_screen.dart'; +import 'screens/tasks/task_edit_screen.dart'; +import 'screens/tasks/tasks_list_screen.dart'; + +// ChangeNotifier that fires when auth or server URL changes, +// used as GoRouter.refreshListenable so the router re-evaluates redirects +// without being recreated. +class _RouterNotifier extends ChangeNotifier { + _RouterNotifier(Ref ref) { + ref.listen(authProvider, (_, _) => notifyListeners()); + ref.listen(serverUrlProvider, (_, _) => notifyListeners()); + } +} + +final routerProvider = Provider((ref) { + final notifier = _RouterNotifier(ref); + + return GoRouter( + refreshListenable: notifier, + initialLocation: Routes.splash, + redirect: (context, state) { + final location = state.matchedLocation; + final serverUrl = ref.read(serverUrlProvider); + final authStatus = ref.read(authProvider); + + if (serverUrl == null || serverUrl.isEmpty) { + if (location != Routes.setup) return Routes.setup; + return null; + } + + if (authStatus == AuthStatus.unauthenticated) { + if (location != Routes.login && location != Routes.setup) { + return Routes.login; + } + return null; + } + + return null; + }, + routes: [ + GoRoute( + path: Routes.splash, + builder: (_, _) => const SplashScreen(), + ), + GoRoute( + path: Routes.setup, + builder: (_, _) => const SetupScreen(), + ), + GoRoute( + path: Routes.login, + builder: (_, _) => const LoginScreen(), + ), + GoRoute( + path: Routes.quickCapture, + builder: (_, _) => const QuickCaptureScreen(), + ), + GoRoute( + path: Routes.settings, + builder: (_, _) => const SettingsScreen(), + ), + GoRoute( + path: Routes.noteNew, + builder: (_, _) => const NoteEditScreen(), + ), + GoRoute( + path: Routes.noteDetail, + builder: (_, state) => NoteDetailScreen( + noteId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: Routes.noteEdit, + builder: (_, state) => NoteEditScreen( + noteId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: Routes.taskNew, + builder: (_, _) => const TaskEditScreen(), + ), + GoRoute( + path: Routes.taskEdit, + builder: (_, state) => TaskEditScreen( + taskId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: Routes.chat, + builder: (_, state) => ChatScreen( + conversationId: int.parse(state.pathParameters['id']!), + ), + ), + ShellRoute( + builder: (context, state, child) => _Shell(child: child), + routes: [ + GoRoute( + path: Routes.notes, + builder: (_, _) => const NotesListScreen(), + ), + GoRoute( + path: Routes.tasks, + builder: (_, _) => const TasksListScreen(), + ), + GoRoute( + path: Routes.conversations, + builder: (_, _) => const ConversationsListScreen(), + ), + ], + ), + ], + ); +}); + +class _Shell extends ConsumerWidget { + final Widget child; + const _Shell({required this.child}); + + static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations]; + + int _tabIndex(String location) { + for (var i = 0; i < _tabs.length; i++) { + if (location.startsWith(_tabs[i])) return i; + } + return 0; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final location = GoRouterState.of(context).matchedLocation; + final index = _tabIndex(location); + + return Scaffold( + body: child, + bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (i) => context.go(_tabs[i]), + destinations: const [ + NavigationDestination(icon: Icon(Icons.note), label: 'Notes'), + NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'), + NavigationDestination( + icon: Icon(Icons.chat_bubble), label: 'Chat'), + ], + ), + floatingActionButton: FloatingActionButton( + heroTag: 'shell_fab', + onPressed: () => context.push(Routes.quickCapture), + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ); + } +} + +class FabledApp extends ConsumerWidget { + const FabledApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'Fabled', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + routerConfig: router, + ); + } +} diff --git a/lib/core/constants.dart b/lib/core/constants.dart new file mode 100644 index 0000000..104ea81 --- /dev/null +++ b/lib/core/constants.dart @@ -0,0 +1,16 @@ +abstract class Routes { + static const splash = '/'; + static const setup = '/setup'; + static const login = '/login'; + static const notes = '/notes'; + static const noteDetail = '/notes/:id'; + static const noteEdit = '/notes/:id/edit'; + static const noteNew = '/notes/new'; + static const tasks = '/tasks'; + static const taskNew = '/tasks/new'; + static const taskEdit = '/tasks/:id/edit'; + static const conversations = '/chat'; + static const chat = '/chat/:id'; + static const quickCapture = '/quick-capture'; + static const settings = '/settings'; +} diff --git a/lib/core/exceptions.dart b/lib/core/exceptions.dart new file mode 100644 index 0000000..a64fd1c --- /dev/null +++ b/lib/core/exceptions.dart @@ -0,0 +1,19 @@ +class AppException implements Exception { + final String message; + const AppException(this.message); + + @override + String toString() => message; +} + +class NetworkException extends AppException { + const NetworkException(super.message); +} + +class AuthException extends AppException { + const AuthException(super.message); +} + +class NotFoundException extends AppException { + const NotFoundException(super.message); +} diff --git a/lib/data/api/api_client.dart b/lib/data/api/api_client.dart new file mode 100644 index 0000000..1419f92 --- /dev/null +++ b/lib/data/api/api_client.dart @@ -0,0 +1,57 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; + +import '../../core/exceptions.dart'; + +PersistCookieJar buildCookieJar(String cookiesPath) => + PersistCookieJar(storage: FileStorage('$cookiesPath/.cookies/')); + +Dio buildDio(String baseUrl, PersistCookieJar cookieJar) { + + final dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: {'Content-Type': 'application/json'}, + )); + + dio.interceptors.add(CookieManager(cookieJar)); + dio.interceptors.add(_ErrorInterceptor()); + + return dio; +} + +class _ErrorInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.response?.statusCode == 401) { + handler.reject(DioException( + requestOptions: err.requestOptions, + error: const AuthException('Session expired. Please log in again.'), + type: err.type, + response: err.response, + )); + return; + } + if (err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.connectionError) { + handler.reject(DioException( + requestOptions: err.requestOptions, + error: const NetworkException('Cannot reach server. Check your connection.'), + type: err.type, + response: err.response, + )); + return; + } + handler.next(err); + } +} + +AppException dioToApp(DioException e) { + if (e.error is AppException) return e.error as AppException; + final status = e.response?.statusCode; + if (status == 401) return const AuthException('Not authenticated.'); + if (status == 404) return const NotFoundException('Resource not found.'); + return NetworkException(e.message ?? 'Unknown network error.'); +} diff --git a/lib/data/api/auth_api.dart b/lib/data/api/auth_api.dart new file mode 100644 index 0000000..4436a5d --- /dev/null +++ b/lib/data/api/auth_api.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; + +import '../../core/exceptions.dart'; +import 'api_client.dart'; + +class AuthApi { + final Dio _dio; + const AuthApi(this._dio); + + Future login(String username, String password) async { + try { + await _dio.post('/api/auth/login', data: { + 'username': username, + 'password': password, + }); + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw const AuthException('Invalid username or password.'); + } + throw dioToApp(e); + } + } + + Future logout() async { + try { + await _dio.post('/api/auth/logout'); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future verify() async { + try { + await _dio.get('/api/auth/me'); + return true; + } on DioException catch (e) { + if (e.response?.statusCode == 401) return false; + throw dioToApp(e); + } + } + + Future> getStatus() async { + try { + final response = await _dio.get('/api/auth/status'); + return response.data as Map; + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart new file mode 100644 index 0000000..3af7050 --- /dev/null +++ b/lib/data/api/chat_api.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../models/conversation.dart'; +import '../models/message.dart'; +import 'api_client.dart'; + +class ChatApi { + final Dio _dio; + const ChatApi(this._dio); + + Future> getConversations() async { + try { + final response = await _dio.get('/api/chat/conversations'); + final data = response.data as Map; + final list = data['conversations'] as List; + return list + .map((e) => Conversation.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future createConversation(String title) async { + try { + final response = await _dio.post('/api/chat/conversations', data: { + 'title': title, + }); + return Conversation.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future deleteConversation(int id) async { + try { + await _dio.delete('/api/chat/conversations/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + // Messages are embedded in the conversation detail response. + Future> getMessages(int conversationId) async { + try { + final response = + await _dio.get('/api/chat/conversations/$conversationId'); + final conv = response.data as Map; + final list = conv['messages'] as List? ?? []; + return list + .map((e) => Message.fromJson(e as Map)) + .where((m) => m.status != 'generating') // skip in-flight placeholders + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + // Step 1: POST the user message — server starts background generation. + Future sendMessage(int conversationId, String content) async { + try { + await _dio.post( + '/api/chat/conversations/$conversationId/messages', + data: {'content': content}, + ); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + // Step 2: GET the SSE stream and yield text chunks. + Stream streamGeneration(int conversationId) async* { + try { + final response = await _dio.get( + '/api/chat/conversations/$conversationId/generation/stream', + options: Options( + responseType: ResponseType.stream, + headers: {'Accept': 'text/event-stream'}, + ), + ); + + final stream = (response.data as ResponseBody).stream; + final buf = StringBuffer(); + String currentEvent = ''; + + await for (final chunk in stream) { + buf.write(utf8.decode(chunk)); + final raw = buf.toString(); + final lines = raw.split('\n'); + + // Keep the last (potentially incomplete) line in the buffer. + buf.clear(); + buf.write(lines.last); + + for (final line in lines.sublist(0, lines.length - 1)) { + if (line.startsWith('event: ')) { + currentEvent = line.substring(7).trim(); + } else if (line.startsWith('data: ')) { + final jsonStr = line.substring(6).trim(); + if (currentEvent == 'chunk') { + try { + final data = json.decode(jsonStr) as Map; + final text = data['text'] as String? ?? ''; + if (text.isNotEmpty) yield text; + } catch (_) {} + } else if (currentEvent == 'done' || currentEvent == 'error') { + return; + } + } else if (line.isEmpty) { + currentEvent = ''; // blank line = SSE event separator + } + } + } + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/api/notes_api.dart b/lib/data/api/notes_api.dart new file mode 100644 index 0000000..9451045 --- /dev/null +++ b/lib/data/api/notes_api.dart @@ -0,0 +1,61 @@ +import 'package:dio/dio.dart'; + +import '../models/note.dart'; +import 'api_client.dart'; + +class NotesApi { + final Dio _dio; + const NotesApi(this._dio); + + Future> getAll() async { + try { + final response = await _dio.get('/api/notes'); + final data = response.data as Map; + final list = data['notes'] as List; + return list.map((e) => Note.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future getOne(int id) async { + try { + final response = await _dio.get('/api/notes/$id'); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create(String title, String body) async { + try { + final response = await _dio.post('/api/notes', data: { + 'title': title, + 'body': body, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update(int id, String title, String body) async { + try { + final response = await _dio.put('/api/notes/$id', data: { + 'title': title, + 'body': body, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int id) async { + try { + await _dio.delete('/api/notes/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/api/tasks_api.dart b/lib/data/api/tasks_api.dart new file mode 100644 index 0000000..e9ec1fe --- /dev/null +++ b/lib/data/api/tasks_api.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; + +import '../models/task.dart'; +import 'api_client.dart'; + +class TasksApi { + final Dio _dio; + const TasksApi(this._dio); + + Future> getAll() async { + try { + final response = await _dio.get('/api/tasks'); + final data = response.data as Map; + final list = data['tasks'] as List; + return list.map((e) => Task.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future getOne(int id) async { + try { + final response = await _dio.get('/api/tasks/$id'); + return Task.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create({ + required String title, + String? description, + required TaskStatus status, + required TaskPriority priority, + DateTime? dueDate, + }) async { + try { + final response = await _dio.post('/api/tasks', data: { + 'title': title, + 'body': description, + 'status': status.value, + 'priority': priority.value, + 'due_date': dueDate?.toIso8601String(), + }); + return Task.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update(int id, Map fields) async { + try { + final response = await _dio.put('/api/tasks/$id', data: fields); + return Task.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int id) async { + try { + await _dio.delete('/api/tasks/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/models/conversation.dart b/lib/data/models/conversation.dart new file mode 100644 index 0000000..129c528 --- /dev/null +++ b/lib/data/models/conversation.dart @@ -0,0 +1,20 @@ +class Conversation { + final int id; + final String title; + final DateTime createdAt; + final DateTime updatedAt; + + const Conversation({ + required this.id, + required this.title, + required this.createdAt, + required this.updatedAt, + }); + + factory Conversation.fromJson(Map json) => Conversation( + id: json['id'] as int, + title: json['title'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} diff --git a/lib/data/models/message.dart b/lib/data/models/message.dart new file mode 100644 index 0000000..3df0a21 --- /dev/null +++ b/lib/data/models/message.dart @@ -0,0 +1,39 @@ +enum MessageRole { user, assistant } + +class Message { + final int? id; + final int conversationId; + final MessageRole role; + final String content; + final String status; // "complete" | "generating" + final DateTime? createdAt; + + const Message({ + this.id, + required this.conversationId, + required this.role, + required this.content, + this.status = 'complete', + this.createdAt, + }); + + factory Message.fromJson(Map json) => Message( + id: json['id'] as int?, + conversationId: json['conversation_id'] as int, + role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant, + content: json['content'] as String, + status: json['status'] as String? ?? 'complete', + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + ); + + Message copyWith({String? content, String? status}) => Message( + id: id, + conversationId: conversationId, + role: role, + content: content ?? this.content, + status: status ?? this.status, + createdAt: createdAt, + ); +} diff --git a/lib/data/models/note.dart b/lib/data/models/note.dart new file mode 100644 index 0000000..647d326 --- /dev/null +++ b/lib/data/models/note.dart @@ -0,0 +1,36 @@ +class Note { + final int id; + final String title; + final String body; + final DateTime createdAt; + final DateTime updatedAt; + + const Note({ + required this.id, + required this.title, + required this.body, + required this.createdAt, + required this.updatedAt, + }); + + factory Note.fromJson(Map json) => Note( + id: json['id'] as int, + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + Map toJson() => { + 'title': title, + 'body': body, + }; + + Note copyWith({String? title, String? body}) => Note( + id: id, + title: title ?? this.title, + body: body ?? this.body, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/lib/data/models/task.dart b/lib/data/models/task.dart new file mode 100644 index 0000000..67d90e5 --- /dev/null +++ b/lib/data/models/task.dart @@ -0,0 +1,107 @@ +enum TaskStatus { todo, inProgress, done } + +enum TaskPriority { none, low, medium, high } + +extension TaskStatusExtension on TaskStatus { + String get value => switch (this) { + TaskStatus.todo => 'todo', + TaskStatus.inProgress => 'in_progress', + TaskStatus.done => 'done', + }; + + String get label => switch (this) { + TaskStatus.todo => 'To Do', + TaskStatus.inProgress => 'In Progress', + TaskStatus.done => 'Done', + }; + + static TaskStatus fromString(String? s) => switch (s) { + 'in_progress' => TaskStatus.inProgress, + 'done' => TaskStatus.done, + _ => TaskStatus.todo, + }; +} + +extension TaskPriorityExtension on TaskPriority { + String get value => switch (this) { + TaskPriority.none => 'none', + TaskPriority.low => 'low', + TaskPriority.medium => 'medium', + TaskPriority.high => 'high', + }; + + String get label => switch (this) { + TaskPriority.none => 'None', + TaskPriority.low => 'Low', + TaskPriority.medium => 'Medium', + TaskPriority.high => 'High', + }; + + static TaskPriority fromString(String? s) => switch (s) { + 'high' => TaskPriority.high, + 'medium' => TaskPriority.medium, + 'low' => TaskPriority.low, + _ => TaskPriority.none, + }; +} + +class Task { + final int id; + final String title; + final String? description; + final TaskStatus status; + final TaskPriority priority; + final DateTime? dueDate; + final DateTime createdAt; + final DateTime updatedAt; + + const Task({ + required this.id, + required this.title, + this.description, + required this.status, + required this.priority, + this.dueDate, + required this.createdAt, + required this.updatedAt, + }); + + factory Task.fromJson(Map json) => Task( + id: json['id'] as int, + title: json['title'] as String? ?? '', + description: json['body'] as String?, + status: TaskStatusExtension.fromString(json['status'] as String?), + priority: TaskPriorityExtension.fromString(json['priority'] as String?), + dueDate: json['due_date'] != null + ? DateTime.parse(json['due_date'] as String) + : null, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + Map toJson() => { + 'title': title, + 'body': description, + 'status': status.value, + 'priority': priority.value, + 'due_date': dueDate?.toIso8601String(), + }; + + Task copyWith({ + String? title, + String? description, + TaskStatus? status, + TaskPriority? priority, + DateTime? dueDate, + }) => + Task( + id: id, + title: title ?? this.title, + description: description ?? this.description, + status: status ?? this.status, + priority: priority ?? this.priority, + dueDate: dueDate ?? this.dueDate, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart new file mode 100644 index 0000000..3ca2fa7 --- /dev/null +++ b/lib/data/repositories/auth_repository.dart @@ -0,0 +1,15 @@ +import '../api/auth_api.dart'; + +class AuthRepository { + final AuthApi _api; + const AuthRepository(this._api); + + Future login(String username, String password) => + _api.login(username, password); + + Future logout() => _api.logout(); + + Future verify() => _api.verify(); + + Future> getStatus() => _api.getStatus(); +} diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart new file mode 100644 index 0000000..afa2d3b --- /dev/null +++ b/lib/data/repositories/chat_repository.dart @@ -0,0 +1,19 @@ +import '../api/chat_api.dart'; +import '../models/conversation.dart'; +import '../models/message.dart'; + +class ChatRepository { + final ChatApi _api; + const ChatRepository(this._api); + + Future> getConversations() => _api.getConversations(); + Future createConversation(String title) => + _api.createConversation(title); + Future deleteConversation(int id) => _api.deleteConversation(id); + Future> getMessages(int conversationId) => + _api.getMessages(conversationId); + Future sendMessage(int conversationId, String content) => + _api.sendMessage(conversationId, content); + Stream streamGeneration(int conversationId) => + _api.streamGeneration(conversationId); +} diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart new file mode 100644 index 0000000..e09b90d --- /dev/null +++ b/lib/data/repositories/notes_repository.dart @@ -0,0 +1,15 @@ +import '../api/notes_api.dart'; +import '../models/note.dart'; + +class NotesRepository { + final NotesApi _api; + const NotesRepository(this._api); + + Future> getAll() => _api.getAll(); + Future getOne(int id) => _api.getOne(id); + Future create(String title, String body) => + _api.create(title, body); + Future update(int id, String title, String body) => + _api.update(id, title, body); + Future delete(int id) => _api.delete(id); +} diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart new file mode 100644 index 0000000..c4bba4b --- /dev/null +++ b/lib/data/repositories/tasks_repository.dart @@ -0,0 +1,30 @@ +import '../api/tasks_api.dart'; +import '../models/task.dart'; + +class TasksRepository { + final TasksApi _api; + const TasksRepository(this._api); + + Future> getAll() => _api.getAll(); + Future getOne(int id) => _api.getOne(id); + + Future create({ + required String title, + String? description, + required TaskStatus status, + required TaskPriority priority, + DateTime? dueDate, + }) => + _api.create( + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + ); + + Future update(int id, Map fields) => + _api.update(id, fields); + + Future delete(int id) => _api.delete(id); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..4ae553e --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app.dart'; +import 'providers/settings_provider.dart'; + +const _widgetChannel = MethodChannel('com.fabledapp.fabled_app/widget'); + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final prefs = await SharedPreferences.getInstance(); + final appDir = await getApplicationDocumentsDirectory(); + + runApp( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + cookiesPathProvider.overrideWithValue(appDir.path), + ], + child: const _WidgetIntentListener(child: FabledApp()), + ), + ); +} + +// Listens for MethodChannel calls from the native Android widget. +class _WidgetIntentListener extends StatefulWidget { + final Widget child; + const _WidgetIntentListener({required this.child}); + + @override + State<_WidgetIntentListener> createState() => _WidgetIntentListenerState(); +} + +class _WidgetIntentListenerState extends State<_WidgetIntentListener> { + @override + void initState() { + super.initState(); + _widgetChannel.setMethodCallHandler((call) async { + if (call.method == 'openChat') { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true) + .pushNamedAndRemoveUntil('/chat', (_) => false); + } + }); + } + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart new file mode 100644 index 0000000..8a337a9 --- /dev/null +++ b/lib/providers/api_client_provider.dart @@ -0,0 +1,57 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/api/api_client.dart'; +import '../data/api/auth_api.dart'; +import '../data/api/chat_api.dart'; +import '../data/api/notes_api.dart'; +import '../data/api/tasks_api.dart'; +import '../data/repositories/auth_repository.dart'; +import '../data/repositories/chat_repository.dart'; +import '../data/repositories/notes_repository.dart'; +import '../data/repositories/tasks_repository.dart'; +import 'settings_provider.dart'; + +final cookieJarProvider = Provider((ref) { + final cookiesPath = ref.watch(cookiesPathProvider); + return buildCookieJar(cookiesPath); +}); + +final dioProvider = Provider((ref) { + final url = ref.watch(serverUrlProvider) ?? ''; + final jar = ref.watch(cookieJarProvider); + return buildDio(url, jar); +}); + +final authApiProvider = Provider((ref) { + return AuthApi(ref.watch(dioProvider)); +}); + +final notesApiProvider = Provider((ref) { + return NotesApi(ref.watch(dioProvider)); +}); + +final tasksApiProvider = Provider((ref) { + return TasksApi(ref.watch(dioProvider)); +}); + +final chatApiProvider = Provider((ref) { + return ChatApi(ref.watch(dioProvider)); +}); + +final authRepositoryProvider = Provider((ref) { + return AuthRepository(ref.watch(authApiProvider)); +}); + +final notesRepositoryProvider = Provider((ref) { + return NotesRepository(ref.watch(notesApiProvider)); +}); + +final tasksRepositoryProvider = Provider((ref) { + return TasksRepository(ref.watch(tasksApiProvider)); +}); + +final chatRepositoryProvider = Provider((ref) { + return ChatRepository(ref.watch(chatApiProvider)); +}); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..6cf1a0f --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_client_provider.dart'; + +enum AuthStatus { unknown, authenticated, unauthenticated } + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(ref); +}); + +class AuthNotifier extends StateNotifier { + final Ref _ref; + + AuthNotifier(this._ref) : super(AuthStatus.unknown); + + Future verify() async { + try { + final repo = _ref.read(authRepositoryProvider); + final ok = await repo.verify(); + state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated; + } catch (_) { + state = AuthStatus.unauthenticated; + } + } + + Future login(String username, String password) async { + final repo = _ref.read(authRepositoryProvider); + await repo.login(username, password); + state = AuthStatus.authenticated; + } + + Future logout() async { + try { + final repo = _ref.read(authRepositoryProvider); + await repo.logout(); + } finally { + state = AuthStatus.unauthenticated; + } + } +} diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart new file mode 100644 index 0000000..f76c8b8 --- /dev/null +++ b/lib/providers/chat_provider.dart @@ -0,0 +1,98 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/conversation.dart'; +import '../data/models/message.dart'; +import 'api_client_provider.dart'; + +final conversationsProvider = + AsyncNotifierProvider>( + ConversationsNotifier.new); + +class ConversationsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(chatRepositoryProvider).getConversations(); + } + + Future create(String title) async { + final conv = + await ref.read(chatRepositoryProvider).createConversation(title); + state = AsyncData([conv, ...state.valueOrNull ?? []]); + return conv; + } + + Future delete(int id) async { + await ref.read(chatRepositoryProvider).deleteConversation(id); + state = AsyncData([ + for (final c in state.valueOrNull ?? []) + if (c.id != id) c, + ]); + } +} + +final messagesProvider = AsyncNotifierProvider.family, int>(MessagesNotifier.new); + +class MessagesNotifier extends FamilyAsyncNotifier, int> { + bool _isStreaming = false; + bool get isStreaming => _isStreaming; + + @override + Future> build(int arg) async { + return ref.watch(chatRepositoryProvider).getMessages(arg); + } + + Future sendMessage(String content) async { + final conversationId = arg; + final repo = ref.read(chatRepositoryProvider); + + // Optimistically add the user message. + final userMsg = Message( + conversationId: conversationId, + role: MessageRole.user, + content: content, + ); + state = AsyncData([...state.valueOrNull ?? [], userMsg]); + + // Add an empty assistant placeholder. + final placeholder = Message( + conversationId: conversationId, + role: MessageRole.assistant, + content: '', + status: 'generating', + ); + state = AsyncData([...state.requireValue, placeholder]); + + _isStreaming = true; + try { + // Step 1: POST the message (fires background generation on server). + await repo.sendMessage(conversationId, content); + + // Step 2: Stream chunks and update the placeholder in place. + await for (final chunk in repo.streamGeneration(conversationId)) { + final msgs = state.requireValue; + final last = msgs.last.copyWith(content: msgs.last.content + chunk); + state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]); + } + + // Mark the assistant message as complete. + final msgs = state.requireValue; + final last = msgs.last.copyWith(status: 'complete'); + state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]); + } catch (e) { + // Remove the placeholder on error so the user can retry. + state = AsyncData([ + for (final m in state.valueOrNull ?? []) + if (m.status != 'generating') m, + ]); + rethrow; + } finally { + _isStreaming = false; + } + } +} + +final isStreamingProvider = Provider.family((ref, convId) { + final notifier = ref.watch(messagesProvider(convId).notifier); + return notifier.isStreaming; +}); diff --git a/lib/providers/notes_provider.dart b/lib/providers/notes_provider.dart new file mode 100644 index 0000000..106c571 --- /dev/null +++ b/lib/providers/notes_provider.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/note.dart'; +import 'api_client_provider.dart'; + +final notesProvider = + AsyncNotifierProvider>(NotesNotifier.new); + +class NotesNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(notesRepositoryProvider).getAll(); + } + + Future create(String title, String body) async { + final note = + await ref.read(notesRepositoryProvider).create(title, body); + state = AsyncData([...state.valueOrNull ?? [], note]); + return note; + } + + Future updateNote(int id, String title, String body) async { + final updated = + await ref.read(notesRepositoryProvider).update(id, title, body); + state = AsyncData([ + for (final n in state.valueOrNull ?? []) + if (n.id == id) updated else n, + ]); + return updated; + } + + Future delete(int id) async { + await ref.read(notesRepositoryProvider).delete(id); + state = AsyncData([ + for (final n in state.valueOrNull ?? []) + if (n.id != id) n, + ]); + } +} + +final noteDetailProvider = + FutureProvider.family((ref, id) async { + return ref.watch(notesRepositoryProvider).getOne(id); +}); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 0000000..6421d68 --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kServerUrl = 'server_url'; + +final sharedPreferencesProvider = Provider((ref) { + throw UnimplementedError('Override in ProviderScope'); +}); + +final cookiesPathProvider = Provider((ref) { + throw UnimplementedError('Override in ProviderScope'); +}); + +final serverUrlProvider = StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return ServerUrlNotifier(prefs); +}); + +class ServerUrlNotifier extends StateNotifier { + final SharedPreferences _prefs; + + ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl)); + + Future setUrl(String url) async { + // Strip trailing slash + final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + await _prefs.setString(_kServerUrl, clean); + state = clean; + } + + Future clear() async { + await _prefs.remove(_kServerUrl); + state = null; + } +} diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart new file mode 100644 index 0000000..00b476f --- /dev/null +++ b/lib/providers/tasks_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/task.dart'; +import 'api_client_provider.dart'; + +final tasksProvider = + AsyncNotifierProvider>(TasksNotifier.new); + +class TasksNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(tasksRepositoryProvider).getAll(); + } + + Future create({ + required String title, + String? description, + required TaskStatus status, + required TaskPriority priority, + DateTime? dueDate, + }) async { + final task = await ref.read(tasksRepositoryProvider).create( + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + ); + state = AsyncData([...state.valueOrNull ?? [], task]); + return task; + } + + Future updateTask(int id, Map fields) async { + final updated = await ref.read(tasksRepositoryProvider).update(id, fields); + state = AsyncData([ + for (final t in state.valueOrNull ?? []) + if (t.id == id) updated else t, + ]); + return updated; + } + + Future delete(int id) async { + await ref.read(tasksRepositoryProvider).delete(id); + state = AsyncData([ + for (final t in state.valueOrNull ?? []) + if (t.id != id) t, + ]); + } +} diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart new file mode 100644 index 0000000..e5f24cc --- /dev/null +++ b/lib/screens/auth/login_screen.dart @@ -0,0 +1,280 @@ +import 'dart:io' as io; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.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/api_client_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/settings_provider.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + bool _loadingStatus = true; + bool _oauthEnabled = false; + bool _localAuthEnabled = false; + + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _loading = false; + bool _obscure = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadStatus(); + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _loadStatus() async { + try { + final status = await ref.read(authRepositoryProvider).getStatus(); + if (!mounted) return; + setState(() { + _oauthEnabled = status['oauth_enabled'] as bool? ?? false; + _localAuthEnabled = status['local_auth_enabled'] as bool? ?? true; + _loadingStatus = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _localAuthEnabled = true; + _loadingStatus = false; + }); + } + } + + Future _localLogin() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _error = null; + }); + try { + await ref.read(authProvider.notifier).login( + _usernameController.text.trim(), + _passwordController.text, + ); + if (mounted) context.go(Routes.notes); + } on AuthException catch (e) { + setState(() => _error = e.message); + } on AppException catch (e) { + setState(() => _error = e.message); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _openOAuth() { + final serverUrl = ref.read(serverUrlProvider) ?? ''; + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => _OAuthWebView( + serverUrl: serverUrl, + cookieJar: ref.read(cookieJarProvider), + onSuccess: () async { + await ref.read(authProvider.notifier).verify(); + if (mounted) context.go(Routes.notes); + }, + ), + )); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: _loadingStatus + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Sign In', + style: TextStyle( + fontSize: 28, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (_oauthEnabled) ...[ + FilledButton.icon( + icon: const Icon(Icons.login), + label: const Text('Sign in with SSO'), + onPressed: _openOAuth, + ), + ], + if (_oauthEnabled && _localAuthEnabled) ...[ + const SizedBox(height: 16), + const Row(children: [ + Expanded(child: Divider()), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('or'), + ), + Expanded(child: Divider()), + ]), + const SizedBox(height: 16), + ], + if (_localAuthEnabled) ...[ + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder()), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_obscure + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => + setState(() => _obscure = !_obscure), + ), + ), + obscureText: _obscure, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _localLogin(), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, + style: TextStyle( + color: + Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _loading ? null : _localLogin, + child: _loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Sign In'), + ), + ], + if (!_oauthEnabled && !_localAuthEnabled) + Text( + 'No sign-in method is available. Check server configuration.', + style: TextStyle( + color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => context.go(Routes.setup), + child: const Text('Change server'), + ), + ], + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// In-app WebView that handles the server-side OAuth redirect flow. +// --------------------------------------------------------------------------- +class _OAuthWebView extends StatefulWidget { + final String serverUrl; + final dynamic cookieJar; // PersistCookieJar — avoid importing cookie_jar here + final Future Function() onSuccess; + + const _OAuthWebView({ + required this.serverUrl, + required this.cookieJar, + required this.onSuccess, + }); + + @override + State<_OAuthWebView> createState() => _OAuthWebViewState(); +} + +class _OAuthWebViewState extends State<_OAuthWebView> { + bool _completing = false; + + Future _handleUrl(String url) async { + if (_completing) return; + // The server redirects to "/" on successful OAuth completion. + final root = widget.serverUrl.endsWith('/') + ? widget.serverUrl + : '${widget.serverUrl}/'; + if (url == widget.serverUrl || url == root) { + _completing = true; + + // Copy cookies from Android WebView store → Dio PersistCookieJar. + final cookieManager = CookieManager.instance(); + final wvCookies = + await cookieManager.getCookies(url: WebUri(widget.serverUrl)); + final serverUri = Uri.parse(widget.serverUrl); + final ioCookies = wvCookies.map((c) { + final dc = io.Cookie(c.name, c.value.toString()); + var domain = c.domain ?? serverUri.host; + if (domain.startsWith('.')) domain = domain.substring(1); + dc.domain = domain; + dc.path = c.path ?? '/'; + return dc; + }).toList(); + await widget.cookieJar.saveFromResponse(serverUri, ioCookies); + + if (mounted) { + Navigator.of(context).pop(); + await widget.onSuccess(); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sign in'), + leading: const CloseButton(), + ), + body: InAppWebView( + initialUrlRequest: URLRequest( + url: WebUri('${widget.serverUrl}/api/auth/oauth/login'), + ), + onLoadStop: (controller, url) async { + if (url != null) await _handleUrl(url.toString()); + }, + ), + ); + } +} diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart new file mode 100644 index 0000000..98d3bd8 --- /dev/null +++ b/lib/screens/chat/chat_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; +import '../../providers/chat_provider.dart'; + +class ChatScreen extends ConsumerStatefulWidget { + final int conversationId; + const ChatScreen({super.key, required this.conversationId}); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final _controller = TextEditingController(); + final _scrollController = ScrollController(); + + @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 _send() async { + final text = _controller.text.trim(); + if (text.isEmpty) return; + _controller.clear(); + try { + await ref + .read(messagesProvider(widget.conversationId).notifier) + .sendMessage(text); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + + @override + Widget build(BuildContext context) { + final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); + final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); + + // Scroll when messages change + ref.listen(messagesProvider(widget.conversationId), (_, _) { + _scrollToBottom(); + }); + + return Scaffold( + appBar: AppBar(title: const Text('Chat')), + body: Column( + children: [ + Expanded( + child: messagesAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (messages) { + if (messages.isEmpty) { + return const Center( + child: Text('Send a message to start.')); + } + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 12), + itemCount: messages.length, + itemBuilder: (context, i) => + _MessageBubble(message: messages[i]), + ); + }, + ), + ), + const Divider(height: 1), + SafeArea( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Message...', + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + maxLines: 4, + minLines: 1, + textInputAction: TextInputAction.newline, + enabled: !isStreaming, + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: isStreaming ? null : _send, + icon: isStreaming + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +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: MediaQuery.of(context).size.width * 0.8, + ), + 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)), + ), + ), + ); + } +} diff --git a/lib/screens/chat/conversations_list_screen.dart b/lib/screens/chat/conversations_list_screen.dart new file mode 100644 index 0000000..7321d09 --- /dev/null +++ b/lib/screens/chat/conversations_list_screen.dart @@ -0,0 +1,122 @@ +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 ConversationsListScreen extends ConsumerWidget { + const ConversationsListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final convsAsync = ref.watch(conversationsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Chat')), + body: convsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: $e'), + 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), + subtitle: Text( + conv.updatedAt.toLocal().toString().substring(0, 16), + style: Theme.of(context).textTheme.bodySmall, + ), + onTap: () => context.push( + Routes.chat.replaceFirst(':id', '${conv.id}'), + ), + onLongPress: () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete conversation?'), + content: Text(conv.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm == true) { + await ref + .read(conversationsProvider.notifier) + .delete(conv.id); + } + }, + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'chat_fab', + onPressed: () => _newConversation(context, ref), + child: const Icon(Icons.add), + ), + ); + } + + Future _newConversation(BuildContext context, WidgetRef ref) async { + final controller = TextEditingController(); + final title = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('New Conversation'), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'Title'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Create'), + ), + ], + ), + ); + if (title != null && title.isNotEmpty) { + final conv = + await ref.read(conversationsProvider.notifier).create(title); + if (context.mounted) { + context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); + } + } + } +} diff --git a/lib/screens/notes/note_detail_screen.dart b/lib/screens/notes/note_detail_screen.dart new file mode 100644 index 0000000..00e02d4 --- /dev/null +++ b/lib/screens/notes/note_detail_screen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../providers/notes_provider.dart'; + +class NoteDetailScreen extends ConsumerWidget { + final int noteId; + const NoteDetailScreen({super.key, required this.noteId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final noteAsync = ref.watch(noteDetailProvider(noteId)); + + return Scaffold( + appBar: AppBar( + title: noteAsync.maybeWhen( + data: (n) => Text(n.title), + orElse: () => const Text('Note'), + ), + actions: [ + noteAsync.maybeWhen( + data: (note) => Row( + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => context.push( + Routes.noteEdit.replaceFirst(':id', '$noteId'), + ), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete note?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm == true) { + await ref.read(notesProvider.notifier).delete(noteId); + if (context.mounted) context.pop(); + } + }, + ), + ], + ), + orElse: () => const SizedBox.shrink(), + ), + ], + ), + body: noteAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (note) => Markdown( + data: note.body, + selectable: true, + ), + ), + ); + } +} diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart new file mode 100644 index 0000000..6381bd5 --- /dev/null +++ b/lib/screens/notes/note_edit_screen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.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'; + +class NoteEditScreen extends ConsumerStatefulWidget { + final int? noteId; + const NoteEditScreen({super.key, this.noteId}); + + @override + ConsumerState createState() => _NoteEditScreenState(); +} + +class _NoteEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + bool _preview = false; + bool _saving = false; + bool _loaded = false; + + @override + void dispose() { + _titleController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + if (_loaded || widget.noteId == null) { + _loaded = true; + return; + } + final note = + await ref.read(notesRepositoryProvider).getOne(widget.noteId!); + _titleController.text = note.title; + _contentController.text = note.body; + _loaded = true; + } + + Future _save() async { + final title = _titleController.text.trim(); + final body = _contentController.text; + if (title.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.noteId == null) { + await ref.read(notesProvider.notifier).create(title, body); + if (mounted) context.pop(); + } else { + await ref + .read(notesProvider.notifier) + .updateNote(widget.noteId!, title, body); + if (mounted) context.pop(); + } + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loadExisting(), + builder: (context, snapshot) { + return Scaffold( + appBar: AppBar( + title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'), + actions: [ + IconButton( + icon: Icon(_preview ? Icons.edit : Icons.preview), + tooltip: _preview ? 'Edit' : 'Preview', + onPressed: () => setState(() => _preview = !_preview), + ), + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + onPressed: _saving ? null : _save, + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextField( + controller: _titleController, + decoration: const InputDecoration( + hintText: 'Title', + border: InputBorder.none, + ), + style: Theme.of(context).textTheme.titleLarge, + textInputAction: TextInputAction.next, + ), + ), + const Divider(), + Expanded( + child: _preview + ? Markdown(data: _contentController.text) + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _contentController, + decoration: const InputDecoration( + hintText: 'Write in markdown...', + border: InputBorder.none, + ), + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + onChanged: (_) => setState(() {}), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/notes/notes_list_screen.dart b/lib/screens/notes/notes_list_screen.dart new file mode 100644 index 0000000..c43d1c2 --- /dev/null +++ b/lib/screens/notes/notes_list_screen.dart @@ -0,0 +1,64 @@ +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/notes_provider.dart'; + +class NotesListScreen extends ConsumerWidget { + const NotesListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notesAsync = ref.watch(notesProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Notes')), + body: notesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: $e'), + TextButton( + onPressed: () => ref.invalidate(notesProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (notes) { + if (notes.isEmpty) { + return const Center(child: Text('No notes yet. Tap + to create one.')); + } + return RefreshIndicator( + onRefresh: () => ref.refresh(notesProvider.future), + child: ListView.separated( + itemCount: notes.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final note = notes[i]; + return ListTile( + title: Text(note.title), + subtitle: Text( + note.updatedAt.toLocal().toString().substring(0, 16), + style: Theme.of(context).textTheme.bodySmall, + ), + onTap: () => context.push( + Routes.noteDetail.replaceFirst(':id', '${note.id}'), + ), + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'notes_fab', + onPressed: () => context.push(Routes.noteNew), + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/screens/quick_capture/quick_capture_screen.dart b/lib/screens/quick_capture/quick_capture_screen.dart new file mode 100644 index 0000000..ea245e9 --- /dev/null +++ b/lib/screens/quick_capture/quick_capture_screen.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/task.dart'; +import '../../providers/notes_provider.dart'; +import '../../providers/tasks_provider.dart'; + +class QuickCaptureScreen extends ConsumerStatefulWidget { + const QuickCaptureScreen({super.key}); + + @override + ConsumerState createState() => + _QuickCaptureScreenState(); +} + +class _QuickCaptureScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabs; + final _noteTitleController = TextEditingController(); + final _noteContentController = TextEditingController(); + final _taskTitleController = TextEditingController(); + TaskPriority _taskPriority = TaskPriority.medium; + bool _saving = false; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabs.dispose(); + _noteTitleController.dispose(); + _noteContentController.dispose(); + _taskTitleController.dispose(); + super.dispose(); + } + + Future _saveNote() async { + final title = _noteTitleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + await ref + .read(notesProvider.notifier) + .create(title, _noteContentController.text); + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _saveTask() async { + final title = _taskTitleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + await ref.read(tasksProvider.notifier).create( + title: title, + status: TaskStatus.todo, + priority: _taskPriority, + ); + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Quick Capture'), + bottom: TabBar( + controller: _tabs, + tabs: const [Tab(text: 'Note'), Tab(text: 'Task')], + ), + ), + body: TabBarView( + controller: _tabs, + children: [ + // Note tab + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _noteTitleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autofocus: true, + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + controller: _noteContentController, + decoration: const InputDecoration( + hintText: 'Content (optional, supports markdown)', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _saveNote, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save Note'), + ), + ), + ], + ), + ), + // Task tab + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _taskTitleController, + decoration: const InputDecoration( + labelText: 'Task title', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _taskPriority, + decoration: const InputDecoration( + labelText: 'Priority', + border: OutlineInputBorder(), + ), + items: TaskPriority.values + .map((p) => + DropdownMenuItem(value: p, child: Text(p.label))) + .toList(), + onChanged: (v) => setState(() => _taskPriority = v!), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _saveTask, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save Task'), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart new file mode 100644 index 0000000..f48406f --- /dev/null +++ b/lib/screens/settings/settings_screen.dart @@ -0,0 +1,39 @@ +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/auth_provider.dart'; +import '../../providers/settings_provider.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverUrl = ref.watch(serverUrlProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + ListTile( + title: const Text('Server URL'), + subtitle: Text(serverUrl ?? 'Not configured'), + leading: const Icon(Icons.dns), + onTap: () => context.go(Routes.setup), + ), + const Divider(), + ListTile( + title: const Text('Sign Out'), + leading: const Icon(Icons.logout), + onTap: () async { + await ref.read(authProvider.notifier).logout(); + if (context.mounted) context.go(Routes.login); + }, + ), + ], + ), + ); + } +} diff --git a/lib/screens/setup/setup_screen.dart b/lib/screens/setup/setup_screen.dart new file mode 100644 index 0000000..9634a58 --- /dev/null +++ b/lib/screens/setup/setup_screen.dart @@ -0,0 +1,123 @@ +import 'package:dio/dio.dart'; +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/settings_provider.dart'; + +class SetupScreen extends ConsumerStatefulWidget { + const SetupScreen({super.key}); + + @override + ConsumerState createState() => _SetupScreenState(); +} + +class _SetupScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _urlController = TextEditingController(); + bool _testing = false; + String? _error; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + Future _testAndSave() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _testing = true; + _error = null; + }); + + var url = _urlController.text.trim(); + if (url.endsWith('/')) url = url.substring(0, url.length - 1); + + try { + final dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 5))); + await dio.get('$url/api/auth/status'); + // 401 is fine — server is reachable + } on DioException catch (_) { + setState(() { + _testing = false; + _error = 'Could not reach server. Check the URL and try again.'; + }); + return; + } catch (e) { + setState(() { + _testing = false; + _error = 'Could not reach server. Check the URL and try again.'; + }); + return; + } + + await ref.read(serverUrlProvider.notifier).setUrl(url); + if (mounted) context.go(Routes.login); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Welcome to Fabled', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Enter your FabledAssistant server URL to get started.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'https://fabled.example.com', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + autocorrect: false, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final uri = Uri.tryParse(v.trim()); + if (uri == null || !uri.hasScheme) { + return 'Enter a valid URL (include http:// or https://)'; + } + return null; + }, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _testing ? null : _testAndSave, + child: _testing + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connect'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/splash/splash_screen.dart b/lib/screens/splash/splash_screen.dart new file mode 100644 index 0000000..c911608 --- /dev/null +++ b/lib/screens/splash/splash_screen.dart @@ -0,0 +1,54 @@ +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/auth_provider.dart'; +import '../../providers/settings_provider.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + _check(); + } + + Future _check() async { + final serverUrl = ref.read(serverUrlProvider); + if (serverUrl == null || serverUrl.isEmpty) { + if (mounted) context.go(Routes.setup); + return; + } + await ref.read(authProvider.notifier).verify(); + if (!mounted) return; + final status = ref.read(authProvider); + if (status == AuthStatus.authenticated) { + context.go(Routes.notes); + } else { + context.go(Routes.login); + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Fabled', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + CircularProgressIndicator(), + ], + ), + ), + ); + } +} diff --git a/lib/screens/tasks/task_edit_screen.dart b/lib/screens/tasks/task_edit_screen.dart new file mode 100644 index 0000000..ddaae41 --- /dev/null +++ b/lib/screens/tasks/task_edit_screen.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/task.dart'; +import '../../providers/api_client_provider.dart'; +import '../../providers/tasks_provider.dart'; + +class TaskEditScreen extends ConsumerStatefulWidget { + final int? taskId; + const TaskEditScreen({super.key, this.taskId}); + + @override + ConsumerState createState() => _TaskEditScreenState(); +} + +class _TaskEditScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + TaskStatus _status = TaskStatus.todo; + TaskPriority _priority = TaskPriority.medium; + DateTime? _dueDate; + bool _saving = false; + bool _loaded = false; + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + if (_loaded || widget.taskId == null) { + _loaded = true; + return; + } + final task = await ref.read(tasksRepositoryProvider).getOne(widget.taskId!); + _titleController.text = task.title; + _descController.text = task.description ?? ''; + _status = task.status; + _priority = task.priority; + _dueDate = task.dueDate; + _loaded = true; + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + if (widget.taskId == null) { + await ref.read(tasksProvider.notifier).create( + title: _titleController.text.trim(), + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + status: _status, + priority: _priority, + dueDate: _dueDate, + ); + } else { + await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, { + 'title': _titleController.text.trim(), + 'description': _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + 'status': _status.value, + 'priority': _priority.value, + 'due_date': _dueDate?.toIso8601String(), + }); + } + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete task?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete')), + ], + ), + ); + if (confirm == true) { + await ref.read(tasksProvider.notifier).delete(widget.taskId!); + if (mounted) context.pop(); + } + } + + Future _pickDate() async { + final date = await showDatePicker( + context: context, + initialDate: _dueDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (date != null) setState(() => _dueDate = date); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loadExisting(), + builder: (context, snapshot) { + return Scaffold( + appBar: AppBar( + title: Text(widget.taskId == null ? 'New Task' : 'Edit Task'), + actions: [ + if (widget.taskId != null) + IconButton( + icon: const Icon(Icons.delete), + onPressed: _delete, + ), + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + onPressed: _saving ? null : _save, + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder(), + ), + items: TaskStatus.values + .map((s) => DropdownMenuItem( + value: s, child: Text(s.label))) + .toList(), + onChanged: (v) => setState(() => _status = v!), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _priority, + decoration: const InputDecoration( + labelText: 'Priority', + border: OutlineInputBorder(), + ), + items: TaskPriority.values + .map((p) => DropdownMenuItem( + value: p, child: Text(p.label))) + .toList(), + onChanged: (v) => setState(() => _priority = v!), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_dueDate == null + ? 'No due date' + : 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'), + leading: const Icon(Icons.calendar_today), + trailing: _dueDate != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => + setState(() => _dueDate = null), + ) + : null, + onTap: _pickDate, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart new file mode 100644 index 0000000..176536e --- /dev/null +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -0,0 +1,129 @@ +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 createState() => _TasksListScreenState(); +} + +class _TasksListScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabs; + + @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( + appBar: AppBar( + title: const Text('Tasks'), + bottom: TabBar( + controller: _tabs, + tabs: const [ + Tab(text: 'To Do'), + Tab(text: 'In Progress'), + Tab(text: 'Done'), + ], + ), + ), + body: tasksAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: $e'), + TextButton( + onPressed: () => ref.invalidate(tasksProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (tasks) { + final todo = + tasks.where((t) => t.status == TaskStatus.todo).toList(); + final inProgress = + tasks.where((t) => t.status == TaskStatus.inProgress).toList(); + final done = + tasks.where((t) => t.status == TaskStatus.done).toList(); + + return RefreshIndicator( + onRefresh: () => ref.refresh(tasksProvider.future), + child: TabBarView( + controller: _tabs, + children: [ + _TaskList(tasks: todo), + _TaskList(tasks: inProgress), + _TaskList(tasks: done), + ], + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'tasks_fab', + onPressed: () => context.push(Routes.taskNew), + child: const Icon(Icons.add), + ), + ); + } +} + +class _TaskList extends ConsumerWidget { + final List tasks; + const _TaskList({required this.tasks}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (tasks.isEmpty) { + return const Center(child: Text('No tasks here.')); + } + 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); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..3095666 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,626 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" + url: "https://pub.dev" + source: hosted + version: "4.0.9" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..c7224a8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,31 @@ +name: fabled_app +description: "FabledAssistant mobile client for Android." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + flutter_riverpod: ^2.5.1 + go_router: ^14.2.0 + dio: ^5.6.0 + cookie_jar: ^4.0.8 + dio_cookie_manager: ^3.1.1 + path_provider: ^2.1.4 + shared_preferences: ^2.3.2 + flutter_markdown: ^0.7.3 + flutter_inappwebview: ^6.1.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..2ebd3e2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,8 @@ +// Placeholder — integration tests go here once the app is running on device. +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(true, isTrue); + }); +}