Compare commits
38 Commits
v1.0.25+25
...
v1.1.23+53
Author | SHA1 | Date | |
---|---|---|---|
dff9cda829 | |||
16ebc51e77 | |||
7981436374 | |||
e38d2086b3 | |||
a4d50289c2 | |||
82fb23dbfd | |||
13f957f016 | |||
707f14c5fb | |||
493422e724 | |||
3b95d128e1 | |||
57636c5de6 | |||
f75299f0d4 | |||
03c6431eca | |||
5876c809a5 | |||
c35308fbc1 | |||
e945844151 | |||
74eb6e3d26 | |||
9755066d1e | |||
671b3cfbe0 | |||
93fb3536ee | |||
346e447d5e | |||
f7777821fd | |||
ffbde73300 | |||
836a0886e2 | |||
1cd10b3809 | |||
43c42ac0d5 | |||
bf4e0fa493 | |||
27476fbc1d | |||
8e9f0eb1b8 | |||
f083fc8e8f | |||
9a716018fc | |||
f1eb52518b | |||
c7d996a402 | |||
ee93ef576b | |||
78ff1953ad | |||
6fb4775043 | |||
0d9ed1e6c1 | |||
bf77ccf8e3 |
@ -3,7 +3,7 @@ name: Flutter Schmutter
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -51,3 +51,11 @@ jobs:
|
|||||||
files: |-
|
files: |-
|
||||||
build/app/outputs/flutter-apk/app-release.apk
|
build/app/outputs/flutter-apk/app-release.apk
|
||||||
token: '${{secrets.RELEASE_TOKEN}}'
|
token: '${{secrets.RELEASE_TOKEN}}'
|
||||||
|
|
||||||
|
- name: upload apk to f-droid server
|
||||||
|
run: |
|
||||||
|
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
|
||||||
|
curl -X POST "https://flumm.io/pullfdroid.php" \
|
||||||
|
-F "token=${{ secrets.PULLER_TOKEN }}" \
|
||||||
|
-F "apk=@build/app/outputs/flutter-apk/app-release.apk" \
|
||||||
|
-F "build=$BUILD_NUMBER"
|
||||||
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 f0ck
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
52
README.md
@ -1,16 +1,48 @@
|
|||||||
# f0ckapp
|
# fApp
|
||||||
|

|
||||||
|
## Overview
|
||||||
|
|
||||||
A new Flutter project.
|
fApp is the mobile application for the website [f0ck.me](https://f0ck.me). This app provides a user-friendly interface to access the content of the website and utilize its features conveniently from your mobile device.
|
||||||
|
|
||||||
## Getting Started
|
## Installation
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
fApp is available in its own F-Droid repository.
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
### Installation Steps
|
||||||
|
|
||||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
1. Add the F-Droid repository to your F-Droid app:
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
- Go to the settings in the F-Droid app.
|
||||||
|
- Select "Repositories" and add the URL `https://fdroid.flumm.io/fdroid/repo/`.
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
2. Search for "f0ckapp" in the F-Droid app and install the app.
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Flutter SDK
|
||||||
|
- Dart SDK
|
||||||
|
- Android Studio or Visual Studio Code
|
||||||
|
|
||||||
|
### Setting Up the Project
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://git.lat/f0ck/fApp.git
|
||||||
|
cd fApp
|
||||||
|
2. Install dependencies:
|
||||||
|
```flutter pub get```
|
||||||
|
3. Run the app:
|
||||||
|
```flutter run```
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
|
don't.
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License. See the LICENSE file for more information.
|
||||||
|
|
||||||
|
### Contact
|
||||||
|
|
||||||
|
For questions or feedback, you can reach us at [contact@f0ck.me](mailto:contact@f0ck.me).
|
||||||
|
@ -11,12 +11,12 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<application
|
<application
|
||||||
android:label="f0ckapp"
|
android:label="f0ck"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -13,8 +15,7 @@
|
|||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize">
|
||||||
android:requestLegacyExternalStorage="true">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
@ -27,12 +28,19 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https" android:host="f0ck.me"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
@ -1,5 +1,69 @@
|
|||||||
package com.f0ck.f0ckapp
|
package com.f0ck.f0ckapp
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.NonNull
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val CHANNEL = "MediaShit"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine): Unit {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
|
||||||
|
call,
|
||||||
|
result ->
|
||||||
|
if (call.method == "saveFile") {
|
||||||
|
val filePath = call.argument<String>("filePath")
|
||||||
|
val fileName = call.argument<String>("fileName")
|
||||||
|
val subDir = call.argument<String?>("subDir")
|
||||||
|
|
||||||
|
if (filePath == null || fileName == null)
|
||||||
|
result.error("SAVE_FAILED", "file not found", null)
|
||||||
|
|
||||||
|
if (!saveFileUsingMediaStore(applicationContext, filePath!!, fileName!!, subDir))
|
||||||
|
result.error("COPY_FAILED", "Datei konnte nicht gespeichert werden", null)
|
||||||
|
result.success(true)
|
||||||
|
} else result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFileUsingMediaStore(
|
||||||
|
context: Context,
|
||||||
|
filePath: String,
|
||||||
|
fileName: String,
|
||||||
|
subDir: String?
|
||||||
|
|
||||||
|
): Boolean {
|
||||||
|
val srcFile = File(filePath)
|
||||||
|
if (!srcFile.exists()) return false
|
||||||
|
|
||||||
|
val values =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/" + (subDir ?: "f0ck"))
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||||
|
val uri = resolver.insert(collection, values) ?: return false
|
||||||
|
|
||||||
|
resolver.openOutputStream(uri).use { out ->
|
||||||
|
FileInputStream(srcFile).use { input -> input.copyTo(out!!, 4096) }
|
||||||
|
}
|
||||||
|
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
|
</adaptive-icon>
|
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 3.5 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
After Width: | Height: | Size: 839 B |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
After Width: | Height: | Size: 446 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 5.2 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 75 KiB |
After Width: | Height: | Size: 75 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 133 KiB |
After Width: | Height: | Size: 133 KiB |
BIN
android/app/src/main/res/play_store_512.png
Normal file
After Width: | Height: | Size: 345 KiB |
BIN
assets/images/f0ck_small.webp
Normal file
After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 447 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 192 KiB |
Before Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 8.7 KiB |
Before Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 14 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 3.7 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 1.2 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 7.0 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png
Normal file
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 2.2 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png
Normal file
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 12 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png
Normal file
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 43 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png
Normal file
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 36 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png
Normal file
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 1.1 MiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png
Normal file
After Width: | Height: | Size: 11 KiB |
@ -1,27 +1,78 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:f0ckapp/screens/MediaGrid.dart';
|
import 'package:f0ckapp/screens/mediagrid_screen.dart';
|
||||||
|
import 'package:f0ckapp/screens/detailview_screen.dart';
|
||||||
|
import 'package:f0ckapp/screens/settings_screen.dart';
|
||||||
|
import 'package:f0ckapp/utils/appversion_util.dart';
|
||||||
|
import 'package:f0ckapp/providers/theme_provider.dart';
|
||||||
|
import 'package:f0ckapp/providers/media_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
runApp(const F0ckApp());
|
await AppVersion.init();
|
||||||
|
|
||||||
|
runApp(ProviderScope(child: F0ckApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class F0ckApp extends StatelessWidget {
|
class F0ckApp extends ConsumerWidget {
|
||||||
const F0ckApp({super.key});
|
const F0ckApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final ThemeData theme = ref.watch(themeNotifierProvider);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: theme,
|
||||||
scaffoldBackgroundColor: const Color.fromARGB(255, 23, 23, 23),
|
initialRoute: '/',
|
||||||
),
|
routes: {
|
||||||
home: Scaffold(
|
'/': (context) => const MediaGrid(),
|
||||||
body: MediaGrid(),
|
'/settings': (context) => const SettingsPage(),
|
||||||
),
|
},
|
||||||
|
onGenerateRoute: (RouteSettings settings) {
|
||||||
|
final String? name = settings.name;
|
||||||
|
if (name == null) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const Scaffold(body: Center(child: Text('Ungültiger Link'))),
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp regExp = RegExp(
|
||||||
|
r'^(?:/tag/(?<tag>[^/]+))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$',
|
||||||
|
);
|
||||||
|
final RegExpMatch? match = regExp.firstMatch(name);
|
||||||
|
|
||||||
|
if (match != null) {
|
||||||
|
final String? tag = match.namedGroup('tag');
|
||||||
|
final String? mime = match.namedGroup('mime');
|
||||||
|
final String? idStr = match.namedGroup('itemid');
|
||||||
|
final int? itemId = idStr != null ? int.tryParse(idStr) : null;
|
||||||
|
const int preloadOffset = 50;
|
||||||
|
|
||||||
|
if (itemId != null) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => DetailView(initialItemId: itemId),
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => const MediaGrid(),
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const Scaffold(body: Center(child: Text('Ungültiger Link'))),
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,7 @@ class MediaItem {
|
|||||||
String get thumbnailUrl => 'https://f0ck.me/t/$id.webp';
|
String get thumbnailUrl => 'https://f0ck.me/t/$id.webp';
|
||||||
String get mediaUrl => 'https://f0ck.me/b/$dest';
|
String get mediaUrl => 'https://f0ck.me/b/$dest';
|
||||||
String get coverUrl => 'https://f0ck.me/ca/$id.webp';
|
String get coverUrl => 'https://f0ck.me/ca/$id.webp';
|
||||||
|
String get postUrl => 'https://f0ck.me/$id';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Tag {
|
class Tag {
|
19
lib/models/suggestion_model.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
class Suggestion {
|
||||||
|
final String tag;
|
||||||
|
final int tagged;
|
||||||
|
final double score;
|
||||||
|
|
||||||
|
Suggestion({
|
||||||
|
required this.tag,
|
||||||
|
required this.tagged,
|
||||||
|
required this.score,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Suggestion.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Suggestion(
|
||||||
|
tag: json['tag'].toString(),
|
||||||
|
tagged: json['tagged'],
|
||||||
|
score: (json['score'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
163
lib/providers/media_provider.dart
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import 'package:f0ckapp/models/mediaitem_model.dart';
|
||||||
|
import 'package:f0ckapp/services/api_service.dart';
|
||||||
|
|
||||||
|
const List<String> mediaTypes = ["alles", "image", "video", "audio"];
|
||||||
|
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
|
||||||
|
const _unsetTag = Object();
|
||||||
|
|
||||||
|
class MediaState {
|
||||||
|
final int typeIndex;
|
||||||
|
final int modeIndex;
|
||||||
|
final bool random;
|
||||||
|
final String? tag;
|
||||||
|
final int crossAxisCount;
|
||||||
|
final List<MediaItem> mediaItems;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool muted;
|
||||||
|
|
||||||
|
const MediaState({
|
||||||
|
this.typeIndex = 0,
|
||||||
|
this.modeIndex = 0,
|
||||||
|
this.random = false,
|
||||||
|
this.tag,
|
||||||
|
this.crossAxisCount = 0,
|
||||||
|
this.mediaItems = const [],
|
||||||
|
this.isLoading = false,
|
||||||
|
this.muted = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
MediaState replace({
|
||||||
|
int? typeIndex,
|
||||||
|
int? modeIndex,
|
||||||
|
bool? random,
|
||||||
|
Object? tag = _unsetTag,
|
||||||
|
int? crossAxisCount,
|
||||||
|
List<MediaItem>? mediaItems,
|
||||||
|
bool? isLoading,
|
||||||
|
bool? muted,
|
||||||
|
}) {
|
||||||
|
return MediaState(
|
||||||
|
typeIndex: typeIndex ?? this.typeIndex,
|
||||||
|
modeIndex: modeIndex ?? this.modeIndex,
|
||||||
|
random: random ?? this.random,
|
||||||
|
tag: identical(tag, _unsetTag) ? this.tag : tag as String?,
|
||||||
|
crossAxisCount: crossAxisCount ?? this.crossAxisCount,
|
||||||
|
mediaItems: mediaItems ?? this.mediaItems,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
muted: muted ?? this.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaNotifier extends StateNotifier<MediaState> {
|
||||||
|
final _storage = const FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaNotifier() : super(const MediaState()) {
|
||||||
|
_loadMutedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMutedState() async {
|
||||||
|
final storedMuted = await _storage.read(key: 'muted');
|
||||||
|
final isMuted = storedMuted == 'true';
|
||||||
|
state = state.replace(muted: isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMutedState() async {
|
||||||
|
await _storage.write(key: 'muted', value: state.muted.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void setType(String type) {
|
||||||
|
final newIndex = mediaTypes.indexOf(type);
|
||||||
|
state = state.replace(typeIndex: newIndex);
|
||||||
|
resetMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMode(int modeIndex) {
|
||||||
|
state = state.replace(modeIndex: modeIndex);
|
||||||
|
resetMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleRandom() {
|
||||||
|
state = state.replace(random: !state.random);
|
||||||
|
resetMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTag(String? tag) {
|
||||||
|
state = state.replace(tag: tag);
|
||||||
|
resetMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCrossAxisCount(int count) {
|
||||||
|
state = state.replace(crossAxisCount: count);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetMedia() {
|
||||||
|
state = state.replace(mediaItems: []);
|
||||||
|
loadMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addMediaItems(List<MediaItem> newItems) {
|
||||||
|
final Set<int> existingIds = state.mediaItems
|
||||||
|
.map((item) => item.id)
|
||||||
|
.toSet();
|
||||||
|
final List<MediaItem> filteredItems = newItems
|
||||||
|
.where((item) => !existingIds.contains(item.id))
|
||||||
|
.toList();
|
||||||
|
if (filteredItems.isNotEmpty) {
|
||||||
|
final List<MediaItem> updated = List<MediaItem>.from(state.mediaItems)
|
||||||
|
..addAll(filteredItems);
|
||||||
|
state = state.replace(mediaItems: updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MediaItem> mergeMediaItems(
|
||||||
|
List<MediaItem> current,
|
||||||
|
List<MediaItem> incoming,
|
||||||
|
) {
|
||||||
|
final existingIds = current.map((item) => item.id).toSet();
|
||||||
|
final newItems = incoming
|
||||||
|
.where((item) => !existingIds.contains(item.id))
|
||||||
|
.toList();
|
||||||
|
return [...current, ...newItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMedia({int? id}) async {
|
||||||
|
if (state.isLoading) return;
|
||||||
|
state = state.replace(isLoading: true);
|
||||||
|
try {
|
||||||
|
final older =
|
||||||
|
id ?? (state.mediaItems.isNotEmpty ? state.mediaItems.last.id : null);
|
||||||
|
final newMedia = await fetchMedia(
|
||||||
|
older: older,
|
||||||
|
type: mediaTypes[state.typeIndex],
|
||||||
|
mode: state.modeIndex,
|
||||||
|
random: state.random,
|
||||||
|
tag: state.tag,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newMedia.isNotEmpty) {
|
||||||
|
state = state.replace(
|
||||||
|
mediaItems: mergeMediaItems(state.mediaItems, newMedia),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Fehler beim Laden der Medien: $e');
|
||||||
|
} finally {
|
||||||
|
state = state.replace(isLoading: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleMute() {
|
||||||
|
state = state.replace(muted: !state.muted);
|
||||||
|
_saveMutedState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mediaProvider = StateNotifierProvider<MediaNotifier, MediaState>(
|
||||||
|
(ref) => MediaNotifier(),
|
||||||
|
);
|
288
lib/providers/theme_provider.dart
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
final Map<String, ThemeData> themeMap = {
|
||||||
|
'f0ck': f0ckTheme,
|
||||||
|
'P1nk': p1nkTheme, // done
|
||||||
|
'Orange': orangeTheme,
|
||||||
|
'Amoled': amoledTheme,
|
||||||
|
'Paper': paperTheme,
|
||||||
|
//'Iced': icedTheme,
|
||||||
|
'f0ck95': f0ck95Theme,
|
||||||
|
'f0ck95d': f0ck95dTheme,
|
||||||
|
};
|
||||||
|
|
||||||
|
class ThemeNotifier extends StateNotifier<ThemeData> {
|
||||||
|
final FlutterSecureStorage secureStorage;
|
||||||
|
|
||||||
|
ThemeNotifier({required this.secureStorage}) : super(f0ckTheme) {
|
||||||
|
_loadTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTheme() async {
|
||||||
|
try {
|
||||||
|
String? savedThemeName = await secureStorage.read(key: 'theme');
|
||||||
|
if (savedThemeName != null && themeMap.containsKey(savedThemeName)) {
|
||||||
|
state = themeMap[savedThemeName]!;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Fehler beim Laden des Themes: $error');
|
||||||
|
state = f0ckTheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateTheme(String themeName) async {
|
||||||
|
try {
|
||||||
|
await secureStorage.write(key: 'theme', value: themeName);
|
||||||
|
state = themeMap[themeName] ?? f0ckTheme;
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Fehler beim Aktualisieren des Themes: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final themeNotifierProvider = StateNotifierProvider<ThemeNotifier, ThemeData>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return ThemeNotifier(
|
||||||
|
secureStorage: const FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ThemeData f0ckTheme = ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primaryColor: const Color(0xFF9FFF00),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF000000),
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFF9FFF00),
|
||||||
|
secondary: Color(0xFF262626),
|
||||||
|
surface: Color(0xFF232323),
|
||||||
|
onPrimary: Color(0xFF000000),
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF2B2B2B),
|
||||||
|
foregroundColor: Color(0xFF9FFF00),
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Colors.white),
|
||||||
|
bodyMedium: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF000000),
|
||||||
|
backgroundColor: const Color(0xFF9FFF00),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
|
||||||
|
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData p1nkTheme = ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primaryColor: const Color(0xFF171717),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF171717),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
color: Color(0xFF2B2B2B),
|
||||||
|
foregroundColor: Color(0xFFFF00D0),
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Color(0xFFFFFFFF)),
|
||||||
|
bodyMedium: TextStyle(color: Color(0xFFFFFFFF)),
|
||||||
|
titleLarge: TextStyle(color: Color(0xFFFFFFFF)),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF000000),
|
||||||
|
backgroundColor: const Color(0xFFFF00D0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
|
color: Color(0xFFFF00D0),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all(const Color(0xFF2B2B2B)),
|
||||||
|
trackColor: WidgetStateProperty.all(const Color(0xFF424242)),
|
||||||
|
),
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFF171717),
|
||||||
|
secondary: Color(0xFFFF00D0),
|
||||||
|
surface: Color(0xFF171717),
|
||||||
|
onPrimary: Color(0xFFFFFFFF),
|
||||||
|
onSecondary: Color(0xFF000000),
|
||||||
|
onSurface: Color(0xFFFFFFFF),
|
||||||
|
error: Color(0xFFA72828),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData paperTheme = ThemeData(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primaryColor: const Color(0xFF000000),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFFFFFFF),
|
||||||
|
colorScheme: const ColorScheme.light(
|
||||||
|
primary: Color(0xFF000000),
|
||||||
|
secondary: Color(0xFF262626),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: Color(0xFF000000),
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFFFFFFFF),
|
||||||
|
foregroundColor: Color(0xFF000000),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Color(0xFF000000)),
|
||||||
|
bodyMedium: TextStyle(color: Color(0xFF000000)),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFFFFFFF),
|
||||||
|
backgroundColor: const Color(0xFF000000),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData orangeTheme = ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primaryColor: const Color(0xFFFF6F00),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF171717),
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFFFF6F00),
|
||||||
|
secondary: Color(0xFF262626),
|
||||||
|
surface: Color(0xFF232323),
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF2B2B2B),
|
||||||
|
foregroundColor: Color(0xFFFF6F00),
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Colors.white),
|
||||||
|
bodyMedium: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: const Color(0xFFFF6F00),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
|
||||||
|
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData amoledTheme = ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primaryColor: const Color(0xFFFFFFFF),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF000000),
|
||||||
|
canvasColor: const Color(0xFF000000),
|
||||||
|
cardColor: const Color(0xFF000000),
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFFFFFFFF),
|
||||||
|
secondary: Color(0xFF1F1F1F),
|
||||||
|
surface: Color(0xFF000000),
|
||||||
|
onPrimary: Color(0xFF000000),
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF000000),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Colors.white),
|
||||||
|
bodyMedium: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
backgroundColor: const Color(0xFFFFFFFF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF1D1C1C)),
|
||||||
|
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF000000)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData f0ck95Theme = ThemeData(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primaryColor: const Color(0xFFC0C0C0),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF008080),
|
||||||
|
colorScheme: const ColorScheme.light(
|
||||||
|
primary: Color(0xFFC0C0C0),
|
||||||
|
secondary: Color(0xFF808080),
|
||||||
|
surface: Color(0xFFC0C0C0),
|
||||||
|
onPrimary: Colors.black,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: const Color(0xFFE0E0E0),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 4,
|
||||||
|
centerTitle: true
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Colors.black),
|
||||||
|
bodyMedium: TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
|
||||||
|
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ThemeData f0ck95dTheme = ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primaryColor: const Color(0xFFFFFFFF),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF0E0F0F),
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFFFFFFFF),
|
||||||
|
secondary: Color(0xFFC0C0C0),
|
||||||
|
surface: Color(0xFF333131),
|
||||||
|
onPrimary: Colors.black,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF0B0A0A),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
bodyLarge: TextStyle(color: Colors.white),
|
||||||
|
bodyMedium: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
|
||||||
|
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
|
||||||
|
),
|
||||||
|
);
|
@ -1,247 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:f0ckapp/models/MediaItem.dart';
|
|
||||||
import 'package:f0ckapp/services/Api.dart';
|
|
||||||
import 'package:f0ckapp/widgets/VideoWidget.dart';
|
|
||||||
import 'package:f0ckapp/utils/SmartRefreshIndicator.dart';
|
|
||||||
import 'package:f0ckapp/utils/PageTransformer.dart';
|
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
|
|
||||||
class DetailView extends StatefulWidget {
|
|
||||||
final int initialItemId;
|
|
||||||
final List<MediaItem> mediaItems;
|
|
||||||
final String type;
|
|
||||||
final int mode;
|
|
||||||
final bool random;
|
|
||||||
final String? tagname;
|
|
||||||
|
|
||||||
const DetailView({
|
|
||||||
super.key,
|
|
||||||
required this.initialItemId,
|
|
||||||
required this.mediaItems,
|
|
||||||
required this.type,
|
|
||||||
required this.mode,
|
|
||||||
required this.random,
|
|
||||||
required this.tagname,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State createState() => _DetailViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DetailViewState extends State<DetailView> {
|
|
||||||
late PageController _pageController;
|
|
||||||
late List<MediaItem> mediaItems;
|
|
||||||
String? _tagname;
|
|
||||||
int currentItemId = 0;
|
|
||||||
bool isLoading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
mediaItems = widget.mediaItems;
|
|
||||||
_tagname = widget.tagname;
|
|
||||||
final initialIndex = mediaItems.indexWhere(
|
|
||||||
(item) => item.id == widget.initialItemId,
|
|
||||||
);
|
|
||||||
_pageController = PageController(initialPage: initialIndex);
|
|
||||||
currentItemId = mediaItems[initialIndex].id;
|
|
||||||
|
|
||||||
int? lastLoadedIndex;
|
|
||||||
_pageController.addListener(() async {
|
|
||||||
final newIndex = _pageController.page?.round();
|
|
||||||
if (newIndex != null &&
|
|
||||||
newIndex < mediaItems.length &&
|
|
||||||
newIndex != lastLoadedIndex) {
|
|
||||||
setState(() => currentItemId = mediaItems[newIndex].id);
|
|
||||||
lastLoadedIndex = newIndex;
|
|
||||||
|
|
||||||
_preloadAdjacentMedia(newIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_pageController.position.pixels >=
|
|
||||||
_pageController.position.maxScrollExtent - 100) {
|
|
||||||
_loadMoreMedia();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_preloadAdjacentMedia(initialIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _preloadAdjacentMedia(int index) async {
|
|
||||||
if (index + 1 < mediaItems.length) {
|
|
||||||
final nextUrl = mediaItems[index + 1].mediaUrl;
|
|
||||||
if (await DefaultCacheManager().getFileFromCache(nextUrl) == null) {
|
|
||||||
await DefaultCacheManager().downloadFile(nextUrl);
|
|
||||||
print('preload ${mediaItems[index + 1].id}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index - 1 >= 0) {
|
|
||||||
final prevUrl = mediaItems[index - 1].mediaUrl;
|
|
||||||
if (await DefaultCacheManager().getFileFromCache(prevUrl) == null) {
|
|
||||||
await DefaultCacheManager().downloadFile(prevUrl);
|
|
||||||
print('preload ${mediaItems[index - 1].id}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMoreMedia() async {
|
|
||||||
if (isLoading) return;
|
|
||||||
setState(() => isLoading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final newMedia = await fetchMedia(
|
|
||||||
older: mediaItems.last.id.toString(),
|
|
||||||
type: widget.type,
|
|
||||||
mode: widget.mode,
|
|
||||||
random: widget.random,
|
|
||||||
tag: _tagname,
|
|
||||||
);
|
|
||||||
if (mounted && newMedia.isNotEmpty) {
|
|
||||||
setState(() => mediaItems.addAll(newMedia));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_showError("Fehler beim Laden weiterer Medien: $e");
|
|
||||||
} finally {
|
|
||||||
setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshMediaItem() async {
|
|
||||||
try {
|
|
||||||
final updatedItem = await fetchMediaDetail(currentItemId);
|
|
||||||
if (mounted) {
|
|
||||||
final index = mediaItems.indexWhere((item) => item.id == currentItemId);
|
|
||||||
if (index != -1) {
|
|
||||||
setState(() => mediaItems[index] = updatedItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_showError("Fehler beim Aktualisieren des Items: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showError(String message) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFF171717),
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: const Color(0xFF2B2B2B),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
title: Text('f0ck #$currentItemId (${widget.type})'),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
PageTransformer(
|
|
||||||
controller: _pageController,
|
|
||||||
pages: mediaItems.map((item) {
|
|
||||||
final isActive = item.id == currentItemId;
|
|
||||||
return Scaffold(
|
|
||||||
body: SafeArea(
|
|
||||||
child: SmartRefreshIndicator(
|
|
||||||
onRefresh: _refreshMediaItem,
|
|
||||||
child: _buildMediaItem(item, isActive),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
if (_tagname != null)
|
|
||||||
Positioned(
|
|
||||||
bottom: 60,
|
|
||||||
left: MediaQuery.of(context).size.width * 0.2,
|
|
||||||
right: MediaQuery.of(context).size.width * 0.2,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 10,
|
|
||||||
horizontal: 20,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.8),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Tag: $_tagname',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_tagname = '___empty___';
|
|
||||||
Navigator.pop(context, _tagname);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMediaItem(MediaItem item, bool isActive) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (item.mime.startsWith('image'))
|
|
||||||
CachedNetworkImage(
|
|
||||||
imageUrl: item.mediaUrl,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
placeholder: (context, url) => CircularProgressIndicator(),
|
|
||||||
errorWidget: (context, url, error) => Icon(Icons.error),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
VideoWidget(details: item, isActive: isActive),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
item.mime,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10, width: double.infinity),
|
|
||||||
Wrap(
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
spacing: 5.0,
|
|
||||||
children: item.tags.map((tag) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if (tag.tag == 'sfw' || tag.tag == 'nsfw') return;
|
|
||||||
setState(() {
|
|
||||||
_tagname = tag.tag;
|
|
||||||
Navigator.pop(context, _tagname);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Chip(
|
|
||||||
label: Text(tag.tag),
|
|
||||||
backgroundColor: switch (tag.id) {
|
|
||||||
1 => Colors.green,
|
|
||||||
2 => Colors.red,
|
|
||||||
_ => const Color(0xFF090909),
|
|
||||||
},
|
|
||||||
labelStyle: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,355 +0,0 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:f0ckapp/services/Api.dart';
|
|
||||||
import 'package:f0ckapp/models/MediaItem.dart';
|
|
||||||
import 'package:f0ckapp/screens/DetailView.dart';
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
class MediaGrid extends StatefulWidget {
|
|
||||||
const MediaGrid({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State createState() => _MediaGridState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MediaGridState extends State<MediaGrid> {
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
final String _version = '1.0.25+25';
|
|
||||||
List<MediaItem> mediaItems = [];
|
|
||||||
bool isLoading = false;
|
|
||||||
Timer? _debounceTimer;
|
|
||||||
Completer<void>? _navigationCompleter;
|
|
||||||
int _crossAxisCount = 0;
|
|
||||||
String _selectedType = 'alles';
|
|
||||||
int _selectedMode = 0;
|
|
||||||
bool _random = false;
|
|
||||||
final List<String> _modes = ["sfw", "nsfw", "untagged", "all"];
|
|
||||||
String? _selectedTag;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadMedia();
|
|
||||||
_scrollController.addListener(() {
|
|
||||||
if (_scrollController.position.pixels >=
|
|
||||||
_scrollController.position.maxScrollExtent - 100) {
|
|
||||||
_debounceLoadMedia();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _debounceLoadMedia() {
|
|
||||||
_debounceTimer?.cancel();
|
|
||||||
_debounceTimer = Timer(const Duration(milliseconds: 500), _loadMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateCrossAxisCount(BuildContext context) {
|
|
||||||
if (_crossAxisCount != 0) {
|
|
||||||
return _crossAxisCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
double screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
int columnCount = (screenWidth / 110).clamp(3, 5).toInt();
|
|
||||||
|
|
||||||
return columnCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMedia() async {
|
|
||||||
if (isLoading) return;
|
|
||||||
setState(() => isLoading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final newMedia = await fetchMedia(
|
|
||||||
older: mediaItems.isNotEmpty ? mediaItems.last.id.toString() : null,
|
|
||||||
type: _selectedType,
|
|
||||||
mode: _selectedMode,
|
|
||||||
random: _random,
|
|
||||||
tag: _selectedTag,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => mediaItems.addAll(newMedia));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Fehler beim Laden der Medien: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshMedia() async {
|
|
||||||
setState(() => isLoading = true);
|
|
||||||
try {
|
|
||||||
final freshMedia = await fetchMedia(
|
|
||||||
older: null,
|
|
||||||
type: _selectedType,
|
|
||||||
mode: _selectedMode,
|
|
||||||
random: _random,
|
|
||||||
tag: _selectedTag,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
mediaItems.clear();
|
|
||||||
mediaItems.addAll(freshMedia);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Fehler beim Aktualisieren: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _navigateToDetail(MediaItem item) async {
|
|
||||||
if (_navigationCompleter?.isCompleted == false) return;
|
|
||||||
|
|
||||||
_navigationCompleter = Completer();
|
|
||||||
try {
|
|
||||||
if (mounted) {
|
|
||||||
final String? newTag = await Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => DetailView(
|
|
||||||
initialItemId: item.id,
|
|
||||||
mediaItems: mediaItems,
|
|
||||||
type: _selectedType,
|
|
||||||
mode: _selectedMode,
|
|
||||||
random: _random,
|
|
||||||
tagname: _selectedTag,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newTag != null) {
|
|
||||||
setState(() {
|
|
||||||
if (newTag == '___empty___') {
|
|
||||||
_selectedTag = null;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
_selectedTag = newTag;
|
|
||||||
}
|
|
||||||
_refreshMedia();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Fehler beim Laden der Details: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_navigationCompleter?.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: const Color.fromARGB(255, 43, 43, 43),
|
|
||||||
foregroundColor: const Color.fromARGB(255, 255, 255, 255),
|
|
||||||
title: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text('f0ck v$_version'),
|
|
||||||
Checkbox(
|
|
||||||
value: _random,
|
|
||||||
onChanged: (bool? value) {
|
|
||||||
setState(() {
|
|
||||||
_random = !_random;
|
|
||||||
_refreshMedia();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottomNavigationBar: BottomAppBar(
|
|
||||||
color: const Color.fromARGB(255, 43, 43, 43),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: _selectedType,
|
|
||||||
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
|
|
||||||
iconEnabledColor: Colors.white,
|
|
||||||
items: ["alles", "image", "video", "audio"].map((String value) {
|
|
||||||
return DropdownMenuItem<String>(
|
|
||||||
value: value,
|
|
||||||
child: Text(value, style: TextStyle(color: Colors.white)),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (String? newValue) {
|
|
||||||
if (newValue != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedType = newValue;
|
|
||||||
_refreshMedia();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: _modes[_selectedMode],
|
|
||||||
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
|
|
||||||
iconEnabledColor: Colors.white,
|
|
||||||
items: _modes.map((String value) {
|
|
||||||
return DropdownMenuItem<String>(
|
|
||||||
value: value,
|
|
||||||
child: Text(value, style: TextStyle(color: Colors.white)),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (String? newValue) {
|
|
||||||
if (newValue != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedMode = _modes.indexOf(newValue);
|
|
||||||
_refreshMedia();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
DropdownButton<int>(
|
|
||||||
value: _crossAxisCount,
|
|
||||||
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
|
|
||||||
iconEnabledColor: Colors.white,
|
|
||||||
items: [0, 3, 4].map((int value) {
|
|
||||||
return DropdownMenuItem<int>(
|
|
||||||
value: value,
|
|
||||||
child: Text(
|
|
||||||
value == 0 ? 'auto' : '$value Spalten',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (int? newValue) {
|
|
||||||
if (newValue != null) {
|
|
||||||
setState(() {
|
|
||||||
_crossAxisCount = newValue;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
RefreshIndicator(
|
|
||||||
onRefresh: _refreshMedia,
|
|
||||||
child: GridView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: _calculateCrossAxisCount(context),
|
|
||||||
crossAxisSpacing: 5.0,
|
|
||||||
mainAxisSpacing: 5.0,
|
|
||||||
),
|
|
||||||
itemCount: mediaItems.length + (isLoading ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= mediaItems.length) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
final item = mediaItems[index];
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => _navigateToDetail(item),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: <Widget>[
|
|
||||||
CachedNetworkImage(
|
|
||||||
imageUrl: item.thumbnailUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => SizedBox.shrink(),
|
|
||||||
errorWidget: (context, url, error) => Icon(Icons.error),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: FractionalOffset.bottomRight,
|
|
||||||
child: Icon(
|
|
||||||
Icons.square,
|
|
||||||
color: switch (item.mode) {
|
|
||||||
1 => Colors.green,
|
|
||||||
2 => Colors.red,
|
|
||||||
_ => Colors.yellow,
|
|
||||||
},
|
|
||||||
size: 15.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_selectedTag != null)
|
|
||||||
Positioned(
|
|
||||||
bottom: 20,
|
|
||||||
left: MediaQuery.of(context).size.width * 0.2,
|
|
||||||
right: MediaQuery.of(context).size.width * 0.2,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 10,
|
|
||||||
horizontal: 20,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.8),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Tag: $_selectedTag',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedTag = null;
|
|
||||||
_refreshMedia();
|
|
||||||
});
|
|
||||||
_scrollController.animateTo(
|
|
||||||
0.0,
|
|
||||||
duration: const Duration(milliseconds: 500),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
/*floatingActionButton: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.black.withValues(alpha: 0.8),
|
|
||||||
child: const Icon(Icons.arrow_upward, color: Colors.white),
|
|
||||||
onPressed: () {
|
|
||||||
_scrollController.animateTo(
|
|
||||||
0.0,
|
|
||||||
duration: const Duration(milliseconds: 500),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),*/
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
_debounceTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|