Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.dart_tool/
.packages
build/
/isar.dll
.idea/
*.iml
.flutter-plugins
.flutter-plugins-dependencies
android/local.properties
*.log
+36
View File
@@ -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'
+280
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
linter:
rules:
avoid_print: true
+11
View File
@@ -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
+44
View File
@@ -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>
+47
View File
@@ -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>
+37
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
Binary file not shown.
+5
View File
@@ -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
+160
View File
@@ -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 "$@"
+90
View File
@@ -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
+26
View File
@@ -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")
+114
View File
@@ -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'),
),
],
),
),
),
),
);
}
}
+26
View File
@@ -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);
}
}
+80
View File
@@ -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(),
),
],
),
],
);
});
+46
View File
@@ -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,
};
}
+16
View File
@@ -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';
+121
View File
@@ -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),
);
});
+80
View File
@@ -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';
}
}
+99
View File
@@ -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';
}
}
+16
View File
@@ -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,
);
}
}
+34
View File
@@ -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),
);
}
}
+13
View File
@@ -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;
}
+70
View File
@@ -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,
);
}
+42
View File
@@ -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?,
);
}
+15
View File
@@ -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;
}
+15
View File
@@ -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,
),
};
}
}
+172
View File
@@ -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();
}
+70
View File
@@ -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);
}
}
+55
View File
@@ -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();
}
+254
View File
@@ -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,
),
);
}
}
}
+73
View File
@@ -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
View File
@@ -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}';
}
+264
View File
@@ -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
+32
View File
@@ -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());
}
}
}
+54
View File
@@ -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();
}
+14
View File
@@ -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)';
}
+117
View File
@@ -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);
}
}
+12
View File
@@ -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);
}
}
+13
View File
@@ -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');
}
}
}
+29
View File
@@ -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