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