Initial Flutter booru app
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
/isar.dll
|
||||
.idea/
|
||||
*.iml
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
android/local.properties
|
||||
*.log
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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: "ff37bef603469fb030f2b72995ab929ccfc227f0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: android
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: linux
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: windows
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
|
||||
# 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,280 @@
|
||||
# GelRuleApp
|
||||
|
||||
GelRuleApp is a single Flutter monolith for browsing booru/gelbooru/rule34-like providers with a Pinterest-style feed. It does not start or require a separate backend server. Provider access, caching, favorites, collections, search history, settings, and health checks all run locally inside the Flutter app.
|
||||
|
||||
## Stack
|
||||
|
||||
- Dart and Flutter
|
||||
- Riverpod for dependency injection
|
||||
- Dio for provider HTTP APIs
|
||||
- Isar for local storage
|
||||
- connectivity_plus for network state
|
||||
- build_runner with Isar/freezed/json_serializable dependencies available for generated code workflows
|
||||
- go_router for navigation
|
||||
- cached_network_image and flutter_staggered_grid_view for the Pinterest-style feed
|
||||
- media_kit for video posts
|
||||
|
||||
## Structure
|
||||
|
||||
- `lib/core/http`: Dio setup, retry/backoff, network info
|
||||
- `lib/core/database`: Isar database and entity mappings
|
||||
- `lib/core/cache`: metadata cache helpers
|
||||
- `lib/core/errors`: app exceptions and failures
|
||||
- `lib/core/utils`: `Result<T>` and logging
|
||||
- `lib/backend/models`: domain models
|
||||
- `lib/backend/providers`: provider interface, concrete providers, factory, manager
|
||||
- `lib/backend/repositories`: local persistence access
|
||||
- `lib/backend/services`: app-facing service layer
|
||||
- `lib/backend/di`: Riverpod providers
|
||||
- `lib/app`: app shell, router, theme, responsive helpers
|
||||
- `lib/shared/widgets`: reusable UI pieces
|
||||
- `lib/features`: feed, post details, search, favorites, collections, providers, settings
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
- `/`: masonry feed
|
||||
- `/search`: recent searches and autocomplete
|
||||
- `/post/:providerId/:postId`: post details
|
||||
- `/favorites`: favorite posts
|
||||
- `/collections`: boards
|
||||
- `/collections/:collectionId`: posts inside a collection
|
||||
- `/providers`: provider management
|
||||
- `/providers/new`: provider form for create/edit
|
||||
- `/providers/check`: provider health checks
|
||||
- `/settings`: app settings
|
||||
|
||||
## Frontend And Backend Integration
|
||||
|
||||
The UI calls backend services through Riverpod providers from `lib/backend/di/backend_providers.dart`.
|
||||
|
||||
- `FeedController` calls `FeedService`, `SearchService`, and `ProviderManager`.
|
||||
- `PostDetailsScreen` uses cached post metadata first, then `ProviderManager.getPost`.
|
||||
- `PostCard` and details actions call `FavoriteService` and `CollectionService`.
|
||||
- Provider screens call `ProviderManager` and `ProviderCheckService`.
|
||||
- Settings call `SettingsService` and `CacheService`.
|
||||
|
||||
Business logic stays in backend/core services. UI controllers only coordinate state and user actions.
|
||||
|
||||
## Providers
|
||||
|
||||
The MVP ships with seed configs for:
|
||||
|
||||
- Gelbooru: `https://gelbooru.com`
|
||||
- Rule34: `https://api.rule34.xxx`
|
||||
- Safebooru: `https://safebooru.org`
|
||||
|
||||
Providers implement:
|
||||
|
||||
```dart
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
String get name;
|
||||
String get baseUrl;
|
||||
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
});
|
||||
|
||||
Future<Post?> getPost(String id);
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
```
|
||||
|
||||
Gelbooru and Rule34 use the Gelbooru-compatible dapi endpoint:
|
||||
|
||||
```text
|
||||
/index.php?page=dapi&s=post&q=index&json=1
|
||||
```
|
||||
|
||||
Danbooru uses:
|
||||
|
||||
```text
|
||||
/posts.json
|
||||
```
|
||||
|
||||
All provider responses are normalized to `Post`.
|
||||
|
||||
## Adding A Provider
|
||||
|
||||
1. Add a mapper in `lib/backend/mappers`.
|
||||
2. Implement `ContentProvider` in `lib/backend/providers`.
|
||||
3. Register the `apiType` in `ProviderFactory`.
|
||||
4. Save a `ContentProviderConfig` with `ProviderManager.addCustomProvider`.
|
||||
|
||||
For custom providers, MVP supports these `apiType` values:
|
||||
|
||||
- `gelbooru`
|
||||
- `rule34`
|
||||
- `danbooru`
|
||||
|
||||
Unsupported types return `ProviderUnavailableException` instead of crashing the app.
|
||||
|
||||
## ProviderManager
|
||||
|
||||
`ProviderManager` loads enabled provider configs from Isar, sorts them by `priority`, creates provider instances through `ProviderFactory`, checks health, and performs multi-provider search.
|
||||
|
||||
Search behavior:
|
||||
|
||||
- Offline providers are skipped based on saved health.
|
||||
- A failed provider marks itself offline.
|
||||
- Other providers continue returning posts.
|
||||
- Results are combined into a single list.
|
||||
|
||||
## Health Checks
|
||||
|
||||
Use `ProviderCheckService`:
|
||||
|
||||
```dart
|
||||
final health = await ref
|
||||
.read(providerCheckServiceProvider)
|
||||
.checkOne('gelbooru');
|
||||
|
||||
final all = await ref
|
||||
.read(providerCheckServiceProvider)
|
||||
.checkAll();
|
||||
```
|
||||
|
||||
`checkAll()` runs in parallel with a concurrency limit of 3 and saves `ProviderHealth` in Isar.
|
||||
|
||||
## Using Services From Flutter UI
|
||||
|
||||
Initialize your app with Riverpod:
|
||||
|
||||
```dart
|
||||
ProviderScope(
|
||||
child: MyApp(),
|
||||
);
|
||||
```
|
||||
|
||||
Load a feed:
|
||||
|
||||
```dart
|
||||
final feed = ref.read(feedServiceProvider);
|
||||
final result = await feed.refresh(
|
||||
tags: ['landscape'],
|
||||
rating: 'safe',
|
||||
);
|
||||
```
|
||||
|
||||
Infinite scroll:
|
||||
|
||||
```dart
|
||||
final next = await ref.read(feedServiceProvider).loadNextPage(
|
||||
tags: ['landscape'],
|
||||
);
|
||||
```
|
||||
|
||||
Search history:
|
||||
|
||||
```dart
|
||||
final search = ref.read(searchServiceProvider);
|
||||
final tags = search.parseTags('cat cute');
|
||||
await search.saveSearch('cat cute', 120);
|
||||
final recent = await search.recentSearches();
|
||||
```
|
||||
|
||||
Favorites:
|
||||
|
||||
```dart
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
final saved = await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.isFavorite(post.id, post.providerId);
|
||||
```
|
||||
|
||||
Collections:
|
||||
|
||||
```dart
|
||||
final collection = await ref
|
||||
.read(collectionServiceProvider)
|
||||
.createCollection('Inspiration', 'Reference posts');
|
||||
|
||||
await ref
|
||||
.read(collectionServiceProvider)
|
||||
.addPostToCollection(collection.data.id, post);
|
||||
```
|
||||
|
||||
Settings export/import:
|
||||
|
||||
```dart
|
||||
final settings = ref.read(settingsServiceProvider);
|
||||
final json = await settings.exportSettingsToJson();
|
||||
await settings.importSettingsFromJson(json.data);
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
Isar stores:
|
||||
|
||||
- provider configs
|
||||
- provider health
|
||||
- cached post metadata
|
||||
- favorites
|
||||
- collections
|
||||
- collection-post links
|
||||
- search history
|
||||
- app settings
|
||||
|
||||
Post cache stores metadata only. It does not download or persist original media files.
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- The app requests API metadata only.
|
||||
- It does not bypass site limits or authentication.
|
||||
- Dio uses a clear User-Agent.
|
||||
- Retry is limited and uses exponential backoff.
|
||||
- Provider failures are isolated so one offline provider does not break the feed.
|
||||
|
||||
## Development
|
||||
|
||||
Fetch dependencies:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
On Windows, Flutter plugins require Developer Mode for symlink support. If `flutter pub get` or desktop builds show a symlink warning, enable Developer Mode in Windows settings.
|
||||
|
||||
Generate Isar code:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
Run the app:
|
||||
|
||||
```bash
|
||||
flutter run -d windows
|
||||
flutter run -d linux
|
||||
flutter run -d android
|
||||
```
|
||||
|
||||
Build release artifacts:
|
||||
|
||||
```bash
|
||||
flutter build windows
|
||||
flutter build linux
|
||||
flutter build apk
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Windows builds require Windows Developer Mode when plugins are used.
|
||||
- Linux builds must be run on a Linux host.
|
||||
- Android builds require Android SDK and `ANDROID_HOME`.
|
||||
|
||||
## Adding A Screen
|
||||
|
||||
1. Add a route in `lib/app/router.dart`.
|
||||
2. Add feature state/controller/screen under `lib/features/<name>/presentation`.
|
||||
3. Use backend providers from `backend_providers.dart`; do not recreate Dio, Isar, or provider logic in UI code.
|
||||
4. Add loading, error, empty, and success states.
|
||||
@@ -0,0 +1,9 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: true
|
||||
@@ -0,0 +1,11 @@
|
||||
/.gradle
|
||||
/captures/
|
||||
/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.example.gel_rule_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.example.gel_rule_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,47 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:label="gel_rule_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<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>
|
||||
<!-- 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,5 @@
|
||||
package com.example.gel_rule_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -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="@color/launch_background" />
|
||||
|
||||
<!-- 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="@color/launch_background" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
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,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background">#101114</color>
|
||||
</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.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.NoTitleBar">
|
||||
<item name="android:windowBackground">#101114</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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,37 @@
|
||||
import com.android.build.gradle.LibraryExtension
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
subprojects {
|
||||
plugins.withId("com.android.library") {
|
||||
extensions.configure<LibraryExtension>("android") {
|
||||
if (namespace == null) {
|
||||
namespace = group.toString()
|
||||
}
|
||||
compileSdk = 36
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -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")
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../backend/backend.dart';
|
||||
import 'router.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
class GelRuleApp extends ConsumerWidget {
|
||||
const GelRuleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final database = ref.watch(appDatabaseProvider);
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return database.when(
|
||||
loading: () => const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: _StartupLoadingScreen(),
|
||||
),
|
||||
error: (error, _) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(Brightness.light),
|
||||
darkTheme: buildTheme(Brightness.dark),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: _StartupErrorScreen(error: error),
|
||||
),
|
||||
data: (_) => MaterialApp.router(
|
||||
title: 'GelRule',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(Brightness.light),
|
||||
darkTheme: buildTheme(Brightness.dark),
|
||||
themeMode: settings.maybeWhen(
|
||||
data: (value) => parseThemeMode(value.themeMode),
|
||||
orElse: () => ThemeMode.dark,
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final appSettingsProvider = FutureProvider<AppSettings>((ref) {
|
||||
return ref.watch(settingsServiceProvider).getSettings().then(
|
||||
(result) => result.fold(
|
||||
onSuccess: (settings) => settings,
|
||||
onError: (_) => AppSettings.defaults,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
class _StartupLoadingScreen extends StatelessWidget {
|
||||
const _StartupLoadingScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF101114),
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFE84D8A),
|
||||
brightness: Brightness.dark,
|
||||
).primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupErrorScreen extends ConsumerWidget {
|
||||
const _StartupErrorScreen({required this.error});
|
||||
|
||||
final Object error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.storage_rounded, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not start local database',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$error',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.invalidate(appDatabaseProvider),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AppBreakpoints {
|
||||
static const mobile = 700.0;
|
||||
static const desktop = 1100.0;
|
||||
}
|
||||
|
||||
class Responsive {
|
||||
static bool isMobile(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width < AppBreakpoints.mobile;
|
||||
|
||||
static bool isDesktop(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= AppBreakpoints.desktop;
|
||||
|
||||
static int columnsFor(
|
||||
BuildContext context, {
|
||||
required int mobileColumns,
|
||||
required int desktopColumns,
|
||||
}) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
if (width < 380) return 1;
|
||||
if (width < AppBreakpoints.mobile) return mobileColumns.clamp(1, 2);
|
||||
if (width < AppBreakpoints.desktop) return 3;
|
||||
return desktopColumns.clamp(3, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../backend/backend.dart';
|
||||
import '../features/collections/presentation/collection_details_screen.dart';
|
||||
import '../features/collections/presentation/collections_screen.dart';
|
||||
import '../features/favorites/presentation/favorites_screen.dart';
|
||||
import '../features/feed/presentation/feed_screen.dart';
|
||||
import '../features/post/presentation/post_details_screen.dart';
|
||||
import '../features/providers/presentation/provider_check_screen.dart';
|
||||
import '../features/providers/presentation/provider_form_screen.dart';
|
||||
import '../features/providers/presentation/providers_screen.dart';
|
||||
import '../features/search/presentation/search_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
import '../shared/widgets/app_shell.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
builder: (context, state) => const SearchScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) => PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/favorites',
|
||||
builder: (context, state) => const FavoritesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/collections',
|
||||
builder: (context, state) => const CollectionsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/collections/:collectionId',
|
||||
builder: (context, state) => CollectionDetailsScreen(
|
||||
collectionId: state.pathParameters['collectionId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers',
|
||||
builder: (context, state) => const ProvidersScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers/new',
|
||||
builder: (context, state) => ProviderFormScreen(
|
||||
initialConfig: state.extra is ContentProviderConfig
|
||||
? state.extra! as ContentProviderConfig
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers/check',
|
||||
builder: (context, state) => const ProviderCheckScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildTheme(Brightness brightness) {
|
||||
final dark = brightness == Brightness.dark;
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFE84D8A),
|
||||
brightness: brightness,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor:
|
||||
dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
|
||||
cardColor: dark ? const Color(0xFF191B20) : Colors.white,
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: dark ? const Color(0xFF15161A) : Colors.white,
|
||||
selectedIconTheme: IconThemeData(color: scheme.primary),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
|
||||
foregroundColor: scheme.onSurface,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMode parseThemeMode(String value) {
|
||||
return switch (value) {
|
||||
'light' => ThemeMode.light,
|
||||
'system' => ThemeMode.system,
|
||||
_ => ThemeMode.dark,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export 'di/backend_providers.dart';
|
||||
export 'models/collection.dart';
|
||||
export 'models/content_provider_config.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/provider_health.dart';
|
||||
export 'models/search_history.dart';
|
||||
export 'models/top_period_filter.dart';
|
||||
export 'providers/content_provider.dart';
|
||||
export 'providers/provider_manager.dart';
|
||||
export 'services/collection_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
export 'services/search_service.dart';
|
||||
export 'services/settings_service.dart';
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/cache/cache_service.dart';
|
||||
import '../../core/cache/image_cache_service.dart';
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/http/network_info.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
import '../services/search_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
|
||||
final database = await AppDatabase.open();
|
||||
ref.onDispose(database.close);
|
||||
return database;
|
||||
});
|
||||
|
||||
final databaseServiceProvider = Provider<DatabaseService>((ref) {
|
||||
final database = ref.watch(appDatabaseProvider).requireValue;
|
||||
return DatabaseService(database);
|
||||
});
|
||||
|
||||
final connectivityProvider = Provider<Connectivity>((ref) => Connectivity());
|
||||
|
||||
final networkInfoProvider = Provider<NetworkInfo>((ref) {
|
||||
return NetworkInfo(ref.watch(connectivityProvider));
|
||||
});
|
||||
|
||||
final providerFactoryProvider = Provider<ProviderFactory>((ref) {
|
||||
return ProviderFactory();
|
||||
});
|
||||
|
||||
final postRepositoryProvider = Provider<PostRepository>((ref) {
|
||||
return PostRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final providerRepositoryProvider = Provider<ProviderRepository>((ref) {
|
||||
return ProviderRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
|
||||
return SearchRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||
return FavoriteRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final collectionRepositoryProvider = Provider<CollectionRepository>((ref) {
|
||||
return CollectionRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final cacheServiceProvider = Provider<CacheService>((ref) {
|
||||
return CacheService(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final imageCacheServiceProvider = Provider<ImageCacheService>((ref) {
|
||||
return ImageCacheService();
|
||||
});
|
||||
|
||||
final providerManagerProvider = Provider<ProviderManager>((ref) {
|
||||
return ProviderManager(
|
||||
ref.watch(providerRepositoryProvider),
|
||||
ref.watch(providerFactoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final feedServiceProvider = Provider<FeedService>((ref) {
|
||||
return FeedService(
|
||||
ref.watch(providerManagerProvider),
|
||||
ref.watch(cacheServiceProvider),
|
||||
ref.watch(settingsServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final searchServiceProvider = Provider<SearchService>((ref) {
|
||||
return SearchService(ref.watch(searchRepositoryProvider));
|
||||
});
|
||||
|
||||
final providerCheckServiceProvider = Provider<ProviderCheckService>((ref) {
|
||||
return ProviderCheckService(
|
||||
ref.watch(providerRepositoryProvider),
|
||||
ref.watch(providerFactoryProvider),
|
||||
ref.watch(providerManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final favoriteServiceProvider = Provider<FavoriteService>((ref) {
|
||||
return FavoriteService(
|
||||
ref.watch(favoriteRepositoryProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final collectionServiceProvider = Provider<CollectionService>((ref) {
|
||||
return CollectionService(ref.watch(collectionRepositoryProvider));
|
||||
});
|
||||
|
||||
final settingsServiceProvider = Provider<SettingsService>((ref) {
|
||||
return SettingsService(
|
||||
ref.watch(databaseServiceProvider),
|
||||
providerRepository: ref.watch(providerRepositoryProvider),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
class DanbooruMapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final items = data is List ? data : const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => postFromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Post postFromJson(
|
||||
Map<String, dynamic> json, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final fileUrl = _string(json['file_url'] ?? json['large_file_url']);
|
||||
final sampleUrl =
|
||||
_string(json['large_file_url'] ?? json['sample_file_url'] ?? fileUrl);
|
||||
final previewUrl = _string(json['preview_file_url'] ?? sampleUrl);
|
||||
return Post(
|
||||
id: _string(json['id']),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: _tags(json['tag_string'] ?? json['tags']),
|
||||
rating: _string(json['rating'], fallback: 'unknown'),
|
||||
width: _int(json['image_width'] ?? json['width']),
|
||||
height: _int(json['image_height'] ?? json['height']),
|
||||
source: _nullableString(json['source']),
|
||||
createdAt:
|
||||
DateTime.tryParse(_string(json['created_at'])) ?? DateTime.now(),
|
||||
fileType: _fileType(fileUrl),
|
||||
score: _int(json['score']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _tags(dynamic value) {
|
||||
if (value is List) return value.map((tag) => tag.toString()).toList();
|
||||
return _string(value)
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String _string(dynamic value, {String fallback = ''}) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String? _nullableString(dynamic value) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static String _fileType(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.isEmpty) return 'unknown';
|
||||
return 'image';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
class GelbooruMapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final items = _extractItems(data);
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => postFromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Post postFromJson(
|
||||
Map<String, dynamic> json, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final id = _string(json['id']);
|
||||
final fileUrl = _string(json['file_url'] ?? json['fileUrl']);
|
||||
final sampleUrl = _string(json['sample_url'] ??
|
||||
json['sampleUrl'] ??
|
||||
json['large_file_url'] ??
|
||||
fileUrl);
|
||||
final previewUrl =
|
||||
_string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl);
|
||||
return Post(
|
||||
id: id,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: _tags(json['tags'] ?? json['tag_string']),
|
||||
rating: _string(json['rating'], fallback: 'unknown'),
|
||||
width: _int(json['width']),
|
||||
height: _int(json['height']),
|
||||
source: _nullableString(json['source']),
|
||||
createdAt: _date(json['created_at'] ?? json['createdAt']),
|
||||
fileType: _fileType(fileUrl),
|
||||
score: _int(json['score']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<dynamic> _extractItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
final posts = data['post'] ?? data['posts'];
|
||||
if (posts is List) return posts;
|
||||
if (posts is Map && posts['post'] is List) return posts['post'] as List;
|
||||
if (posts is Map) return [posts];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static List<String> _tags(dynamic value) {
|
||||
if (value is List) return value.map((tag) => tag.toString()).toList();
|
||||
return _string(value)
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String _string(dynamic value, {String fallback = ''}) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String? _nullableString(dynamic value) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static DateTime _date(dynamic value) {
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
return DateTime.tryParse(value?.toString() ?? '') ?? DateTime.now();
|
||||
}
|
||||
|
||||
static String _fileType(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.isEmpty) return 'unknown';
|
||||
return 'image';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../models/post.dart';
|
||||
import 'gelbooru_mapper.dart';
|
||||
|
||||
class Rule34Mapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
data,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
class Collection {
|
||||
const Collection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.coverUrl,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? coverUrl;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Collection copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? description,
|
||||
String? coverUrl,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) =>
|
||||
Collection(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
class ContentProviderConfig {
|
||||
const ContentProviderConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required this.apiType,
|
||||
required this.enabled,
|
||||
required this.priority,
|
||||
required this.timeoutSeconds,
|
||||
required this.customHeaders,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String baseUrl;
|
||||
final String apiType;
|
||||
final bool enabled;
|
||||
final int priority;
|
||||
final int timeoutSeconds;
|
||||
final Map<String, String> customHeaders;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
ContentProviderConfig copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? baseUrl,
|
||||
String? apiType,
|
||||
bool? enabled,
|
||||
int? priority,
|
||||
int? timeoutSeconds,
|
||||
Map<String, String>? customHeaders,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return ContentProviderConfig(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
apiType: apiType ?? this.apiType,
|
||||
enabled: enabled ?? this.enabled,
|
||||
priority: priority ?? this.priority,
|
||||
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
|
||||
customHeaders: customHeaders ?? this.customHeaders,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'baseUrl': baseUrl,
|
||||
'apiType': apiType,
|
||||
'enabled': enabled,
|
||||
'priority': priority,
|
||||
'timeoutSeconds': timeoutSeconds,
|
||||
'customHeaders': customHeaders,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ContentProviderConfig.fromJson(Map<String, dynamic> json) {
|
||||
return ContentProviderConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
baseUrl: json['baseUrl'] as String,
|
||||
apiType: json['apiType'] as String,
|
||||
enabled: (json['enabled'] as bool?) ?? true,
|
||||
priority: (json['priority'] as num?)?.toInt() ?? 0,
|
||||
timeoutSeconds: (json['timeoutSeconds'] as num?)?.toInt() ?? 20,
|
||||
customHeaders: Map<String, String>.from(
|
||||
(json['customHeaders'] as Map?) ?? const {},
|
||||
),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class Favorite {
|
||||
const Favorite({
|
||||
required this.id,
|
||||
required this.postId,
|
||||
required this.providerId,
|
||||
required this.savedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String postId;
|
||||
final String providerId;
|
||||
final DateTime savedAt;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class Post {
|
||||
const Post({
|
||||
required this.id,
|
||||
required this.providerId,
|
||||
required this.providerName,
|
||||
required this.previewUrl,
|
||||
required this.sampleUrl,
|
||||
required this.fileUrl,
|
||||
required this.tags,
|
||||
required this.rating,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.source,
|
||||
required this.createdAt,
|
||||
required this.fileType,
|
||||
required this.score,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String providerId;
|
||||
final String providerName;
|
||||
final String previewUrl;
|
||||
final String sampleUrl;
|
||||
final String fileUrl;
|
||||
final List<String> tags;
|
||||
final String rating;
|
||||
final int width;
|
||||
final int height;
|
||||
final String? source;
|
||||
final DateTime createdAt;
|
||||
final String fileType;
|
||||
final int score;
|
||||
|
||||
String get cacheKey => '$providerId:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'providerId': providerId,
|
||||
'providerName': providerName,
|
||||
'previewUrl': previewUrl,
|
||||
'sampleUrl': sampleUrl,
|
||||
'fileUrl': fileUrl,
|
||||
'tags': tags,
|
||||
'rating': rating,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'source': source,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'fileType': fileType,
|
||||
'score': score,
|
||||
};
|
||||
|
||||
factory Post.fromJson(Map<String, dynamic> json) => Post(
|
||||
id: json['id'].toString(),
|
||||
providerId: json['providerId'] as String,
|
||||
providerName: json['providerName'] as String,
|
||||
previewUrl: (json['previewUrl'] as String?) ?? '',
|
||||
sampleUrl: (json['sampleUrl'] as String?) ?? '',
|
||||
fileUrl: (json['fileUrl'] as String?) ?? '',
|
||||
tags: List<String>.from((json['tags'] as List?) ?? const []),
|
||||
rating: (json['rating'] as String?) ?? 'unknown',
|
||||
width: (json['width'] as num?)?.toInt() ?? 0,
|
||||
height: (json['height'] as num?)?.toInt() ?? 0,
|
||||
source: json['source'] as String?,
|
||||
createdAt: DateTime.tryParse((json['createdAt'] as String?) ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
fileType: (json['fileType'] as String?) ?? 'unknown',
|
||||
score: (json['score'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
enum ProviderStatus { unknown, online, offline }
|
||||
|
||||
class ProviderHealth {
|
||||
const ProviderHealth({
|
||||
required this.providerId,
|
||||
required this.status,
|
||||
required this.pingMs,
|
||||
required this.lastCheckedAt,
|
||||
this.errorMessage,
|
||||
this.apiVersion,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final ProviderStatus status;
|
||||
final int pingMs;
|
||||
final DateTime lastCheckedAt;
|
||||
final String? errorMessage;
|
||||
final String? apiVersion;
|
||||
|
||||
bool get isOnline => status == ProviderStatus.online;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'providerId': providerId,
|
||||
'status': status.name,
|
||||
'pingMs': pingMs,
|
||||
'lastCheckedAt': lastCheckedAt.toIso8601String(),
|
||||
'errorMessage': errorMessage,
|
||||
'apiVersion': apiVersion,
|
||||
};
|
||||
|
||||
factory ProviderHealth.fromJson(Map<String, dynamic> json) => ProviderHealth(
|
||||
providerId: json['providerId'] as String,
|
||||
status: ProviderStatus.values.firstWhere(
|
||||
(value) => value.name == json['status'],
|
||||
orElse: () => ProviderStatus.unknown,
|
||||
),
|
||||
pingMs: (json['pingMs'] as num?)?.toInt() ?? 0,
|
||||
lastCheckedAt: DateTime.parse(json['lastCheckedAt'] as String),
|
||||
errorMessage: json['errorMessage'] as String?,
|
||||
apiVersion: json['apiVersion'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class SearchHistory {
|
||||
const SearchHistory({
|
||||
required this.id,
|
||||
required this.query,
|
||||
required this.tags,
|
||||
required this.searchedAt,
|
||||
required this.resultCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String query;
|
||||
final List<String> tags;
|
||||
final DateTime searchedAt;
|
||||
final int resultCount;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
enum TopPeriodFilter {
|
||||
none,
|
||||
month,
|
||||
year,
|
||||
allTime;
|
||||
|
||||
String get label {
|
||||
return switch (this) {
|
||||
TopPeriodFilter.none => 'Latest',
|
||||
TopPeriodFilter.month => 'Top month',
|
||||
TopPeriodFilter.year => 'Top year',
|
||||
TopPeriodFilter.allTime => 'Top all time',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
String get name;
|
||||
String get baseUrl;
|
||||
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
});
|
||||
|
||||
Future<Post?> getPost(String id);
|
||||
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class CustomProvider implements ContentProvider {
|
||||
CustomProvider(this._delegate);
|
||||
|
||||
final ContentProvider _delegate;
|
||||
|
||||
@override
|
||||
String get id => _delegate.id;
|
||||
@override
|
||||
String get name => _delegate.name;
|
||||
@override
|
||||
String get baseUrl => _delegate.baseUrl;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) =>
|
||||
_delegate.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) => _delegate.getPost(id);
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() => _delegate.checkHealth();
|
||||
}
|
||||
|
||||
class UnsupportedCustomProvider implements ContentProvider {
|
||||
UnsupportedCustomProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required this.apiType,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final String apiType;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
throw ProviderUnavailableException(
|
||||
'Unsupported provider apiType: $apiType');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
throw ProviderUnavailableException(
|
||||
'Unsupported provider apiType: $apiType');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: 'Unsupported provider apiType: $apiType',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/danbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _queryParameters = queryParameters,
|
||||
_dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
..._topTags(topPeriod),
|
||||
];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
'page': page <= 0 ? 1 : page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return DanbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts/$id.json',
|
||||
queryParameters: _queryParameters,
|
||||
);
|
||||
final posts = DanbooruMapper.postsFromResponse(
|
||||
[response.data],
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {'limit': 1, ..._queryParameters},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'danbooru',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'danbooru',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'],
|
||||
TopPeriodFilter.allTime => const ['order:score'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class GelbooruProvider implements ContentProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _queryParameters = queryParameters,
|
||||
_dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
Dio get dio => _dio;
|
||||
Map<String, String> get queryParameters => _queryParameters;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
..._topTags(topPeriod),
|
||||
];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'id': id,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'limit': 1,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'gelbooru-compatible',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'gelbooru-compatible',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Never unavailable(String message) =>
|
||||
throw ProviderUnavailableException(message);
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import 'content_provider.dart';
|
||||
import 'custom_provider.dart';
|
||||
import 'danbooru_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
|
||||
class ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) {
|
||||
final headers = Map<String, String>.from(config.customHeaders)
|
||||
..removeWhere((key, _) => key.startsWith('query.'));
|
||||
final queryParameters = {
|
||||
for (final entry in config.customHeaders.entries)
|
||||
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
|
||||
entry.key.substring('query.'.length): entry.value.trim(),
|
||||
};
|
||||
final client = DioClient(
|
||||
baseUrl: config.baseUrl,
|
||||
timeout: Duration(seconds: config.timeoutSeconds),
|
||||
headers: headers,
|
||||
);
|
||||
return switch (config.apiType.toLowerCase()) {
|
||||
'gelbooru' => GelbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'rule34' => Rule34Provider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'danbooru' => DanbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
_ => UnsupportedCustomProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiType: config.apiType,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import 'content_provider.dart';
|
||||
import 'provider_factory.dart';
|
||||
|
||||
class ProviderManager {
|
||||
ProviderManager(this._repository, this._factory);
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
await _repository.ensureSeedProviders();
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeProviders() async {
|
||||
final result = await loadConfigs();
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>).data
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return Success(configs.map(_factory.create).toList());
|
||||
}
|
||||
|
||||
Future<Result<void>> enableProvider(String id, bool enabled) async {
|
||||
final result = await _repository.getProvider(id);
|
||||
return result.fold(
|
||||
onSuccess: (config) {
|
||||
if (config == null) {
|
||||
return const Error<void>(
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
return _repository.saveProvider(
|
||||
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
|
||||
);
|
||||
},
|
||||
onError: Error<void>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
|
||||
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) =>
|
||||
_repository.deleteProvider(id);
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providers.isEmpty) return const Success([]);
|
||||
final results = await _runLimited(
|
||||
providers,
|
||||
concurrency,
|
||||
(provider) async {
|
||||
final health = await provider.checkHealth();
|
||||
await _repository.saveHealth(health);
|
||||
return health;
|
||||
},
|
||||
);
|
||||
return Success(results);
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> searchAcrossProviders({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
var providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providerId != null) {
|
||||
providers =
|
||||
providers.where((provider) => provider.id == providerId).toList();
|
||||
}
|
||||
|
||||
final posts = <Post>[];
|
||||
for (final provider in providers) {
|
||||
final health = await _repository.getHealth(provider.id);
|
||||
final offline = health is Success<ProviderHealth?> &&
|
||||
health.data?.status == ProviderStatus.offline;
|
||||
if (offline) continue;
|
||||
|
||||
try {
|
||||
final providerPosts = await provider.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
posts.addAll(providerPosts);
|
||||
} catch (_) {
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Success(posts);
|
||||
}
|
||||
|
||||
Future<Result<Post?>> getPost(String providerId, String postId) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches = providers.where((provider) => provider.id == providerId);
|
||||
if (matches.isEmpty) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Provider not found'));
|
||||
}
|
||||
try {
|
||||
return Success(await matches.first.getPost(postId));
|
||||
} catch (error) {
|
||||
return Error(
|
||||
Failure(
|
||||
code: 'provider_get_post',
|
||||
message: 'Could not load post',
|
||||
details: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<R>> _runLimited<T, R>(
|
||||
List<T> items,
|
||||
int concurrency,
|
||||
Future<R> Function(T item) action,
|
||||
) async {
|
||||
final results = <R>[];
|
||||
var index = 0;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (index < items.length) {
|
||||
final current = items[index++];
|
||||
results.add(await action(current));
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(
|
||||
List.generate(concurrency.clamp(1, items.length), (_) => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import '../mappers/rule34_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
class Rule34Provider extends GelbooruProvider {
|
||||
Rule34Provider({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.baseUrl,
|
||||
required super.dioClient,
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final topTags = switch (topPeriod) {
|
||||
TopPeriodFilter.none => const <String>[],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
};
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': [
|
||||
...tags,
|
||||
if (rating != null) 'rating:$rating',
|
||||
...topTags,
|
||||
].join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
return Rule34Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:isar/isar.dart' hide Collection;
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/collection.dart';
|
||||
import '../models/post.dart';
|
||||
import 'post_repository.dart';
|
||||
|
||||
class CollectionRepository {
|
||||
CollectionRepository(this._databaseService, this._postRepository);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final PostRepository _postRepository;
|
||||
|
||||
Future<Result<Collection>> save(Collection collection) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionEntitys.put(
|
||||
CollectionEntity()
|
||||
..collectionId = collection.id
|
||||
..name = collection.name
|
||||
..description = collection.description
|
||||
..coverUrl = collection.coverUrl
|
||||
..createdAt = collection.createdAt
|
||||
..updatedAt = collection.updatedAt,
|
||||
);
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> delete(String id) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(id)
|
||||
.deleteAll();
|
||||
await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Collection>>> all() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.collectionEntitys.where().findAll();
|
||||
items.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return items.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Collection?>> getById(String id) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.collectionEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(id)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> addPost(String collectionId, Post post) async {
|
||||
await _postRepository.cachePosts([post]);
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys.put(
|
||||
CollectionPostEntity()
|
||||
..linkKey = '$collectionId:${post.providerId}:${post.id}'
|
||||
..collectionId = collectionId
|
||||
..postId = post.id
|
||||
..providerId = post.providerId
|
||||
..addedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> removePost(
|
||||
String collectionId,
|
||||
String postId,
|
||||
String providerId,
|
||||
) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.linkKeyEqualTo('$collectionId:$providerId:$postId')
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> posts(String collectionId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final links = await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(collectionId)
|
||||
.findAll();
|
||||
links.sort((a, b) => b.addedAt.compareTo(a.addedAt));
|
||||
final posts = <Post>[];
|
||||
for (final link in links) {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(link.providerId)
|
||||
.postIdEqualTo(link.postId)
|
||||
.findFirst();
|
||||
if (entity != null) posts.add(entity.toModel());
|
||||
}
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/favorite.dart';
|
||||
import '../models/post.dart';
|
||||
import 'post_repository.dart';
|
||||
|
||||
class FavoriteRepository {
|
||||
FavoriteRepository(this._databaseService, this._postRepository);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final PostRepository _postRepository;
|
||||
|
||||
Future<Result<void>> add(Post post) async {
|
||||
await _postRepository.cachePosts([post]);
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final key = '${post.providerId}:${post.id}';
|
||||
await isar.favoriteEntitys.put(
|
||||
FavoriteEntity()
|
||||
..favoriteKey = key
|
||||
..favoriteId = key
|
||||
..postId = post.id
|
||||
..providerId = post.providerId
|
||||
..savedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> remove(String postId, String providerId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.favoriteEntitys
|
||||
.filter()
|
||||
.favoriteKeyEqualTo('$providerId:$postId')
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<bool>> exists(String postId, String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
return await isar.favoriteEntitys
|
||||
.filter()
|
||||
.favoriteKeyEqualTo('$providerId:$postId')
|
||||
.count() >
|
||||
0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Favorite>>> all() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.favoriteEntitys.where().findAll();
|
||||
items.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return items.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.favoriteEntitys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
|
||||
class PostRepository {
|
||||
PostRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> cachePosts(List<Post> posts) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.cachedPostEntitys.putAll(
|
||||
posts.map((post) => CachedPostEntity.fromModel(post)).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Post?>> getCachedPost(String postId, String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(providerId)
|
||||
.postIdEqualTo(postId)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> getCachedPostsByKeys(Iterable<String> keys) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final posts = <Post>[];
|
||||
for (final key in keys) {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.cacheKeyEqualTo(key)
|
||||
.findFirst();
|
||||
if (entity != null) posts.add(entity.toModel());
|
||||
}
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/provider_health.dart';
|
||||
|
||||
class ProviderRepository {
|
||||
ProviderRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
static List<ContentProviderConfig> seedProviders() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
ContentProviderConfig(
|
||||
id: 'gelbooru',
|
||||
name: 'Gelbooru',
|
||||
baseUrl: 'https://gelbooru.com',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'rule34',
|
||||
name: 'Rule34',
|
||||
baseUrl: 'https://api.rule34.xxx',
|
||||
apiType: 'rule34',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'safebooru',
|
||||
name: 'Safebooru',
|
||||
baseUrl: 'https://safebooru.org',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<Result<void>> ensureSeedProviders() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final count = await isar.providerConfigEntitys.count();
|
||||
if (count > 0) return;
|
||||
await isar.providerConfigEntitys.putAll(
|
||||
seedProviders().map(ProviderConfigEntity.fromModel).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> getProviders({
|
||||
bool enabledOnly = false,
|
||||
}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final providers = enabledOnly
|
||||
? await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.enabledEqualTo(true)
|
||||
.findAll()
|
||||
: await isar.providerConfigEntitys.where().findAll();
|
||||
providers.sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return providers.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<ContentProviderConfig?>> getProvider(String id) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveProvider(ContentProviderConfig config) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerConfigEntitys
|
||||
.put(ProviderConfigEntity.fromModel(config));
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.deleteAll();
|
||||
await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveHealth(ProviderHealth health) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerHealthEntitys
|
||||
.put(ProviderHealthEntity.fromModel(health));
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<ProviderHealth?>> getHealth(String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(providerId)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/search_history.dart';
|
||||
|
||||
class SearchRepository {
|
||||
SearchRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> save(SearchHistory history) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.put(
|
||||
SearchHistoryEntity()
|
||||
..historyId = history.id
|
||||
..query = history.query
|
||||
..tags = history.tags
|
||||
..searchedAt = history.searchedAt
|
||||
..resultCount = history.resultCount,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.searchHistoryEntitys.where().findAll();
|
||||
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return items.take(limit).map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../models/collection.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
|
||||
class CollectionService {
|
||||
CollectionService(this._repository);
|
||||
|
||||
final CollectionRepository _repository;
|
||||
|
||||
Future<Result<Collection>> createCollection(
|
||||
String name, String? description) {
|
||||
final now = DateTime.now();
|
||||
return _repository.save(
|
||||
Collection(
|
||||
id: now.microsecondsSinceEpoch.toString(),
|
||||
name: name,
|
||||
description: description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<Collection>> updateCollection(
|
||||
String id, {
|
||||
required String name,
|
||||
String? description,
|
||||
String? coverUrl,
|
||||
}) async {
|
||||
final existingResult = await _repository.getById(id);
|
||||
if (existingResult is Error<Collection?>) {
|
||||
return Error(existingResult.failure);
|
||||
}
|
||||
final existing = (existingResult as Success<Collection?>).data;
|
||||
if (existing == null) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Collection not found'),
|
||||
);
|
||||
}
|
||||
return _repository.save(
|
||||
Collection(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
coverUrl: coverUrl,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteCollection(String id) => _repository.delete(id);
|
||||
Future<Result<void>> addPostToCollection(String collectionId, Post post) {
|
||||
return _repository.addPost(collectionId, post);
|
||||
}
|
||||
|
||||
Future<Result<void>> removePostFromCollection(
|
||||
String collectionId,
|
||||
String postId,
|
||||
String providerId,
|
||||
) {
|
||||
return _repository.removePost(collectionId, postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<List<Collection>>> getCollections() => _repository.all();
|
||||
Future<Result<List<Post>>> getCollectionPosts(String collectionId) {
|
||||
return _repository.posts(collectionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/favorite.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
|
||||
class FavoriteService {
|
||||
FavoriteService(this._repository, [this._postRepository]);
|
||||
|
||||
final FavoriteRepository _repository;
|
||||
final PostRepository? _postRepository;
|
||||
|
||||
Future<Result<void>> addFavorite(Post post) => _repository.add(post);
|
||||
Future<Result<void>> removeFavorite(String postId, String providerId) {
|
||||
return _repository.remove(postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<bool>> isFavorite(String postId, String providerId) {
|
||||
return _repository.exists(postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<List<Favorite>>> getFavorites() => _repository.all();
|
||||
Future<Result<List<Post>>> getFavoritePosts() async {
|
||||
final postRepository = _postRepository;
|
||||
if (postRepository == null) return const Success([]);
|
||||
final favoritesResult = await _repository.all();
|
||||
if (favoritesResult is Error<List<Favorite>>) {
|
||||
return Error(favoritesResult.failure);
|
||||
}
|
||||
final favorites = (favoritesResult as Success<List<Favorite>>).data;
|
||||
return postRepository.getCachedPostsByKeys(
|
||||
favorites.map((favorite) => '${favorite.providerId}:${favorite.postId}'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearFavorites() => _repository.clear();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import '../../core/cache/cache_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class FeedService {
|
||||
FeedService(this._providerManager, this._cacheService, this._settingsService);
|
||||
|
||||
final ProviderManager _providerManager;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService _settingsService;
|
||||
final Map<String, int> _pages = {};
|
||||
|
||||
String _key(
|
||||
List<String> tags,
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod,
|
||||
) {
|
||||
final sorted = [...tags]..sort();
|
||||
return '${sorted.join(' ')}|${rating ?? ''}|${providerId ?? ''}|${topPeriod.name}';
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> refresh({
|
||||
List<String> tags = const [],
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
int limit = 50,
|
||||
}) {
|
||||
_pages[_key(tags, rating, providerId, topPeriod)] = 0;
|
||||
return loadNextPage(
|
||||
tags: tags,
|
||||
rating: rating,
|
||||
providerId: providerId,
|
||||
topPeriod: topPeriod,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> loadNextPage({
|
||||
List<String> tags = const [],
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
final key = _key(tags, rating, providerId, topPeriod);
|
||||
final page = _pages[key] ?? 0;
|
||||
final result = await _providerManager.searchAcrossProviders(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
providerId: providerId,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
if (result is Error<List<Post>>) return Error(result.failure);
|
||||
final posts = (result as Success<List<Post>>).data;
|
||||
_pages[key] = page + 1;
|
||||
final settingsResult = await _settingsService.getSettings();
|
||||
final maxItems = settingsResult is Success<AppSettings>
|
||||
? settingsResult.data.cacheMaxItems
|
||||
: null;
|
||||
await _cacheService.cachePosts(posts, maxItems: maxItems);
|
||||
return Success(posts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
|
||||
class ProviderCheckService {
|
||||
ProviderCheckService(this._repository, this._factory, this._manager);
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
final ProviderManager _manager;
|
||||
|
||||
Future<Result<ProviderHealth>> checkOne(String providerId) async {
|
||||
final configResult = await _repository.getProvider(providerId);
|
||||
if (configResult is Error<ContentProviderConfig?>) {
|
||||
return Error(configResult.failure);
|
||||
}
|
||||
final config = (configResult as Success<ContentProviderConfig?>).data;
|
||||
if (config == null) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
final health = await _factory.create(config).checkHealth();
|
||||
await _repository.saveHealth(health);
|
||||
return Success(health);
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll() {
|
||||
return _manager.checkAll(concurrency: 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/search_history.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
|
||||
class SearchService {
|
||||
SearchService(this._repository);
|
||||
|
||||
final SearchRepository _repository;
|
||||
|
||||
List<String> parseTags(String query) {
|
||||
return query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
||||
final tags = parseTags(query);
|
||||
return _repository.save(
|
||||
SearchHistory(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
||||
query: query.trim(),
|
||||
tags: tags,
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: resultCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
|
||||
return _repository.recent(limit: limit);
|
||||
}
|
||||
|
||||
Future<Result<List<String>>> autocomplete(String prefix,
|
||||
{int limit = 10}) async {
|
||||
final recent = await _repository.recent(limit: 100);
|
||||
return recent.fold(
|
||||
onSuccess: (items) {
|
||||
final needle = prefix.toLowerCase();
|
||||
final values = <String>{};
|
||||
for (final item in items) {
|
||||
for (final tag in item.tags) {
|
||||
if (tag.toLowerCase().startsWith(needle)) values.add(tag);
|
||||
}
|
||||
}
|
||||
return Success(values.take(limit).toList());
|
||||
},
|
||||
onError: Error<List<String>>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() => _repository.clear();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
|
||||
class AppSettings {
|
||||
const AppSettings({
|
||||
required this.enabledProviderIds,
|
||||
required this.nsfwEnabled,
|
||||
required this.cacheTtlHours,
|
||||
required this.cacheMaxItems,
|
||||
required this.providerPriority,
|
||||
required this.themeMode,
|
||||
required this.desktopColumns,
|
||||
required this.mobileColumns,
|
||||
required this.blurExplicitContent,
|
||||
required this.allowDownloads,
|
||||
required this.selectedFeedProviderIds,
|
||||
required this.showPostBadges,
|
||||
required this.defaultTopPeriodFilter,
|
||||
this.defaultRatingFilter,
|
||||
});
|
||||
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
final int cacheMaxItems;
|
||||
final Map<String, int> providerPriority;
|
||||
final String themeMode;
|
||||
final int desktopColumns;
|
||||
final int mobileColumns;
|
||||
final bool blurExplicitContent;
|
||||
final bool allowDownloads;
|
||||
final List<String> selectedFeedProviderIds;
|
||||
final bool showPostBadges;
|
||||
final String defaultTopPeriodFilter;
|
||||
final String? defaultRatingFilter;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
nsfwEnabled: true,
|
||||
cacheTtlHours: 24,
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
||||
themeMode: 'dark',
|
||||
desktopColumns: 5,
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
defaultRatingFilter: null,
|
||||
);
|
||||
|
||||
AppSettings copyWith({
|
||||
List<String>? enabledProviderIds,
|
||||
bool? nsfwEnabled,
|
||||
int? cacheTtlHours,
|
||||
int? cacheMaxItems,
|
||||
Map<String, int>? providerPriority,
|
||||
String? themeMode,
|
||||
int? desktopColumns,
|
||||
int? mobileColumns,
|
||||
bool? blurExplicitContent,
|
||||
bool? allowDownloads,
|
||||
List<String>? selectedFeedProviderIds,
|
||||
bool? showPostBadges,
|
||||
String? defaultTopPeriodFilter,
|
||||
String? defaultRatingFilter,
|
||||
}) {
|
||||
return AppSettings(
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
cacheMaxItems: cacheMaxItems ?? this.cacheMaxItems,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
desktopColumns: desktopColumns ?? this.desktopColumns,
|
||||
mobileColumns: mobileColumns ?? this.mobileColumns,
|
||||
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
|
||||
allowDownloads: allowDownloads ?? this.allowDownloads,
|
||||
selectedFeedProviderIds:
|
||||
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
|
||||
showPostBadges: showPostBadges ?? this.showPostBadges,
|
||||
defaultTopPeriodFilter:
|
||||
defaultTopPeriodFilter ?? this.defaultTopPeriodFilter,
|
||||
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
'cacheMaxItems': cacheMaxItems,
|
||||
'providerPriority': providerPriority,
|
||||
'themeMode': themeMode,
|
||||
'desktopColumns': desktopColumns,
|
||||
'mobileColumns': mobileColumns,
|
||||
'blurExplicitContent': blurExplicitContent,
|
||||
'allowDownloads': allowDownloads,
|
||||
'selectedFeedProviderIds': selectedFeedProviderIds,
|
||||
'showPostBadges': showPostBadges,
|
||||
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
||||
'defaultRatingFilter': defaultRatingFilter,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enabledProviderIds: List<String>.from(
|
||||
(json['enabledProviderIds'] as List?) ?? defaults.enabledProviderIds,
|
||||
),
|
||||
nsfwEnabled: (json['nsfwEnabled'] as bool?) ?? defaults.nsfwEnabled,
|
||||
cacheTtlHours:
|
||||
(json['cacheTtlHours'] as num?)?.toInt() ?? defaults.cacheTtlHours,
|
||||
cacheMaxItems:
|
||||
(json['cacheMaxItems'] as num?)?.toInt() ?? defaults.cacheMaxItems,
|
||||
providerPriority: Map<String, int>.from(
|
||||
(json['providerPriority'] as Map?) ?? defaults.providerPriority,
|
||||
),
|
||||
themeMode: (json['themeMode'] as String?) ?? defaults.themeMode,
|
||||
desktopColumns: (json['desktopColumns'] as num?)?.toInt() ??
|
||||
defaults.desktopColumns,
|
||||
mobileColumns:
|
||||
(json['mobileColumns'] as num?)?.toInt() ?? defaults.mobileColumns,
|
||||
blurExplicitContent: (json['blurExplicitContent'] as bool?) ??
|
||||
defaults.blurExplicitContent,
|
||||
allowDownloads:
|
||||
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
|
||||
selectedFeedProviderIds: List<String>.from(
|
||||
(json['selectedFeedProviderIds'] as List?) ??
|
||||
defaults.selectedFeedProviderIds,
|
||||
),
|
||||
showPostBadges:
|
||||
(json['showPostBadges'] as bool?) ?? defaults.showPostBadges,
|
||||
defaultTopPeriodFilter: (json['defaultTopPeriodFilter'] as String?) ??
|
||||
defaults.defaultTopPeriodFilter,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
SettingsService(
|
||||
this._databaseService, {
|
||||
ProviderRepository? providerRepository,
|
||||
}) : _providerRepository = providerRepository;
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final ProviderRepository? _providerRepository;
|
||||
static const _settingsKey = 'app_settings';
|
||||
|
||||
Future<Result<AppSettings>> getSettings() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.appSettingEntitys
|
||||
.filter()
|
||||
.keyEqualTo(_settingsKey)
|
||||
.findFirst();
|
||||
if (entity == null) return AppSettings.defaults;
|
||||
return AppSettings.fromJson(
|
||||
jsonDecode(entity.jsonValue) as Map<String, dynamic>);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> updateSettings(AppSettings settings) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.appSettingEntitys.put(
|
||||
AppSettingEntity()
|
||||
..key = _settingsKey
|
||||
..jsonValue = jsonEncode(settings.toJson())
|
||||
..updatedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveEnabledProviders(List<String> providerIds) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
final updateResult = await updateSettings(
|
||||
settings.copyWith(enabledProviderIds: providerIds));
|
||||
if (updateResult is Error<void>) return updateResult;
|
||||
|
||||
final repository = _providerRepository;
|
||||
if (repository == null) return updateResult;
|
||||
|
||||
final providersResult = await repository.getProviders();
|
||||
if (providersResult is Error<List<ContentProviderConfig>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final enabled = providerIds.toSet();
|
||||
for (final provider
|
||||
in (providersResult as Success<List<ContentProviderConfig>>).data) {
|
||||
await repository.saveProvider(
|
||||
provider.copyWith(
|
||||
enabled: enabled.contains(provider.id),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
Future<Result<void>> saveNsfwFilter(bool enabled) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveCacheSettings({
|
||||
required int ttlHours,
|
||||
required int maxItems,
|
||||
}) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
return updateSettings(
|
||||
settings.copyWith(cacheTtlHours: ttlHours, cacheMaxItems: maxItems),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<String>> exportSettingsToJson() async {
|
||||
final result = await getSettings();
|
||||
return result.fold(
|
||||
onSuccess: (settings) => Success(jsonEncode(settings.toJson())),
|
||||
onError: Error<String>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> importSettingsFromJson(String json) async {
|
||||
try {
|
||||
final settings =
|
||||
AppSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
final result = await updateSettings(settings);
|
||||
if (result is Error<void>) return result;
|
||||
await saveEnabledProviders(settings.enabledProviderIds);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return Error(
|
||||
Failure(
|
||||
code: 'settings_import',
|
||||
message: 'Invalid settings JSON',
|
||||
details: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+73
@@ -0,0 +1,73 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/database_service.dart';
|
||||
import '../utils/result.dart';
|
||||
import '../../backend/models/post.dart';
|
||||
|
||||
class CacheService {
|
||||
CacheService(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> cachePosts(
|
||||
List<Post> posts, {
|
||||
int? maxItems,
|
||||
}) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.cachedPostEntitys.putAll(
|
||||
posts.map((post) => CachedPostEntity.fromModel(post)).toList(),
|
||||
);
|
||||
if (maxItems != null && maxItems > 0) {
|
||||
await _evictOverflow(isar, maxItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> getCachedPosts({
|
||||
Duration maxAge = const Duration(hours: 24),
|
||||
}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final cutoff = DateTime.now().subtract(maxAge);
|
||||
final entities = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.cachedAtGreaterThan(cutoff)
|
||||
.findAll();
|
||||
entities.sort((a, b) => b.cachedAt.compareTo(a.cachedAt));
|
||||
return entities.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> prune({
|
||||
Duration? olderThan,
|
||||
int? maxItems,
|
||||
}) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
if (olderThan != null) {
|
||||
final cutoff = DateTime.now().subtract(olderThan);
|
||||
await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.cachedAtLessThan(cutoff)
|
||||
.deleteAll();
|
||||
}
|
||||
if (maxItems != null && maxItems > 0) {
|
||||
await _evictOverflow(isar, maxItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.cachedPostEntitys.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _evictOverflow(Isar isar, int maxItems) async {
|
||||
final entities = await isar.cachedPostEntitys.where().findAll();
|
||||
if (entities.length <= maxItems) return;
|
||||
entities.sort((a, b) => b.cachedAt.compareTo(a.cachedAt));
|
||||
final overflowIds =
|
||||
entities.skip(maxItems).map((entity) => entity.isarId).toList();
|
||||
await isar.cachedPostEntitys.deleteAll(overflowIds);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import '../../backend/models/post.dart';
|
||||
|
||||
class ImageCacheService {
|
||||
String previewCacheKey(Post post) => 'preview:${post.providerId}:${post.id}';
|
||||
String sampleCacheKey(Post post) => 'sample:${post.providerId}:${post.id}';
|
||||
String fileCacheKey(Post post) => 'file:${post.providerId}:${post.id}';
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart' hide Collection;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../backend/models/collection.dart';
|
||||
import '../../backend/models/content_provider_config.dart';
|
||||
import '../../backend/models/favorite.dart';
|
||||
import '../../backend/models/post.dart';
|
||||
import '../../backend/models/provider_health.dart';
|
||||
import '../../backend/models/search_history.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
class AppDatabase {
|
||||
AppDatabase(this.isar);
|
||||
|
||||
final Isar isar;
|
||||
|
||||
static Future<AppDatabase> open({String? directory}) async {
|
||||
final dir = directory ?? (await getApplicationDocumentsDirectory()).path;
|
||||
final isar = await Isar.open(
|
||||
[
|
||||
ProviderConfigEntitySchema,
|
||||
ProviderHealthEntitySchema,
|
||||
CachedPostEntitySchema,
|
||||
FavoriteEntitySchema,
|
||||
CollectionEntitySchema,
|
||||
CollectionPostEntitySchema,
|
||||
SearchHistoryEntitySchema,
|
||||
AppSettingEntitySchema,
|
||||
],
|
||||
directory: dir,
|
||||
name: 'gel_rule_app',
|
||||
);
|
||||
return AppDatabase(isar);
|
||||
}
|
||||
|
||||
Future<void> close() => isar.close();
|
||||
}
|
||||
|
||||
@collection
|
||||
class ProviderConfigEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String providerId;
|
||||
late String name;
|
||||
late String baseUrl;
|
||||
late String apiType;
|
||||
late bool enabled;
|
||||
late int priority;
|
||||
late int timeoutSeconds;
|
||||
late String customHeadersJson;
|
||||
late DateTime createdAt;
|
||||
late DateTime updatedAt;
|
||||
|
||||
ContentProviderConfig toModel() => ContentProviderConfig(
|
||||
id: providerId,
|
||||
name: name,
|
||||
baseUrl: baseUrl,
|
||||
apiType: apiType,
|
||||
enabled: enabled,
|
||||
priority: priority,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
customHeaders: Map<String, String>.from(
|
||||
jsonDecode(customHeadersJson) as Map,
|
||||
),
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
||||
static ProviderConfigEntity fromModel(ContentProviderConfig model) {
|
||||
return ProviderConfigEntity()
|
||||
..providerId = model.id
|
||||
..name = model.name
|
||||
..baseUrl = model.baseUrl
|
||||
..apiType = model.apiType
|
||||
..enabled = model.enabled
|
||||
..priority = model.priority
|
||||
..timeoutSeconds = model.timeoutSeconds
|
||||
..customHeadersJson = jsonEncode(model.customHeaders)
|
||||
..createdAt = model.createdAt
|
||||
..updatedAt = model.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@collection
|
||||
class ProviderHealthEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String providerId;
|
||||
@enumerated
|
||||
late ProviderStatus status;
|
||||
late int pingMs;
|
||||
late DateTime lastCheckedAt;
|
||||
String? errorMessage;
|
||||
String? apiVersion;
|
||||
|
||||
ProviderHealth toModel() => ProviderHealth(
|
||||
providerId: providerId,
|
||||
status: status,
|
||||
pingMs: pingMs,
|
||||
lastCheckedAt: lastCheckedAt,
|
||||
errorMessage: errorMessage,
|
||||
apiVersion: apiVersion,
|
||||
);
|
||||
|
||||
static ProviderHealthEntity fromModel(ProviderHealth model) {
|
||||
return ProviderHealthEntity()
|
||||
..providerId = model.providerId
|
||||
..status = model.status
|
||||
..pingMs = model.pingMs
|
||||
..lastCheckedAt = model.lastCheckedAt
|
||||
..errorMessage = model.errorMessage
|
||||
..apiVersion = model.apiVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@collection
|
||||
class CachedPostEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String cacheKey;
|
||||
@Index(composite: [CompositeIndex('postId')])
|
||||
late String providerId;
|
||||
late String postId;
|
||||
late String providerName;
|
||||
late String previewUrl;
|
||||
late String sampleUrl;
|
||||
late String fileUrl;
|
||||
late List<String> tags;
|
||||
late String rating;
|
||||
late int width;
|
||||
late int height;
|
||||
String? source;
|
||||
late DateTime createdAt;
|
||||
late String fileType;
|
||||
late int score;
|
||||
late DateTime cachedAt;
|
||||
|
||||
Post toModel() => Post(
|
||||
id: postId,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: tags,
|
||||
rating: rating,
|
||||
width: width,
|
||||
height: height,
|
||||
source: source,
|
||||
createdAt: createdAt,
|
||||
fileType: fileType,
|
||||
score: score,
|
||||
);
|
||||
|
||||
static CachedPostEntity fromModel(Post model, {DateTime? cachedAt}) {
|
||||
return CachedPostEntity()
|
||||
..cacheKey = model.cacheKey
|
||||
..providerId = model.providerId
|
||||
..postId = model.id
|
||||
..providerName = model.providerName
|
||||
..previewUrl = model.previewUrl
|
||||
..sampleUrl = model.sampleUrl
|
||||
..fileUrl = model.fileUrl
|
||||
..tags = model.tags
|
||||
..rating = model.rating
|
||||
..width = model.width
|
||||
..height = model.height
|
||||
..source = model.source
|
||||
..createdAt = model.createdAt
|
||||
..fileType = model.fileType
|
||||
..score = model.score
|
||||
..cachedAt = cachedAt ?? DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@collection
|
||||
class FavoriteEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String favoriteKey;
|
||||
late String favoriteId;
|
||||
late String postId;
|
||||
late String providerId;
|
||||
late DateTime savedAt;
|
||||
|
||||
Favorite toModel() => Favorite(
|
||||
id: favoriteId,
|
||||
postId: postId,
|
||||
providerId: providerId,
|
||||
savedAt: savedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@collection
|
||||
class CollectionEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String collectionId;
|
||||
late String name;
|
||||
String? description;
|
||||
String? coverUrl;
|
||||
late DateTime createdAt;
|
||||
late DateTime updatedAt;
|
||||
|
||||
Collection toModel() => Collection(
|
||||
id: collectionId,
|
||||
name: name,
|
||||
description: description,
|
||||
coverUrl: coverUrl,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@collection
|
||||
class CollectionPostEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String linkKey;
|
||||
@Index()
|
||||
late String collectionId;
|
||||
late String postId;
|
||||
late String providerId;
|
||||
late DateTime addedAt;
|
||||
}
|
||||
|
||||
@collection
|
||||
class SearchHistoryEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String historyId;
|
||||
late String query;
|
||||
late List<String> tags;
|
||||
late DateTime searchedAt;
|
||||
late int resultCount;
|
||||
|
||||
SearchHistory toModel() => SearchHistory(
|
||||
id: historyId,
|
||||
query: query,
|
||||
tags: tags,
|
||||
searchedAt: searchedAt,
|
||||
resultCount: resultCount,
|
||||
);
|
||||
}
|
||||
|
||||
@collection
|
||||
class AppSettingEntity {
|
||||
Id isarId = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true, replace: true)
|
||||
late String key;
|
||||
late String jsonValue;
|
||||
late DateTime updatedAt;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../errors/app_exception.dart';
|
||||
import '../utils/result.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
class DatabaseService {
|
||||
DatabaseService(this.database);
|
||||
|
||||
final AppDatabase database;
|
||||
|
||||
Isar get isar => database.isar;
|
||||
|
||||
Future<Result<T>> safeRead<T>(Future<T> Function(Isar isar) action) async {
|
||||
try {
|
||||
return Success(await action(isar));
|
||||
} catch (error) {
|
||||
return Error(
|
||||
DatabaseException(error.toString(), details: error).toFailure());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<T>> safeWrite<T>(Future<T> Function(Isar isar) action) async {
|
||||
try {
|
||||
final value = await isar.writeTxn(() => action(isar));
|
||||
return Success(value);
|
||||
} catch (error) {
|
||||
return Error(
|
||||
DatabaseException(error.toString(), details: error).toFailure());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'failure.dart';
|
||||
|
||||
sealed class AppException implements Exception {
|
||||
const AppException(this.message, {this.details});
|
||||
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
String get code;
|
||||
|
||||
Failure toFailure() =>
|
||||
Failure(code: code, message: message, details: details);
|
||||
}
|
||||
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'network';
|
||||
}
|
||||
|
||||
class RequestTimeoutException extends AppException {
|
||||
const RequestTimeoutException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'timeout';
|
||||
}
|
||||
|
||||
class ProviderUnavailableException extends AppException {
|
||||
const ProviderUnavailableException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'provider_unavailable';
|
||||
}
|
||||
|
||||
class BadResponseException extends AppException {
|
||||
const BadResponseException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'bad_response';
|
||||
}
|
||||
|
||||
class DatabaseException extends AppException {
|
||||
const DatabaseException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'database';
|
||||
}
|
||||
|
||||
class UnknownException extends AppException {
|
||||
const UnknownException(super.message, {super.details});
|
||||
@override
|
||||
String get code => 'unknown';
|
||||
}
|
||||
|
||||
Failure failureFromObject(Object error) {
|
||||
if (error is AppException) return error.toFailure();
|
||||
return UnknownException(error.toString(), details: error).toFailure();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
class Failure {
|
||||
const Failure({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
@override
|
||||
String toString() => 'Failure($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../errors/app_exception.dart';
|
||||
import '../utils/result.dart';
|
||||
|
||||
class DioClient {
|
||||
DioClient({
|
||||
String? baseUrl,
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
Map<String, String>? headers,
|
||||
Dio? dio,
|
||||
}) : dio = dio ?? Dio(_options(baseUrl, timeout, headers)) {
|
||||
this.dio.interceptors.add(_RetryInterceptor(this.dio));
|
||||
if (kDebugMode) {
|
||||
this.dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestBody: false,
|
||||
responseBody: false,
|
||||
requestHeader: false,
|
||||
responseHeader: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final Dio dio;
|
||||
|
||||
static BaseOptions _options(
|
||||
String? baseUrl,
|
||||
Duration timeout,
|
||||
Map<String, String>? headers,
|
||||
) {
|
||||
return BaseOptions(
|
||||
baseUrl: baseUrl ?? '',
|
||||
connectTimeout: timeout,
|
||||
receiveTimeout: timeout,
|
||||
sendTimeout: timeout,
|
||||
headers: {
|
||||
'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser',
|
||||
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
|
||||
...?headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<T>> safeRequest<T>(Future<T> Function(Dio dio) request) async {
|
||||
try {
|
||||
return Success(await request(dio));
|
||||
} on DioException catch (error) {
|
||||
return Error(_mapDioException(error).toFailure());
|
||||
} on AppException catch (error) {
|
||||
return Error(error.toFailure());
|
||||
} catch (error) {
|
||||
return Error(
|
||||
UnknownException(error.toString(), details: error).toFailure());
|
||||
}
|
||||
}
|
||||
|
||||
static AppException _mapDioException(DioException error) {
|
||||
return switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
RequestTimeoutException('Request timed out', details: error),
|
||||
DioExceptionType.badResponse => BadResponseException(
|
||||
'Provider returned HTTP ${error.response?.statusCode}',
|
||||
details: error,
|
||||
),
|
||||
DioExceptionType.connectionError => NetworkException(
|
||||
'Network connection failed',
|
||||
details: error,
|
||||
),
|
||||
_ =>
|
||||
UnknownException(error.message ?? 'Unknown HTTP error', details: error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _RetryInterceptor extends Interceptor {
|
||||
_RetryInterceptor(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
static const _maxAttempts = 2;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err, ErrorInterceptorHandler handler) async {
|
||||
final attempt = (err.requestOptions.extra['retryAttempt'] as int?) ?? 0;
|
||||
if (!_shouldRetry(err) || attempt >= _maxAttempts) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final delayMs = 350 * pow(2, attempt).toInt();
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
try {
|
||||
final options = err.requestOptions;
|
||||
options.extra['retryAttempt'] = attempt + 1;
|
||||
final response = await _dio.fetch<dynamic>(options);
|
||||
handler.resolve(response);
|
||||
} on DioException catch (error) {
|
||||
handler.next(error);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRetry(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
return error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
status == 429 ||
|
||||
(status != null && status >= 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
class NetworkInfo {
|
||||
NetworkInfo(this._connectivity);
|
||||
|
||||
final Connectivity _connectivity;
|
||||
|
||||
Future<bool> get isConnected async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return results.any((result) => result != ConnectivityResult.none);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppLogger {
|
||||
const AppLogger();
|
||||
|
||||
void debug(String message, [Object? error, StackTrace? stackTrace]) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[GelRule] $message');
|
||||
if (error != null) debugPrint(' error: $error');
|
||||
if (stackTrace != null) debugPrint(' stack: $stackTrace');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../errors/failure.dart';
|
||||
|
||||
sealed class Result<T> {
|
||||
const Result();
|
||||
|
||||
bool get isSuccess => this is Success<T>;
|
||||
bool get isError => this is Error<T>;
|
||||
|
||||
R fold<R>({
|
||||
required R Function(T data) onSuccess,
|
||||
required R Function(Failure failure) onError,
|
||||
}) {
|
||||
final current = this;
|
||||
return switch (current) {
|
||||
Success<T>(:final data) => onSuccess(data),
|
||||
Error<T>(:final failure) => onError(failure),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Success<T> extends Result<T> {
|
||||
const Success(this.data);
|
||||
final T data;
|
||||
}
|
||||
|
||||
class Error<T> extends Result<T> {
|
||||
const Error(this.failure);
|
||||
final Failure failure;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
class CollectionDetailsScreen extends ConsumerWidget {
|
||||
const CollectionDetailsScreen({required this.collectionId, super.key});
|
||||
|
||||
final String collectionId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
return AdaptiveScaffold(
|
||||
title: 'Collection',
|
||||
body: posts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'Collection is empty')
|
||||
: PostMasonryGrid(
|
||||
posts: items,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
Future<void> showCollectionFormDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
Collection? collection,
|
||||
}) async {
|
||||
final nameController = TextEditingController(text: collection?.name);
|
||||
final descriptionController =
|
||||
TextEditingController(text: collection?.description);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(collection == null ? 'New collection' : 'Edit collection'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (collection == null) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.create(name, descriptionController.text.trim());
|
||||
} else {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.updateCollection(
|
||||
collection.id,
|
||||
name: name,
|
||||
description: descriptionController.text.trim(),
|
||||
coverUrl: collection.coverUrl,
|
||||
);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
final collectionsControllerProvider =
|
||||
AsyncNotifierProvider<CollectionsController, List<Collection>>(
|
||||
CollectionsController.new,
|
||||
);
|
||||
|
||||
class CollectionsController extends AsyncNotifier<List<Collection>> {
|
||||
@override
|
||||
Future<List<Collection>> build() => _load();
|
||||
|
||||
Future<void> create(String name, String? description) async {
|
||||
await ref
|
||||
.read(collectionServiceProvider)
|
||||
.createCollection(name, description);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> updateCollection(
|
||||
String id, {
|
||||
required String name,
|
||||
String? description,
|
||||
String? coverUrl,
|
||||
}) async {
|
||||
await ref.read(collectionServiceProvider).updateCollection(
|
||||
id,
|
||||
name: name,
|
||||
description: description,
|
||||
coverUrl: coverUrl,
|
||||
);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await ref.read(collectionServiceProvider).deleteCollection(id);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<List<Collection>> _load() async {
|
||||
final result = await ref.read(collectionServiceProvider).getCollections();
|
||||
return result is Success<List<Collection>> ? result.data : const [];
|
||||
}
|
||||
}
|
||||
|
||||
final collectionPostsProvider =
|
||||
FutureProvider.family<List<Post>, String>((ref, collectionId) async {
|
||||
final result = await ref
|
||||
.watch(collectionServiceProvider)
|
||||
.getCollectionPosts(collectionId);
|
||||
return result is Success<List<Post>> ? result.data : const [];
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'collection_form_dialog.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
class CollectionsScreen extends ConsumerWidget {
|
||||
const CollectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final collections = ref.watch(collectionsControllerProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Collections',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New collection',
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
onPressed: () => showCollectionFormDialog(context, ref),
|
||||
),
|
||||
],
|
||||
body: collections.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'No collections yet')
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.35,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/collections/${item.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.collections_bookmark_rounded,
|
||||
size: 32),
|
||||
const Spacer(),
|
||||
Text(item.name,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(item.description ?? ''),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: item,
|
||||
),
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Delete collection',
|
||||
message: 'Remove ${item.name}?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider
|
||||
.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import 'favorites_state.dart';
|
||||
|
||||
final favoritesControllerProvider =
|
||||
AsyncNotifierProvider<FavoritesController, FavoritesState>(
|
||||
FavoritesController.new,
|
||||
);
|
||||
|
||||
class FavoritesController extends AsyncNotifier<FavoritesState> {
|
||||
@override
|
||||
Future<FavoritesState> build() async => FavoritesState(posts: await _load());
|
||||
|
||||
Future<void> remove(Post post) async {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
state = AsyncData(FavoritesState(posts: await _load()));
|
||||
}
|
||||
|
||||
Future<List<Post>> _load() async {
|
||||
final result = await ref.read(favoriteServiceProvider).getFavoritePosts();
|
||||
return result is Success<List<Post>> ? result.data : const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import 'favorites_controller.dart';
|
||||
|
||||
class FavoritesScreen extends ConsumerWidget {
|
||||
const FavoritesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(favoritesControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
return AdaptiveScaffold(
|
||||
title: 'Favorites',
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => data.posts.isEmpty
|
||||
? const EmptyView(title: 'No favorites yet')
|
||||
: PostMasonryGrid(
|
||||
posts: data.posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import '../../../backend/backend.dart';
|
||||
|
||||
class FavoritesState {
|
||||
const FavoritesState({this.posts = const []});
|
||||
final List<Post> posts;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import 'feed_state.dart';
|
||||
|
||||
final feedControllerProvider =
|
||||
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
final initial = FeedState(
|
||||
providers: providers,
|
||||
ratingFilter: settings.defaultRatingFilter,
|
||||
topPeriodFilter: TopPeriodFilter.values.firstWhere(
|
||||
(value) => value.name == settings.defaultTopPeriodFilter,
|
||||
orElse: () => TopPeriodFilter.none,
|
||||
),
|
||||
selectedProviderIds:
|
||||
settings.selectedFeedProviderIds.where(providerIds.contains).toList(),
|
||||
);
|
||||
state = AsyncData(initial);
|
||||
await loadInitial();
|
||||
return state.value ?? initial;
|
||||
}
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...posts],
|
||||
isLoadingMore: false,
|
||||
hasMore: posts.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
final tags = ref.read(searchServiceProvider).parseTags(query);
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(selectedTags: tags, posts: []));
|
||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
||||
await refresh();
|
||||
final count = state.value?.posts.length ?? 0;
|
||||
await ref.read(searchServiceProvider).saveSearch(query, count);
|
||||
}
|
||||
|
||||
Future<void> updateTagSuggestions(String query) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
if (query.trim().isEmpty) {
|
||||
state = AsyncData(current.copyWith(tagSuggestions: []));
|
||||
return;
|
||||
}
|
||||
final lastToken = query.trim().split(RegExp(r'\s+')).last;
|
||||
final result =
|
||||
await ref.read(searchServiceProvider).autocomplete(lastToken);
|
||||
final suggestions =
|
||||
result is Success<List<String>> ? result.data : const <String>[];
|
||||
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
|
||||
}
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(selectedFeedProviderIds: providerIds));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(topPeriodFilter: period, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(defaultTopPeriodFilter: period.name),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> setRating(String? rating) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> clearFilters() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
selectedTags: [],
|
||||
selectedProviderIds: [],
|
||||
tagSuggestions: [],
|
||||
clearRating: true,
|
||||
posts: [],
|
||||
),
|
||||
);
|
||||
final settings = await _settings();
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.updateSettings(settings.copyWith(selectedFeedProviderIds: []));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<List<Post>> _load({
|
||||
required bool refresh,
|
||||
required FeedState current,
|
||||
}) async {
|
||||
final service = ref.read(feedServiceProvider);
|
||||
final providerIds = current.selectedProviderIds.isEmpty
|
||||
? <String?>[null]
|
||||
: current.selectedProviderIds;
|
||||
final posts = <Post>[];
|
||||
for (final providerId in providerIds) {
|
||||
final result = refresh
|
||||
? await service.refresh(
|
||||
tags: current.selectedTags,
|
||||
rating: current.ratingFilter,
|
||||
providerId: providerId,
|
||||
topPeriod: current.topPeriodFilter,
|
||||
)
|
||||
: await service.loadNextPage(
|
||||
tags: current.selectedTags,
|
||||
rating: current.ratingFilter,
|
||||
providerId: providerId,
|
||||
topPeriod: current.topPeriodFilter,
|
||||
);
|
||||
if (result is Success<List<Post>>) posts.addAll(result.data);
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _loadProviders() async {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: true);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
}
|
||||
|
||||
Future<AppSettings> _settings() async {
|
||||
final result = await ref.read(settingsServiceProvider).getSettings();
|
||||
return result is Success<AppSettings> ? result.data : AppSettings.defaults;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_card.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../collections/presentation/collection_form_dialog.dart';
|
||||
import 'feed_controller.dart';
|
||||
import 'widgets/feed_toolbar.dart';
|
||||
|
||||
class FeedScreen extends ConsumerStatefulWidget {
|
||||
const FeedScreen({this.initialQuery, super.key});
|
||||
|
||||
final String? initialQuery;
|
||||
|
||||
@override
|
||||
ConsumerState<FeedScreen> createState() => _FeedScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
bool _usedInitialQuery = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.extentAfter < 800) {
|
||||
ref.read(feedControllerProvider.notifier).loadNextPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final feed = ref.watch(feedControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
|
||||
if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
|
||||
_usedInitialQuery = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(feedControllerProvider.notifier).search(widget.initialQuery!);
|
||||
});
|
||||
}
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
_RefreshIntent: CallbackAction<_RefreshIntent>(
|
||||
onInvoke: (_) {
|
||||
ref.read(feedControllerProvider.notifier).refresh();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: AdaptiveScaffold(
|
||||
title: 'Feed',
|
||||
body: feed.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(
|
||||
message: error.toString(),
|
||||
onRetry: () =>
|
||||
ref.read(feedControllerProvider.notifier).loadInitial(),
|
||||
),
|
||||
data: (state) => Column(
|
||||
children: [
|
||||
FeedToolbar(
|
||||
selectedTags: state.selectedTags,
|
||||
selectedProviderIds: state.selectedProviderIds,
|
||||
topPeriodFilter: state.topPeriodFilter,
|
||||
tagSuggestions: state.tagSuggestions,
|
||||
providers: state.providers,
|
||||
rating: state.ratingFilter,
|
||||
onSearchChanged: (query) => ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.updateTagSuggestions(query),
|
||||
onSuggestionTap: (query) =>
|
||||
ref.read(feedControllerProvider.notifier).search(query),
|
||||
onTopPeriodChanged: (period) => ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.setTopPeriod(period),
|
||||
onSearch: (query) =>
|
||||
ref.read(feedControllerProvider.notifier).search(query),
|
||||
onRefresh: () =>
|
||||
ref.read(feedControllerProvider.notifier).refresh(),
|
||||
onClearFilters: () =>
|
||||
ref.read(feedControllerProvider.notifier).clearFilters(),
|
||||
onQuickProviderToggle: (providerId) {
|
||||
if (providerId == '__all__') {
|
||||
ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.setProviders([]);
|
||||
return;
|
||||
}
|
||||
final selected =
|
||||
state.selectedProviderIds.contains(providerId)
|
||||
? state.selectedProviderIds
|
||||
.where((id) => id != providerId)
|
||||
.toList()
|
||||
: [...state.selectedProviderIds, providerId];
|
||||
ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.setProviders(selected);
|
||||
},
|
||||
onProviderFilter: () async {
|
||||
final selected = await showProviderFilterSheet(
|
||||
context,
|
||||
providers: state.providers,
|
||||
selectedIds: state.selectedProviderIds,
|
||||
);
|
||||
if (selected != null) {
|
||||
await ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.setProviders(selected);
|
||||
}
|
||||
},
|
||||
onRatingFilter: () async {
|
||||
final rating = await showRatingFilterSheet(
|
||||
context, state.ratingFilter);
|
||||
await ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.setRating(rating);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: state.posts.isEmpty && !state.isLoadingMore
|
||||
? const EmptyView(
|
||||
title: 'No posts yet',
|
||||
message: 'Try another tag or check providers.',
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.refresh(),
|
||||
child: PostMasonryGrid(
|
||||
controller: _scrollController,
|
||||
posts: state.posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
loading: state.isLoadingMore,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) => ref
|
||||
.read(favoriteServiceProvider)
|
||||
.addFavorite(post),
|
||||
onAddToCollection: (post) =>
|
||||
_addToCollection(context, ref, post),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
) async {
|
||||
final result = await ref.read(collectionServiceProvider).getCollections();
|
||||
final collections =
|
||||
result is Success<List<Collection>> ? result.data : <Collection>[];
|
||||
if (!context.mounted) return;
|
||||
await showAddToCollectionPicker(
|
||||
context,
|
||||
collections: collections,
|
||||
onSelected: (collection) {
|
||||
ref
|
||||
.read(collectionServiceProvider)
|
||||
.addPostToCollection(collection.id, post);
|
||||
},
|
||||
onCreate: () => showCollectionFormDialog(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshIntent extends Intent {
|
||||
const _RefreshIntent();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../../backend/backend.dart';
|
||||
|
||||
class FeedState {
|
||||
const FeedState({
|
||||
this.posts = const [],
|
||||
this.selectedTags = const [],
|
||||
this.selectedProviderIds = const [],
|
||||
this.ratingFilter,
|
||||
this.topPeriodFilter = TopPeriodFilter.none,
|
||||
this.tagSuggestions = const [],
|
||||
this.providers = const [],
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = true,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final List<Post> posts;
|
||||
final List<String> selectedTags;
|
||||
final List<String> selectedProviderIds;
|
||||
final String? ratingFilter;
|
||||
final TopPeriodFilter topPeriodFilter;
|
||||
final List<String> tagSuggestions;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final String? error;
|
||||
|
||||
FeedState copyWith({
|
||||
List<Post>? posts,
|
||||
List<String>? selectedTags,
|
||||
List<String>? selectedProviderIds,
|
||||
String? ratingFilter,
|
||||
TopPeriodFilter? topPeriodFilter,
|
||||
List<String>? tagSuggestions,
|
||||
bool clearRating = false,
|
||||
List<ContentProviderConfig>? providers,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return FeedState(
|
||||
posts: posts ?? this.posts,
|
||||
selectedTags: selectedTags ?? this.selectedTags,
|
||||
selectedProviderIds: selectedProviderIds ?? this.selectedProviderIds,
|
||||
ratingFilter: clearRating ? null : ratingFilter ?? this.ratingFilter,
|
||||
topPeriodFilter: topPeriodFilter ?? this.topPeriodFilter,
|
||||
tagSuggestions: tagSuggestions ?? this.tagSuggestions,
|
||||
providers: providers ?? this.providers,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/responsive.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/app_search_bar.dart';
|
||||
|
||||
class FeedToolbar extends StatefulWidget {
|
||||
const FeedToolbar({
|
||||
required this.onSearch,
|
||||
required this.onRefresh,
|
||||
required this.onProviderFilter,
|
||||
required this.onRatingFilter,
|
||||
required this.onClearFilters,
|
||||
required this.providers,
|
||||
required this.selectedTags,
|
||||
required this.selectedProviderIds,
|
||||
required this.topPeriodFilter,
|
||||
required this.tagSuggestions,
|
||||
required this.rating,
|
||||
required this.onQuickProviderToggle,
|
||||
required this.onSearchChanged,
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback onProviderFilter;
|
||||
final VoidCallback onRatingFilter;
|
||||
final VoidCallback onClearFilters;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final List<String> selectedTags;
|
||||
final List<String> selectedProviderIds;
|
||||
final TopPeriodFilter topPeriodFilter;
|
||||
final List<String> tagSuggestions;
|
||||
final String? rating;
|
||||
final ValueChanged<String> onQuickProviderToggle;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
|
||||
@override
|
||||
State<FeedToolbar> createState() => _FeedToolbarState();
|
||||
}
|
||||
|
||||
class _FeedToolbarState extends State<FeedToolbar> {
|
||||
late String _query;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_query = widget.selectedTags.join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedToolbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedTags != widget.selectedTags) {
|
||||
_query = widget.selectedTags.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Responsive.isMobile(context)) return _buildMobile(context);
|
||||
return _buildDesktop(context);
|
||||
}
|
||||
|
||||
Widget _buildDesktop(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppSearchBar(
|
||||
initialValue: _query,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All providers'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.tagSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.tagSuggestions.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => ActionChip(
|
||||
avatar: const Icon(Icons.tag_rounded, size: 15),
|
||||
label: Text(widget.tagSuggestions[index]),
|
||||
onPressed: () =>
|
||||
_applySuggestion(widget.tagSuggestions[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobile(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
AppSearchBar(
|
||||
initialValue: _query,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.tagSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.tagSuggestions.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||||
itemBuilder: (context, index) => ActionChip(
|
||||
label: Text(widget.tagSuggestions[index]),
|
||||
onPressed: () =>
|
||||
_applySuggestion(widget.tagSuggestions[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
final tokens = _query.trim().isEmpty
|
||||
? <String>[]
|
||||
: _query.trim().split(RegExp(r'\s+'));
|
||||
if (tokens.isEmpty) {
|
||||
tokens.add(suggestion);
|
||||
} else {
|
||||
tokens[tokens.length - 1] = suggestion;
|
||||
}
|
||||
final next = tokens.toSet().join(' ');
|
||||
setState(() => _query = next);
|
||||
widget.onSuggestionTap(next);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>?> showProviderFilterSheet(
|
||||
BuildContext context, {
|
||||
required List<ContentProviderConfig> providers,
|
||||
required List<String> selectedIds,
|
||||
}) {
|
||||
final selected = selectedIds.toSet();
|
||||
return showModalBottomSheet<List<String>>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Providers', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
for (final provider in providers)
|
||||
CheckboxListTile(
|
||||
value: selected.contains(provider.id),
|
||||
title: Text(provider.name),
|
||||
subtitle: Text(provider.baseUrl),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value ?? false) {
|
||||
selected.add(provider.id);
|
||||
} else {
|
||||
selected.remove(provider.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, selected.toList()),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showRatingFilterSheet(BuildContext context, String? selected) {
|
||||
return showModalBottomSheet<String?>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Rating', style: Theme.of(context).textTheme.titleLarge),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
selected == null
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
),
|
||||
title: const Text('Any'),
|
||||
onTap: () => Navigator.pop(context, null),
|
||||
),
|
||||
for (final rating in ['safe', 'questionable', 'explicit'])
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
selected == rating
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
),
|
||||
title: Text(rating),
|
||||
onTap: () => Navigator.pop(context, rating),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'feed_toolbar.dart' show showProviderFilterSheet;
|
||||
@@ -0,0 +1 @@
|
||||
export 'feed_toolbar.dart' show showRatingFilterSheet;
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
final postDetailsControllerProvider =
|
||||
FutureProvider.family<Post?, PostDetailsArgs>((ref, args) async {
|
||||
if (args.initialPost != null) return args.initialPost;
|
||||
final cached = await ref
|
||||
.watch(postRepositoryProvider)
|
||||
.getCachedPost(args.postId, args.providerId);
|
||||
if (cached is Success<Post?> && cached.data != null) return cached.data;
|
||||
final remote = await ref
|
||||
.watch(providerManagerProvider)
|
||||
.getPost(args.providerId, args.postId);
|
||||
return remote is Success<Post?> ? remote.data : null;
|
||||
});
|
||||
|
||||
class PostDetailsArgs {
|
||||
const PostDetailsArgs({
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PostDetailsArgs &&
|
||||
other.providerId == providerId &&
|
||||
other.postId == postId &&
|
||||
other.initialPost == initialPost;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(providerId, postId, initialPost);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_card.dart';
|
||||
import '../../../shared/widgets/rating_badge.dart';
|
||||
import '../../collections/presentation/collection_form_dialog.dart';
|
||||
import '../../feed/presentation/feed_controller.dart';
|
||||
import 'post_details_controller.dart';
|
||||
import 'widgets/post_action_bar.dart';
|
||||
import 'widgets/post_media_viewer.dart';
|
||||
import 'widgets/post_tags_panel.dart';
|
||||
|
||||
class PostDetailsScreen extends ConsumerWidget {
|
||||
const PostDetailsScreen({
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final args = PostDetailsArgs(
|
||||
providerId: providerId,
|
||||
postId: postId,
|
||||
initialPost: initialPost,
|
||||
);
|
||||
final post = ref.watch(postDetailsControllerProvider(args));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
return AdaptiveScaffold(
|
||||
title: 'Post',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
onPressed: () => _close(context),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
body: post.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (post) {
|
||||
if (post == null) return const EmptyView(title: 'Post not found');
|
||||
final currentIndex = feedPosts.indexWhere(
|
||||
(item) => item.providerId == post.providerId && item.id == post.id,
|
||||
);
|
||||
final previous =
|
||||
currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
|
||||
final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
|
||||
? feedPosts[currentIndex + 1]
|
||||
: null;
|
||||
if (Responsive.isMobile(context)) {
|
||||
return _buildMobileDetails(context, ref, post, settings);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Previous',
|
||||
onPressed: previous == null
|
||||
? null
|
||||
: () => _openPost(context, previous),
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 760),
|
||||
child: PostMediaViewer(post: post),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Next',
|
||||
onPressed:
|
||||
next == null ? null : () => _openPost(context, next),
|
||||
icon: const Icon(Icons.chevron_right_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PostActionBar(
|
||||
onFavorite: () =>
|
||||
ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onCopy: () =>
|
||||
Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, post)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(label: Text(post.providerName)),
|
||||
RatingBadge(rating: post.rating),
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
PostTagsPanel(post: post),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileDetails(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
AppSettings settings,
|
||||
) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
|
||||
),
|
||||
child: Center(child: PostMediaViewer(post: post)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
onFavorite: () => ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onDownload:
|
||||
settings.allowDownloads ? () => _download(context, post) : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text(post.providerName)),
|
||||
RatingBadge(rating: post.rating),
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: PostTagsPanel(post: post),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
) async {
|
||||
final result = await ref.read(collectionServiceProvider).getCollections();
|
||||
final collections =
|
||||
result is Success<List<Collection>> ? result.data : <Collection>[];
|
||||
if (!context.mounted) return;
|
||||
await showAddToCollectionPicker(
|
||||
context,
|
||||
collections: collections,
|
||||
onSelected: (collection) {
|
||||
ref
|
||||
.read(collectionServiceProvider)
|
||||
.addPostToCollection(collection.id, post);
|
||||
},
|
||||
onCreate: () => showCollectionFormDialog(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _download(BuildContext context, Post post) async {
|
||||
final filename = p.basename(Uri.parse(post.fileUrl).path);
|
||||
final location = await getSaveLocation(suggestedName: filename);
|
||||
if (location == null) return;
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Downloading...')),
|
||||
);
|
||||
await Dio().download(post.fileUrl, location.path);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Download complete')),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPost(BuildContext context, Post post) {
|
||||
context.go('/post/${post.providerId}/${post.id}', extra: post);
|
||||
}
|
||||
|
||||
void _close(BuildContext context) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PostActionBar extends StatelessWidget {
|
||||
const PostActionBar({
|
||||
required this.onFavorite,
|
||||
required this.onCollection,
|
||||
required this.onOpen,
|
||||
required this.onCopy,
|
||||
this.onDownload,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback onCollection;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onCopy;
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onFavorite,
|
||||
icon: const Icon(Icons.favorite_rounded),
|
||||
label: const Text('Favorite'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Collection'),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Open original',
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Copy link',
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
),
|
||||
if (onDownload != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Download',
|
||||
onPressed: onDownload,
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class PostMediaViewer extends StatefulWidget {
|
||||
const PostMediaViewer({required this.post, super.key});
|
||||
|
||||
final Post post;
|
||||
|
||||
@override
|
||||
State<PostMediaViewer> createState() => _PostMediaViewerState();
|
||||
}
|
||||
|
||||
class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
Player? _player;
|
||||
VideoController? _controller;
|
||||
late List<String> _imageUrls;
|
||||
int _imageIndex = 0;
|
||||
bool _controlsVisible = true;
|
||||
bool _coverVideo = false;
|
||||
bool _muted = false;
|
||||
Timer? _hideTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imageUrls = _buildImageUrls(widget.post);
|
||||
if (widget.post.fileType == 'video') {
|
||||
MediaKit.ensureInitialized();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player!);
|
||||
_player!.open(Media(widget.post.fileUrl), play: false);
|
||||
_scheduleControlsHide();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_player?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_controller != null) {
|
||||
return _VideoSurface(
|
||||
player: _player!,
|
||||
controller: _controller!,
|
||||
aspectRatio: widget.post.width > 0 && widget.post.height > 0
|
||||
? widget.post.width / widget.post.height
|
||||
: 16 / 9,
|
||||
controlsVisible: _controlsVisible,
|
||||
coverVideo: _coverVideo,
|
||||
muted: _muted,
|
||||
onInteract: _showControls,
|
||||
onToggleFit: () {
|
||||
setState(() => _coverVideo = !_coverVideo);
|
||||
_showControls();
|
||||
},
|
||||
onToggleMute: () async {
|
||||
final nextMuted = !_muted;
|
||||
setState(() => _muted = nextMuted);
|
||||
await _player!.setVolume(nextMuted ? 0 : 100);
|
||||
_showControls();
|
||||
},
|
||||
);
|
||||
}
|
||||
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
|
||||
return InteractiveViewer(
|
||||
child: CachedNetworkImage(
|
||||
key: ValueKey(url),
|
||||
imageUrl: url,
|
||||
httpHeaders: _headersFor(widget.post),
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (context, url) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
errorWidget: (context, url, error) {
|
||||
if (_imageIndex < _imageUrls.length - 1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _imageIndex++);
|
||||
});
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.broken_image_rounded, size: 48),
|
||||
SizedBox(height: 8),
|
||||
Text('Could not load image'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _buildImageUrls(Post post) {
|
||||
return {
|
||||
post.sampleUrl,
|
||||
post.fileUrl,
|
||||
post.previewUrl,
|
||||
}.where((url) => url.trim().isNotEmpty).toList();
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
post.fileUrl.contains('gelbooru.com') ||
|
||||
post.sampleUrl.contains('gelbooru.com') ||
|
||||
post.previewUrl.contains('gelbooru.com'))
|
||||
'Referer': 'https://gelbooru.com/',
|
||||
};
|
||||
}
|
||||
|
||||
void _showControls() {
|
||||
if (!mounted) return;
|
||||
setState(() => _controlsVisible = true);
|
||||
_scheduleControlsHide();
|
||||
}
|
||||
|
||||
void _scheduleControlsHide() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _controlsVisible = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoSurface extends StatelessWidget {
|
||||
const _VideoSurface({
|
||||
required this.player,
|
||||
required this.controller,
|
||||
required this.aspectRatio,
|
||||
required this.controlsVisible,
|
||||
required this.coverVideo,
|
||||
required this.muted,
|
||||
required this.onInteract,
|
||||
required this.onToggleFit,
|
||||
required this.onToggleMute,
|
||||
});
|
||||
|
||||
final Player player;
|
||||
final VideoController controller;
|
||||
final double aspectRatio;
|
||||
final bool controlsVisible;
|
||||
final bool coverVideo;
|
||||
final bool muted;
|
||||
final VoidCallback onInteract;
|
||||
final VoidCallback onToggleFit;
|
||||
final VoidCallback onToggleMute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onHover: (_) => onInteract(),
|
||||
child: GestureDetector(
|
||||
onTap: onInteract,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: AspectRatio(
|
||||
aspectRatio: aspectRatio.clamp(0.35, 2.4),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Video(
|
||||
controller: controller,
|
||||
fit: coverVideo ? BoxFit.cover : BoxFit.contain,
|
||||
controls: null,
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: controlsVisible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _VideoControls(
|
||||
player: player,
|
||||
muted: muted,
|
||||
coverVideo: coverVideo,
|
||||
onToggleFit: onToggleFit,
|
||||
onToggleMute: onToggleMute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoControls extends StatelessWidget {
|
||||
const _VideoControls({
|
||||
required this.player,
|
||||
required this.muted,
|
||||
required this.coverVideo,
|
||||
required this.onToggleFit,
|
||||
required this.onToggleMute,
|
||||
});
|
||||
|
||||
final Player player;
|
||||
final bool muted;
|
||||
final bool coverVideo;
|
||||
final VoidCallback onToggleFit;
|
||||
final VoidCallback onToggleMute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.black.withValues(alpha: 0.18),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
initialData: player.state.playing,
|
||||
builder: (context, playingSnapshot) {
|
||||
final playing = playingSnapshot.data ?? false;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.duration,
|
||||
initialData: player.state.duration,
|
||||
builder: (context, durationSnapshot) {
|
||||
final duration = durationSnapshot.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (context, positionSnapshot) {
|
||||
final position = positionSnapshot.data ?? Duration.zero;
|
||||
final maxMs =
|
||||
duration.inMilliseconds.clamp(1, 1 << 31).toDouble();
|
||||
final valueMs = position.inMilliseconds
|
||||
.clamp(0, maxMs.toInt())
|
||||
.toDouble();
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Slider(
|
||||
value: valueMs,
|
||||
max: maxMs,
|
||||
onChanged: (value) => player.seek(
|
||||
Duration(milliseconds: value.round()),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: playing ? 'Pause' : 'Play',
|
||||
onPressed: player.playOrPause,
|
||||
icon: Icon(
|
||||
playing
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_format(position)} / ${_format(duration)}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
tooltip: muted ? 'Unmute' : 'Mute',
|
||||
onPressed: onToggleMute,
|
||||
icon: Icon(
|
||||
muted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
tooltip: coverVideo ? 'Fit' : 'Fill',
|
||||
onPressed: onToggleFit,
|
||||
icon: Icon(
|
||||
coverVideo
|
||||
? Icons.fit_screen_rounded
|
||||
: Icons.crop_free_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _format(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
final hours = duration.inHours;
|
||||
if (hours > 0) return '$hours:$minutes:$seconds';
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/tag_chip.dart';
|
||||
|
||||
class PostTagsPanel extends StatelessWidget {
|
||||
const PostTagsPanel({required this.post, super.key});
|
||||
|
||||
final Post post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in post.tags.take(80))
|
||||
TagChip(
|
||||
tag: tag,
|
||||
onTap: () => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'providers_controller.dart';
|
||||
import 'widgets/provider_health_tile.dart';
|
||||
|
||||
class ProviderCheckScreen extends ConsumerWidget {
|
||||
const ProviderCheckScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providers = ref.watch(providersControllerProvider);
|
||||
final health = ref.watch(providerHealthProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Provider Check',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Check all',
|
||||
onPressed: () => _checkAll(ref),
|
||||
icon: const Icon(Icons.playlist_add_check_rounded),
|
||||
),
|
||||
],
|
||||
body: providers.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ProviderHealthTile(
|
||||
config: item,
|
||||
health: health[item.id],
|
||||
onCheck: () => _checkOne(ref, item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkAll(WidgetRef ref) async {
|
||||
final result = await ref.read(providerCheckServiceProvider).checkAll();
|
||||
if (result is Success<List<ProviderHealth>>) {
|
||||
ref.read(providerHealthProvider.notifier).state = {
|
||||
for (final item in result.data) item.providerId: item,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkOne(WidgetRef ref, String providerId) async {
|
||||
final result =
|
||||
await ref.read(providerCheckServiceProvider).checkOne(providerId);
|
||||
if (result is Success<ProviderHealth>) {
|
||||
ref.read(providerHealthProvider.notifier).state = {
|
||||
...ref.read(providerHealthProvider),
|
||||
providerId: result.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import 'providers_controller.dart';
|
||||
|
||||
class ProviderFormScreen extends ConsumerStatefulWidget {
|
||||
const ProviderFormScreen({this.initialConfig, super.key});
|
||||
|
||||
final ContentProviderConfig? initialConfig;
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderFormScreen> createState() => _ProviderFormScreenState();
|
||||
}
|
||||
|
||||
class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
late final TextEditingController _name;
|
||||
late final TextEditingController _baseUrl;
|
||||
late final TextEditingController _priority;
|
||||
late final TextEditingController _timeout;
|
||||
late final TextEditingController _headers;
|
||||
late final TextEditingController _apiKey;
|
||||
late final TextEditingController _userId;
|
||||
late final TextEditingController _login;
|
||||
late String _apiType;
|
||||
late bool _enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final config = widget.initialConfig;
|
||||
_name = TextEditingController(text: config?.name);
|
||||
_baseUrl = TextEditingController(text: config?.baseUrl);
|
||||
_priority =
|
||||
TextEditingController(text: (config?.priority ?? 10).toString());
|
||||
_timeout =
|
||||
TextEditingController(text: (config?.timeoutSeconds ?? 20).toString());
|
||||
_headers = TextEditingController(
|
||||
text: jsonEncode(_visibleHeaders(config?.customHeaders ?? const {})),
|
||||
);
|
||||
_apiKey =
|
||||
TextEditingController(text: config?.customHeaders['query.api_key']);
|
||||
_userId =
|
||||
TextEditingController(text: config?.customHeaders['query.user_id']);
|
||||
_login = TextEditingController(text: config?.customHeaders['query.login']);
|
||||
_apiType = config?.apiType ?? 'gelbooru';
|
||||
_enabled = config?.enabled ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_baseUrl.dispose();
|
||||
_priority.dispose();
|
||||
_timeout.dispose();
|
||||
_headers.dispose();
|
||||
_apiKey.dispose();
|
||||
_userId.dispose();
|
||||
_login.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.initialConfig == null ? 'New provider' : 'Edit provider')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _name,
|
||||
decoration: const InputDecoration(labelText: 'Name')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _baseUrl,
|
||||
decoration: const InputDecoration(labelText: 'Base URL')),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _apiType,
|
||||
decoration: const InputDecoration(labelText: 'API type'),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'gelbooru', child: Text('gelbooru-compatible')),
|
||||
DropdownMenuItem(
|
||||
value: 'rule34', child: Text('rule34-compatible')),
|
||||
DropdownMenuItem(
|
||||
value: 'danbooru', child: Text('danbooru-compatible')),
|
||||
DropdownMenuItem(value: 'custom', child: Text('custom')),
|
||||
],
|
||||
onChanged: (value) =>
|
||||
setState(() => _apiType = value ?? 'gelbooru'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: _enabled,
|
||||
title: const Text('Enabled'),
|
||||
onChanged: (value) => setState(() => _enabled = value),
|
||||
),
|
||||
TextField(
|
||||
controller: _priority,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Priority'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _timeout,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Timeout seconds'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Provider API credentials',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _apiKey,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API key',
|
||||
helperText: 'Gelbooru: api_key, Danbooru: api_key',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _userId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'User ID',
|
||||
helperText: 'Gelbooru-compatible APIs may require user_id',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _login,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Login',
|
||||
helperText: 'Danbooru-compatible APIs may require login',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _headers,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
decoration: const InputDecoration(labelText: 'Custom headers JSON'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save provider'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final now = DateTime.now();
|
||||
final id = widget.initialConfig?.id ??
|
||||
_name.text.trim().toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
|
||||
final headersJson =
|
||||
jsonDecode(_headers.text.trim().isEmpty ? '{}' : _headers.text);
|
||||
final customHeaders = Map<String, String>.from(headersJson as Map);
|
||||
void putQuery(String key, String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isNotEmpty) customHeaders['query.$key'] = trimmed;
|
||||
}
|
||||
|
||||
putQuery('api_key', _apiKey.text);
|
||||
putQuery('user_id', _userId.text);
|
||||
putQuery('login', _login.text);
|
||||
final config = ContentProviderConfig(
|
||||
id: id,
|
||||
name: _name.text.trim(),
|
||||
baseUrl: _baseUrl.text.trim(),
|
||||
apiType: _apiType,
|
||||
enabled: _enabled,
|
||||
priority: int.tryParse(_priority.text) ?? 10,
|
||||
timeoutSeconds: int.tryParse(_timeout.text) ?? 20,
|
||||
customHeaders: customHeaders,
|
||||
createdAt: widget.initialConfig?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await ref.read(providersControllerProvider.notifier).save(config);
|
||||
if (mounted) context.go('/providers');
|
||||
}
|
||||
|
||||
Map<String, String> _visibleHeaders(Map<String, String> headers) {
|
||||
return Map<String, String>.from(headers)
|
||||
..removeWhere((key, _) => key.startsWith('query.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
final providersControllerProvider =
|
||||
AsyncNotifierProvider<ProvidersController, List<ContentProviderConfig>>(
|
||||
ProvidersController.new,
|
||||
);
|
||||
|
||||
class ProvidersController extends AsyncNotifier<List<ContentProviderConfig>> {
|
||||
@override
|
||||
Future<List<ContentProviderConfig>> build() => _load();
|
||||
|
||||
Future<void> toggle(String id, bool enabled) async {
|
||||
await ref.read(providerManagerProvider).enableProvider(id, enabled);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> save(ContentProviderConfig config) async {
|
||||
await ref.read(providerManagerProvider).addCustomProvider(config);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await ref.read(providerManagerProvider).deleteProvider(id);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _load() async {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: false);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
}
|
||||
}
|
||||
|
||||
final providerHealthProvider =
|
||||
StateProvider<Map<String, ProviderHealth>>((ref) => {});
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'providers_controller.dart';
|
||||
import 'widgets/provider_card.dart';
|
||||
|
||||
class ProvidersScreen extends ConsumerWidget {
|
||||
const ProvidersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providers = ref.watch(providersControllerProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Providers',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Check providers',
|
||||
onPressed: () => context.go('/providers/check'),
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Add provider',
|
||||
onPressed: () => context.go('/providers/new'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
body: providers.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ProviderCard(
|
||||
config: item,
|
||||
onToggle: (enabled) => ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.toggle(item.id, enabled),
|
||||
onEdit: () => context.go('/providers/new', extra: item),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Delete provider',
|
||||
message: 'Remove ${item.name}?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class ProviderCard extends StatelessWidget {
|
||||
const ProviderCard({
|
||||
required this.config,
|
||||
required this.onToggle,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContentProviderConfig config;
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Switch(value: config.enabled, onChanged: onToggle),
|
||||
title: Text(config.name),
|
||||
subtitle: Text(
|
||||
'${config.apiType} · ${config.baseUrl}\nPriority ${config.priority}'),
|
||||
isThreeLine: true,
|
||||
trailing: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/provider_status_badge.dart';
|
||||
|
||||
class ProviderHealthTile extends StatelessWidget {
|
||||
const ProviderHealthTile({
|
||||
required this.config,
|
||||
required this.health,
|
||||
required this.onCheck,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContentProviderConfig config;
|
||||
final ProviderHealth? health;
|
||||
final VoidCallback onCheck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(config.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (health != null) 'Ping ${health!.pingMs} ms',
|
||||
if (health?.lastCheckedAt != null)
|
||||
'Checked ${health!.lastCheckedAt}',
|
||||
if (health?.errorMessage != null) health!.errorMessage!,
|
||||
].join('\n'),
|
||||
),
|
||||
leading: ProviderStatusBadge(health: health),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Check',
|
||||
onPressed: onCheck,
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import 'search_state.dart';
|
||||
|
||||
final searchControllerProvider =
|
||||
AsyncNotifierProvider<SearchController, SearchState>(SearchController.new);
|
||||
|
||||
class SearchController extends AsyncNotifier<SearchState> {
|
||||
@override
|
||||
Future<SearchState> build() async {
|
||||
final recent = await _recent();
|
||||
return SearchState(recent: recent);
|
||||
}
|
||||
|
||||
Future<void> updateQuery(String query) async {
|
||||
final lastToken =
|
||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||
final suggestionsResult =
|
||||
await ref.read(searchServiceProvider).autocomplete(lastToken);
|
||||
final suggestions = suggestionsResult is Success<List<String>>
|
||||
? suggestionsResult.data
|
||||
: <String>[];
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(
|
||||
query: query,
|
||||
suggestions: suggestions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
await ref.read(searchServiceProvider).clearHistory();
|
||||
state = const AsyncData(SearchState());
|
||||
}
|
||||
|
||||
Future<List<SearchHistory>> _recent() async {
|
||||
final result = await ref.read(searchServiceProvider).recentSearches();
|
||||
return result is Success<List<SearchHistory>> ? result.data : const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/app_search_bar.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'search_controller.dart';
|
||||
import 'widgets/recent_searches.dart';
|
||||
import 'widgets/search_suggestions.dart';
|
||||
|
||||
class SearchScreen extends ConsumerWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(searchControllerProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Search',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear history',
|
||||
onPressed: () =>
|
||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AppSearchBar(
|
||||
onSubmitted: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SearchSuggestions(
|
||||
suggestions: data.suggestions,
|
||||
onTap: (suggestion) {
|
||||
final query = _applySuggestion(data.query, suggestion);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _applySuggestion(String query, String suggestion) {
|
||||
final tokens =
|
||||
query.trim().isEmpty ? <String>[] : query.trim().split(RegExp(r'\s+'));
|
||||
if (tokens.isEmpty) {
|
||||
tokens.add(suggestion);
|
||||
} else {
|
||||
tokens[tokens.length - 1] = suggestion;
|
||||
}
|
||||
return tokens.toSet().join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import '../../../backend/backend.dart';
|
||||
|
||||
class SearchState {
|
||||
const SearchState({
|
||||
this.recent = const [],
|
||||
this.suggestions = const [],
|
||||
this.query = '',
|
||||
});
|
||||
|
||||
final List<SearchHistory> recent;
|
||||
final List<String> suggestions;
|
||||
final String query;
|
||||
|
||||
SearchState copyWith({
|
||||
List<SearchHistory>? recent,
|
||||
List<String>? suggestions,
|
||||
String? query,
|
||||
}) {
|
||||
return SearchState(
|
||||
recent: recent ?? this.recent,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
query: query ?? this.query,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class RecentSearches extends StatelessWidget {
|
||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
||||
|
||||
final List<SearchHistory> items;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const Text('No recent searches');
|
||||
return Column(
|
||||
children: [
|
||||
for (final item in items)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(item.query),
|
||||
subtitle:
|
||||
Text('${item.tags.join(' ')} · ${item.resultCount} results'),
|
||||
onTap: () => onTap(item.query),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user