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 <noreply@anthropic.com>
This commit is contained in:
+51
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="fabled_app"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Home screen widget -->
|
||||||
|
<receiver
|
||||||
|
android:name=".ChatWidgetProvider"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/chat_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="@android:color/white"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:src="@mipmap/ic_launcher"
|
||||||
|
android:contentDescription="Fabled" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:text="Ask Fabled..."
|
||||||
|
android:textColor="#757575"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="3"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:updatePeriodMillis="0"
|
||||||
|
android:initialLayout="@layout/chat_widget_layout"
|
||||||
|
android:resizeMode="horizontal"
|
||||||
|
android:widgetCategory="home_screen" />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
+193
@@ -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<GoRouter>((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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _dio.post('/api/auth/logout');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<Map<String, dynamic>> getStatus() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/auth/status');
|
||||||
|
return response.data as Map<String, dynamic>;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Conversation>> getConversations() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/chat/conversations');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['conversations'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Conversation.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Conversation> createConversation(String title) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post('/api/chat/conversations', data: {
|
||||||
|
'title': title,
|
||||||
|
});
|
||||||
|
return Conversation.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<List<Message>> getMessages(int conversationId) async {
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _dio.get('/api/chat/conversations/$conversationId');
|
||||||
|
final conv = response.data as Map<String, dynamic>;
|
||||||
|
final list = conv['messages'] as List<dynamic>? ?? [];
|
||||||
|
return list
|
||||||
|
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||||
|
.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<void> 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<String> 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<String, dynamic>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Note>> getAll() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/notes');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['notes'] as List<dynamic>;
|
||||||
|
return list.map((e) => Note.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/notes/$id');
|
||||||
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> 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<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> 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<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/notes/$id');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Task>> getAll() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/tasks');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['tasks'] as List<dynamic>;
|
||||||
|
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/tasks/$id');
|
||||||
|
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task> 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<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.put('/api/tasks/$id', data: fields);
|
||||||
|
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/tasks/$id');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import '../api/auth_api.dart';
|
||||||
|
|
||||||
|
class AuthRepository {
|
||||||
|
final AuthApi _api;
|
||||||
|
const AuthRepository(this._api);
|
||||||
|
|
||||||
|
Future<void> login(String username, String password) =>
|
||||||
|
_api.login(username, password);
|
||||||
|
|
||||||
|
Future<void> logout() => _api.logout();
|
||||||
|
|
||||||
|
Future<bool> verify() => _api.verify();
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getStatus() => _api.getStatus();
|
||||||
|
}
|
||||||
@@ -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<List<Conversation>> getConversations() => _api.getConversations();
|
||||||
|
Future<Conversation> createConversation(String title) =>
|
||||||
|
_api.createConversation(title);
|
||||||
|
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
|
||||||
|
Future<List<Message>> getMessages(int conversationId) =>
|
||||||
|
_api.getMessages(conversationId);
|
||||||
|
Future<void> sendMessage(int conversationId, String content) =>
|
||||||
|
_api.sendMessage(conversationId, content);
|
||||||
|
Stream<String> streamGeneration(int conversationId) =>
|
||||||
|
_api.streamGeneration(conversationId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import '../api/notes_api.dart';
|
||||||
|
import '../models/note.dart';
|
||||||
|
|
||||||
|
class NotesRepository {
|
||||||
|
final NotesApi _api;
|
||||||
|
const NotesRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Note>> getAll() => _api.getAll();
|
||||||
|
Future<Note> getOne(int id) => _api.getOne(id);
|
||||||
|
Future<Note> create(String title, String body) =>
|
||||||
|
_api.create(title, body);
|
||||||
|
Future<Note> update(int id, String title, String body) =>
|
||||||
|
_api.update(id, title, body);
|
||||||
|
Future<void> delete(int id) => _api.delete(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import '../api/tasks_api.dart';
|
||||||
|
import '../models/task.dart';
|
||||||
|
|
||||||
|
class TasksRepository {
|
||||||
|
final TasksApi _api;
|
||||||
|
const TasksRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Task>> getAll() => _api.getAll();
|
||||||
|
Future<Task> getOne(int id) => _api.getOne(id);
|
||||||
|
|
||||||
|
Future<Task> 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<Task> update(int id, Map<String, dynamic> fields) =>
|
||||||
|
_api.update(id, fields);
|
||||||
|
|
||||||
|
Future<void> delete(int id) => _api.delete(id);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<PersistCookieJar>((ref) {
|
||||||
|
final cookiesPath = ref.watch(cookiesPathProvider);
|
||||||
|
return buildCookieJar(cookiesPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
final dioProvider = Provider<Dio>((ref) {
|
||||||
|
final url = ref.watch(serverUrlProvider) ?? '';
|
||||||
|
final jar = ref.watch(cookieJarProvider);
|
||||||
|
return buildDio(url, jar);
|
||||||
|
});
|
||||||
|
|
||||||
|
final authApiProvider = Provider<AuthApi>((ref) {
|
||||||
|
return AuthApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final notesApiProvider = Provider<NotesApi>((ref) {
|
||||||
|
return NotesApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final tasksApiProvider = Provider<TasksApi>((ref) {
|
||||||
|
return TasksApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final chatApiProvider = Provider<ChatApi>((ref) {
|
||||||
|
return ChatApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
|
return AuthRepository(ref.watch(authApiProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
||||||
|
return NotesRepository(ref.watch(notesApiProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||||
|
return TasksRepository(ref.watch(tasksApiProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||||
|
return ChatRepository(ref.watch(chatApiProvider));
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
|
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||||
|
|
||||||
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
|
||||||
|
return AuthNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class AuthNotifier extends StateNotifier<AuthStatus> {
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
AuthNotifier(this._ref) : super(AuthStatus.unknown);
|
||||||
|
|
||||||
|
Future<void> verify() async {
|
||||||
|
try {
|
||||||
|
final repo = _ref.read(authRepositoryProvider);
|
||||||
|
final ok = await repo.verify();
|
||||||
|
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||||
|
} catch (_) {
|
||||||
|
state = AuthStatus.unauthenticated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> login(String username, String password) async {
|
||||||
|
final repo = _ref.read(authRepositoryProvider);
|
||||||
|
await repo.login(username, password);
|
||||||
|
state = AuthStatus.authenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
final repo = _ref.read(authRepositoryProvider);
|
||||||
|
await repo.logout();
|
||||||
|
} finally {
|
||||||
|
state = AuthStatus.unauthenticated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, List<Conversation>>(
|
||||||
|
ConversationsNotifier.new);
|
||||||
|
|
||||||
|
class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||||
|
@override
|
||||||
|
Future<List<Conversation>> build() async {
|
||||||
|
return ref.watch(chatRepositoryProvider).getConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Conversation> create(String title) async {
|
||||||
|
final conv =
|
||||||
|
await ref.read(chatRepositoryProvider).createConversation(title);
|
||||||
|
state = AsyncData([conv, ...state.valueOrNull ?? []]);
|
||||||
|
return conv;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<MessagesNotifier,
|
||||||
|
List<Message>, int>(MessagesNotifier.new);
|
||||||
|
|
||||||
|
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||||
|
bool _isStreaming = false;
|
||||||
|
bool get isStreaming => _isStreaming;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Message>> build(int arg) async {
|
||||||
|
return ref.watch(chatRepositoryProvider).getMessages(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<bool, int>((ref, convId) {
|
||||||
|
final notifier = ref.watch(messagesProvider(convId).notifier);
|
||||||
|
return notifier.isStreaming;
|
||||||
|
});
|
||||||
@@ -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, List<Note>>(NotesNotifier.new);
|
||||||
|
|
||||||
|
class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||||
|
@override
|
||||||
|
Future<List<Note>> build() async {
|
||||||
|
return ref.watch(notesRepositoryProvider).getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> create(String title, String body) async {
|
||||||
|
final note =
|
||||||
|
await ref.read(notesRepositoryProvider).create(title, body);
|
||||||
|
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> updateNote(int id, String title, String body) async {
|
||||||
|
final updated =
|
||||||
|
await ref.read(notesRepositoryProvider).update(id, title, body);
|
||||||
|
state = AsyncData([
|
||||||
|
for (final n in state.valueOrNull ?? [])
|
||||||
|
if (n.id == id) updated else n,
|
||||||
|
]);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<Note, int>((ref, id) async {
|
||||||
|
return ref.watch(notesRepositoryProvider).getOne(id);
|
||||||
|
});
|
||||||
@@ -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<SharedPreferences>((ref) {
|
||||||
|
throw UnimplementedError('Override in ProviderScope');
|
||||||
|
});
|
||||||
|
|
||||||
|
final cookiesPathProvider = Provider<String>((ref) {
|
||||||
|
throw UnimplementedError('Override in ProviderScope');
|
||||||
|
});
|
||||||
|
|
||||||
|
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
|
||||||
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
|
return ServerUrlNotifier(prefs);
|
||||||
|
});
|
||||||
|
|
||||||
|
class ServerUrlNotifier extends StateNotifier<String?> {
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
|
||||||
|
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
|
||||||
|
|
||||||
|
Future<void> 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<void> clear() async {
|
||||||
|
await _prefs.remove(_kServerUrl);
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, List<Task>>(TasksNotifier.new);
|
||||||
|
|
||||||
|
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||||
|
@override
|
||||||
|
Future<List<Task>> build() async {
|
||||||
|
return ref.watch(tasksRepositoryProvider).getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task> 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<Task> updateTask(int id, Map<String, dynamic> 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<void> delete(int id) async {
|
||||||
|
await ref.read(tasksRepositoryProvider).delete(id);
|
||||||
|
state = AsyncData([
|
||||||
|
for (final t in state.valueOrNull ?? [])
|
||||||
|
if (t.id != id) t,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
|
bool _loadingStatus = true;
|
||||||
|
bool _oauthEnabled = false;
|
||||||
|
bool _localAuthEnabled = false;
|
||||||
|
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void> _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<void> 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<void> _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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||||
|
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<void> _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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool>(
|
||||||
|
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<void> _newConversation(BuildContext context, WidgetRef ref) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final title = await showDialog<String>(
|
||||||
|
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}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NoteEditScreen> createState() => _NoteEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||||
|
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<void> _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<void> _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(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuickCaptureScreen> createState() =>
|
||||||
|
_QuickCaptureScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickCaptureScreenState extends ConsumerState<QuickCaptureScreen>
|
||||||
|
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<void> _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<void> _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<TaskPriority>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SetupScreen> createState() => _SetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _urlController = TextEditingController();
|
||||||
|
bool _testing = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SplashScreen> createState() => _SplashScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_check();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TaskEditScreen> createState() => _TaskEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void> _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<void> _delete() async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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<void> _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<TaskStatus>(
|
||||||
|
initialValue: _status,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Status',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskStatus.values
|
||||||
|
.map((s) => DropdownMenuItem(
|
||||||
|
value: s, child: Text(s.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _status = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskPriority>(
|
||||||
|
initialValue: _priority,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Priority',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskPriority.values
|
||||||
|
.map((p) => DropdownMenuItem(
|
||||||
|
value: p, child: Text(p.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _priority = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(_dueDate == null
|
||||||
|
? 'No due date'
|
||||||
|
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||||
|
leading: const Icon(Icons.calendar_today),
|
||||||
|
trailing: _dueDate != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _dueDate = null),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _pickDate,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TasksListScreen> createState() => _TasksListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
|
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<Task> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+626
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user