Compare commits

...

23 Commits

Author SHA1 Message Date
dff9cda829 v1.1.23+53
All checks were successful
Flutter Schmutter / build (push) Successful in 3m40s
- bye go_router
2025-06-12 11:39:31 +02:00
16ebc51e77 v1.1.22+52
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-11 21:30:05 +02:00
7981436374 v1.1.21+51
All checks were successful
Flutter Schmutter / build (push) Successful in 3m40s
2025-06-11 20:45:37 +02:00
e38d2086b3 v1.1.20+50
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-11 18:56:55 +02:00
a4d50289c2 v1.1.19+49
All checks were successful
Flutter Schmutter / build (push) Successful in 3m36s
2025-06-11 14:53:26 +02:00
82fb23dbfd v1.1.18+48
All checks were successful
Flutter Schmutter / build (push) Successful in 3m36s
- fullscreen
2025-06-11 13:41:12 +02:00
13f957f016 test schmest
All checks were successful
Flutter Schmutter / build (push) Successful in 3m32s
2025-06-11 12:16:32 +02:00
707f14c5fb testbuild, rebranding
All checks were successful
Flutter Schmutter / build (push) Successful in 3m43s
2025-06-11 11:31:45 +02:00
493422e724 v1.1.15+45
All checks were successful
Flutter Schmutter / build (push) Successful in 3m35s
- buildtest lol
2025-06-11 11:22:00 +02:00
3b95d128e1 fk gitea 2025-06-11 11:01:40 +02:00
57636c5de6 v1.1.14+44
All checks were successful
Flutter Schmutter / build (push) Successful in 3m32s
2025-06-11 10:52:15 +02:00
f75299f0d4 xd
All checks were successful
Flutter Schmutter / build (push) Successful in 3m53s
2025-06-10 18:53:52 +02:00
03c6431eca v1.1.13+43
- fk android
2025-06-10 18:53:07 +02:00
5876c809a5 v1.1.12+42
All checks were successful
Flutter Schmutter / build (push) Successful in 3m45s
- search schmearch
2025-06-10 11:07:00 +02:00
c35308fbc1 v1.1.11+41
All checks were successful
Flutter Schmutter / build (push) Successful in 3m57s
- fixed: duplicates on the frontpage
- new: search by tag
2025-06-10 08:39:55 +02:00
e945844151 v1.1.10+40
All checks were successful
Flutter Schmutter / build (push) Successful in 3m56s
- download button lel
2025-06-09 19:08:23 +02:00
74eb6e3d26 readme & license 2025-06-09 15:42:13 +02:00
9755066d1e full retard renaming 2025-06-09 15:04:03 +02:00
671b3cfbe0 v1.1.9+39
All checks were successful
Flutter Schmutter / build (push) Successful in 3m35s
2025-06-09 14:02:59 +02:00
93fb3536ee v1.1.8+38
All checks were successful
Flutter Schmutter / build (push) Successful in 3m48s
- blah
2025-06-08 19:40:06 +02:00
346e447d5e v1.1.7+37
All checks were successful
Flutter Schmutter / build (push) Successful in 3m44s
- worst update eu west
2025-06-08 17:16:10 +02:00
f7777821fd iiiiiicon 2025-06-08 10:33:05 +02:00
ffbde73300 v1.1.6+36
All checks were successful
Flutter Schmutter / build (push) Successful in 3m34s
- new theme: p1nk
- optimizations
2025-06-07 23:07:31 +02:00
67 changed files with 1698 additions and 887 deletions

View File

@ -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
View 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.

View File

@ -1,16 +1,48 @@
# f0ckapp # fApp
![f0ck.me Logo](https://git.lat/f0ck/fApp/raw/branch/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png)
## 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).

View File

@ -1,8 +1,9 @@
<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"> android:enableOnBackInvokedCallback="true">
@ -14,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
@ -28,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

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,18 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:f0ckapp/screens/MediaGrid.dart';
import 'package:f0ckapp/utils/AppVersion.dart';
import 'package:f0ckapp/providers/ThemeProvider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.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]);
await AppVersion.init(); await AppVersion.init();
runApp(ProviderScope(child: const F0ckApp())); runApp(ProviderScope(child: F0ckApp()));
} }
class F0ckApp extends ConsumerWidget { class F0ckApp extends ConsumerWidget {
@ -20,12 +22,55 @@ class F0ckApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Consumer( final ThemeData theme = ref.watch(themeNotifierProvider);
builder: (context, ref, _) {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ref.watch(themeNotifierProvider), theme: theme,
home: const MediaGrid(), initialRoute: '/',
routes: {
'/': (context) => const 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,
); );
}, },
); );

View 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(),
);
}
}

View File

@ -1,8 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:f0ckapp/models/MediaItem.dart'; import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/services/Api.dart'; import 'package:f0ckapp/services/api_service.dart';
const List<String> mediaTypes = ["alles", "image", "video", "audio"]; const List<String> mediaTypes = ["alles", "image", "video", "audio"];
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"]; const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
@ -102,18 +102,36 @@ class MediaNotifier extends StateNotifier<MediaState> {
} }
void addMediaItems(List<MediaItem> newItems) { void addMediaItems(List<MediaItem> newItems) {
final updated = List<MediaItem>.from(state.mediaItems)..addAll(newItems); final Set<int> existingIds = state.mediaItems
state = state.replace(mediaItems: updated); .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);
}
} }
Future<void> loadMedia() async { List<MediaItem> mergeMediaItems(
if (state.isLoading) return; 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); state = state.replace(isLoading: true);
try { try {
final older = state.mediaItems.isNotEmpty final older =
? state.mediaItems.last.id id ?? (state.mediaItems.isNotEmpty ? state.mediaItems.last.id : null);
: null;
final newMedia = await fetchMedia( final newMedia = await fetchMedia(
older: older, older: older,
type: mediaTypes[state.typeIndex], type: mediaTypes[state.typeIndex],
@ -121,8 +139,11 @@ class MediaNotifier extends StateNotifier<MediaState> {
random: state.random, random: state.random,
tag: state.tag, tag: state.tag,
); );
if (newMedia.isNotEmpty) { if (newMedia.isNotEmpty) {
addMediaItems(newMedia); state = state.replace(
mediaItems: mergeMediaItems(state.mediaItems, newMedia),
);
} }
} catch (e) { } catch (e) {
print('Fehler beim Laden der Medien: $e'); print('Fehler beim Laden der Medien: $e');

View File

@ -1,8 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.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> { class ThemeNotifier extends StateNotifier<ThemeData> {
final FlutterSecureStorage secureStorage; final FlutterSecureStorage secureStorage;
@ -11,23 +23,24 @@ class ThemeNotifier extends StateNotifier<ThemeData> {
} }
Future<void> _loadTheme() async { Future<void> _loadTheme() async {
String? savedThemeName = await secureStorage.read(key: 'theme'); try {
if (savedThemeName != null) { String? savedThemeName = await secureStorage.read(key: 'theme');
final customTheme = themes.firstWhere( if (savedThemeName != null && themeMap.containsKey(savedThemeName)) {
(t) => t.name == savedThemeName, state = themeMap[savedThemeName]!;
orElse: () => CustomTheme(name: 'f0ck', theme: f0ckTheme), }
); } catch (error) {
state = customTheme.theme; debugPrint('Fehler beim Laden des Themes: $error');
state = f0ckTheme;
} }
} }
Future<void> updateTheme(String themeName) async { Future<void> updateTheme(String themeName) async {
await secureStorage.write(key: 'theme', value: themeName); try {
final newTheme = themes.firstWhere( await secureStorage.write(key: 'theme', value: themeName);
(t) => t.name == themeName, state = themeMap[themeName] ?? f0ckTheme;
orElse: () => CustomTheme(name: 'f0ck', theme: f0ckTheme), } catch (error) {
); debugPrint('Fehler beim Aktualisieren des Themes: $error');
state = newTheme.theme; }
} }
} }
@ -35,28 +48,12 @@ final themeNotifierProvider = StateNotifierProvider<ThemeNotifier, ThemeData>((
ref, ref,
) { ) {
return ThemeNotifier( return ThemeNotifier(
secureStorage: FlutterSecureStorage( secureStorage: const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
), ),
); );
}); });
class CustomTheme {
final String name;
final ThemeData theme;
CustomTheme({required this.name, required this.theme});
}
final List<CustomTheme> themes = [
CustomTheme(name: 'f0ck', theme: f0ckTheme),
CustomTheme(name: 'Paper', theme: paperTheme),
CustomTheme(name: 'Orange', theme: orangeTheme),
CustomTheme(name: 'Amoled', theme: amoledTheme),
CustomTheme(name: 'f0ck95', theme: f0ck95Theme),
CustomTheme(name: 'f0ck95d', theme: f0ck95dTheme),
];
final ThemeData f0ckTheme = ThemeData( final ThemeData f0ckTheme = ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primaryColor: const Color(0xFF9FFF00), primaryColor: const Color(0xFF9FFF00),
@ -78,13 +75,53 @@ final ThemeData f0ckTheme = ThemeData(
bodyLarge: TextStyle(color: Colors.white), bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white), bodyMedium: TextStyle(color: Colors.white),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: const Color(0xFF9FFF00), style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: const Color(0xFF000000),
backgroundColor: const Color(0xFF9FFF00),
),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll<Color>(Color(0xFF2B2B2B)), thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
trackColor: WidgetStatePropertyAll<Color>(Color(0xFF424242)), 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),
), ),
); );
@ -109,9 +146,11 @@ final ThemeData paperTheme = ThemeData(
bodyLarge: TextStyle(color: Color(0xFF000000)), bodyLarge: TextStyle(color: Color(0xFF000000)),
bodyMedium: TextStyle(color: Color(0xFF000000)), bodyMedium: TextStyle(color: Color(0xFF000000)),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: const Color(0xFF000000), style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: const Color(0xFFFFFFFF),
backgroundColor: const Color(0xFF000000),
),
), ),
); );
@ -136,13 +175,15 @@ final ThemeData orangeTheme = ThemeData(
bodyLarge: TextStyle(color: Colors.white), bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white), bodyMedium: TextStyle(color: Colors.white),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: const Color(0xFFFF6F00), style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: Colors.white,
backgroundColor: const Color(0xFFFF6F00),
),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll<Color>(Color(0xFF2B2B2B)), thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
trackColor: WidgetStatePropertyAll<Color>(Color(0xFF424242)), trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
), ),
); );
@ -169,9 +210,11 @@ final ThemeData amoledTheme = ThemeData(
bodyLarge: TextStyle(color: Colors.white), bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white), bodyMedium: TextStyle(color: Colors.white),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: const Color(0xFFFFFFFF), style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: Colors.black,
backgroundColor: const Color(0xFFFFFFFF),
),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF1D1C1C)), thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF1D1C1C)),
@ -190,22 +233,25 @@ final ThemeData f0ck95Theme = ThemeData(
onPrimary: Colors.black, onPrimary: Colors.black,
onSecondary: Colors.white, onSecondary: Colors.white,
), ),
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Color(0xFFC0C0C0), backgroundColor: const Color(0xFFE0E0E0),
foregroundColor: Colors.black, foregroundColor: Colors.black,
elevation: 2, elevation: 4,
centerTitle: true
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.black), bodyLarge: TextStyle(color: Colors.black),
bodyMedium: TextStyle(color: Colors.black), bodyMedium: TextStyle(color: Colors.black),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: Colors.black, style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: Colors.white,
backgroundColor: Colors.black,
),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll<Color>(Color(0xFF2B2B2B)), thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
trackColor: WidgetStatePropertyAll<Color>(Color(0xFF424242)), trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
), ),
); );
@ -229,12 +275,14 @@ final ThemeData f0ck95dTheme = ThemeData(
bodyLarge: TextStyle(color: Colors.white), bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white), bodyMedium: TextStyle(color: Colors.white),
), ),
buttonTheme: ButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
buttonColor: Colors.white, style: ElevatedButton.styleFrom(
textTheme: ButtonTextTheme.primary, foregroundColor: Colors.black,
backgroundColor: Colors.white,
),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll<Color>(Color(0xFF2B2B2B)), thumbColor: WidgetStateProperty.all<Color>(const Color(0xFF2B2B2B)),
trackColor: WidgetStatePropertyAll<Color>(Color(0xFF424242)), trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
), ),
); );

View File

@ -1,242 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:share_plus/share_plus.dart';
import 'package:f0ckapp/models/MediaItem.dart';
import 'package:f0ckapp/widgets/VideoWidget.dart';
import 'package:f0ckapp/utils/SmartRefreshIndicator.dart';
import 'package:f0ckapp/utils/PageTransformer.dart';
import 'package:f0ckapp/providers/MediaProvider.dart';
class DetailView extends ConsumerStatefulWidget {
final int initialItemId;
const DetailView({super.key, required this.initialItemId});
@override
ConsumerState<DetailView> createState() => _DetailViewState();
}
class _DetailViewState extends ConsumerState<DetailView> {
late PageController _pageController;
bool isLoading = false;
int _currentIndex = 0;
@override
void initState() {
super.initState();
final mediaState = ref.read(mediaProvider);
final initialIndex = mediaState.mediaItems.indexWhere(
(item) => item.id == widget.initialItemId,
);
_pageController = PageController(initialPage: initialIndex);
_currentIndex = initialIndex;
_pageController.addListener(() {
setState(() => _currentIndex = _pageController.page?.round() ?? 0);
});
_preloadAdjacentMedia(initialIndex);
}
void _preloadAdjacentMedia(int index) async {
final mediaState = ref.read(mediaProvider);
if (index + 1 < mediaState.mediaItems.length) {
final nextUrl = mediaState.mediaItems[index + 1].mediaUrl;
if (await DefaultCacheManager().getFileFromCache(nextUrl) == null) {
await DefaultCacheManager().downloadFile(nextUrl);
}
}
if (index - 1 >= 0) {
final prevUrl = mediaState.mediaItems[index - 1].mediaUrl;
if (await DefaultCacheManager().getFileFromCache(prevUrl) == null) {
await DefaultCacheManager().downloadFile(prevUrl);
}
}
}
Future<void> _loadMoreMedia() async {
if (isLoading) return;
setState(() => isLoading = true);
try {
await ref.read(mediaProvider.notifier).loadMedia();
} catch (e) {
_showError("Fehler beim Laden der Medien: $e");
} finally {
setState(() => isLoading = false);
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final mediaState = ref.watch(mediaProvider);
final mediaNotifier = ref.read(mediaProvider.notifier);
if (mediaState.mediaItems.isEmpty) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text('f0ck #${mediaState.mediaItems[_currentIndex].id}'),
actions: [
IconButton(
icon: Icon(Icons.download),
onPressed: () {
// wip
},
),
PopupMenuButton<String>(
onSelected: (value) async {
final item = mediaState.mediaItems[_currentIndex];
switch (value) {
case 'media':
File file = await DefaultCacheManager().getSingleFile(
item.mediaUrl,
);
Uint8List bytes = await file.readAsBytes();
final params = ShareParams(
files: [XFile.fromData(bytes, mimeType: item.mime)],
);
await SharePlus.instance.share(params);
break;
case 'direct_link':
await SharePlus.instance.share(
ShareParams(text: item.mediaUrl),
);
break;
case 'post_link':
await SharePlus.instance.share(
ShareParams(text: item.postUrl),
);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'media',
child: ListTile(
leading: const Icon(Icons.image),
title: const Text('Als Datei'),
),
),
PopupMenuItem(
value: 'direct_link',
child: ListTile(
leading: const Icon(Icons.link),
title: const Text('Link zur Datei'),
),
),
PopupMenuItem(
value: 'post_link',
child: ListTile(
leading: const Icon(Icons.article),
title: const Text('Link zum f0ck'),
),
),
],
icon: const Icon(Icons.share),
),
],
),
body: Stack(
children: [
PageTransformer(
controller: _pageController,
pages: mediaState.mediaItems.map((item) {
int itemIndex = mediaState.mediaItems.indexOf(item);
return SafeArea(
child: SmartRefreshIndicator(
onRefresh: _loadMoreMedia,
child: _buildMediaItem(item, _currentIndex == itemIndex),
),
);
}).toList(),
),
],
),
persistentFooterButtons: mediaState.tag != null
? [
Center(
child: InputChip(
label: Text(mediaState.tag!),
onDeleted: () {
mediaNotifier.setTag(null);
Navigator.pop(context);
},
),
),
]
: null,
);
}
Widget _buildMediaItem(MediaItem item, bool isActive) {
final mediaNotifier = ref.read(mediaProvider.notifier);
return SingleChildScrollView(
child: Column(
children: [
if (item.mime.startsWith('image'))
CachedNetworkImage(
imageUrl: item.mediaUrl,
fit: BoxFit.contain,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
)
else
VideoWidget(details: item, isActive: isActive),
const SizedBox(height: 10, width: double.infinity),
Wrap(
alignment: WrapAlignment.center,
spacing: 5.0,
children: item.tags.map((tag) {
return ActionChip(
onPressed: () {
if (tag.tag == 'sfw' || tag.tag == 'nsfw') return;
setState(() {
mediaNotifier.setTag(tag.tag);
Navigator.pop(context, true);
});
},
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),
],
),
);
}
}

View File

@ -1,322 +0,0 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/screens/DetailView.dart';
import 'package:f0ckapp/providers/MediaProvider.dart';
import 'package:f0ckapp/utils/AppVersion.dart';
import 'package:f0ckapp/providers/ThemeProvider.dart';
const List<String> mediaTypes = ["alles", "image", "video", "audio"];
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
class MediaGrid extends ConsumerStatefulWidget {
const MediaGrid({super.key});
@override
ConsumerState<MediaGrid> createState() => _MediaGridState();
}
class _MediaGridState extends ConsumerState<MediaGrid> {
final ScrollController _scrollController = ScrollController();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
int _calculateCrossAxisCount(BuildContext context, int defaultCount) {
return defaultCount == 0
? (MediaQuery.of(context).size.width / 110).clamp(3, 5).toInt()
: defaultCount;
}
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(mediaProvider.notifier).loadMedia();
});
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(mediaProvider.notifier).loadMedia();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final mediaState = ref.watch(mediaProvider);
final mediaNotifier = ref.read(mediaProvider.notifier);
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: GestureDetector(
child: Row(
spacing: 10,
children: [
Image.asset(
'assets/images/f0ck_small.webp',
fit: BoxFit.fitHeight,
),
Text('fApp', style: TextStyle(fontSize: 24)),
],
),
onTap: () {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
},
),
actions: [
IconButton(
icon: Icon(
mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle,
),
onPressed: () {
mediaNotifier.toggleRandom();
_scrollController.jumpTo(0);
},
),
IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
_scaffoldKey.currentState?.openEndDrawer();
},
),
],
),
bottomNavigationBar: BottomAppBar(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const Text('type: '),
DropdownButton<String>(
value: mediaTypes[mediaState.typeIndex],
isDense: true,
items: mediaTypes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
mediaNotifier.setType(newValue);
_scrollController.jumpTo(0);
}
},
),
const Text('mode: '),
DropdownButton<String>(
value: mediaModes[mediaState.modeIndex],
isDense: true,
items: mediaModes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
mediaNotifier.setMode(mediaModes.indexOf(newValue));
_scrollController.jumpTo(0);
}
},
),
],
),
),
endDrawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/menu.webp'),
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
child: null,
),
ExpansionTile(
title: const Text('Login'),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
readOnly: true,
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Benutzername',
),
),
const SizedBox(height: 10),
TextField(
readOnly: true,
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("noch nicht implementiert lol"),
),
/*final success = await login(
_usernameController.text,
_passwordController.text,
);
if (success) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Login fehlgeschlagen!")),
);
}*/
);
},
child: const Text('Login'),
),
],
),
),
],
),
ExpansionTile(
title: const Text('Theme'),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: themes.map((themeData) {
final currentTheme = ref.watch(themeNotifierProvider);
final isSelected = currentTheme == themeData.theme;
return ListTile(
title: Text(themeData.name),
selected: isSelected,
selectedTileColor: Colors.blue.withValues(alpha: 0.2),
onTap: () async {
await ref
.read(themeNotifierProvider.notifier)
.updateTheme(themeData.name);
},
);
}).toList(),
),
),
],
),
ListTile(
title: Text('v${AppVersion.version}'),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('jooong lass das, hier ist nichts'),
),
);
},
),
],
),
),
persistentFooterButtons: mediaState.tag != null
? [
Center(
child: InputChip(
label: Text(mediaState.tag!),
onDeleted: () {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
},
),
),
]
: null,
body: RefreshIndicator(
onRefresh: () async {
mediaNotifier.resetMedia();
_scrollController.jumpTo(0);
},
child: GridView.builder(
key: const PageStorageKey('mediaGrid'),
controller: _scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _calculateCrossAxisCount(
context,
mediaState.crossAxisCount,
),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5.0,
),
itemCount:
mediaState.mediaItems.length + (mediaState.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= mediaState.mediaItems.length) {
return const Center(child: CircularProgressIndicator());
}
final item = mediaState.mediaItems[index];
return InkWell(
onTap: () async {
bool? ret = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailView(initialItemId: item.id),
),
);
if (ret != null && ret) {
_scrollController.jumpTo(0);
}
},
child: Stack(
fit: StackFit.expand,
children: <Widget>[
CachedNetworkImage(
imageUrl: item.thumbnailUrl,
fit: BoxFit.cover,
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) =>
const Icon(Icons.error),
),
Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.square,
color: switch (item.mode) {
1 => Colors.green,
2 => Colors.red,
_ => Colors.yellow,
},
size: 15.0,
),
),
],
),
);
},
),
),
);
}
}

View File

@ -0,0 +1,299 @@
import 'dart:io';
import 'package:f0ckapp/screens/fullscreen_screen.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/widgets/video_widget.dart';
import 'package:f0ckapp/utils/smartrefreshindicator_util.dart';
import 'package:f0ckapp/utils/pagetransformer_util.dart';
import 'package:f0ckapp/providers/media_provider.dart';
class DetailView extends ConsumerStatefulWidget {
final int initialItemId;
const DetailView({super.key, required this.initialItemId});
@override
ConsumerState<DetailView> createState() => _DetailViewState();
}
class _DetailViewState extends ConsumerState<DetailView> {
PageController? _pageController;
bool isLoading = false;
int _currentIndex = 0;
@override
void initState() {
super.initState();
}
void _preloadAdjacentMedia(int index) async {
final mediaState = ref.read(mediaProvider);
for (int offset in [-1, 1]) {
final adjacentIndex = index + offset;
if (adjacentIndex >= 0 && adjacentIndex < mediaState.mediaItems.length) {
final url = mediaState.mediaItems[adjacentIndex].mediaUrl;
if (await DefaultCacheManager().getFileFromCache(url) == null) {
await DefaultCacheManager().downloadFile(url);
}
}
}
}
Future<void> _loadMoreMedia() async {
if (isLoading) return;
setState(() => isLoading = true);
try {
await ref.read(mediaProvider.notifier).loadMedia();
} catch (e) {
_showMsg("Fehler beim Laden der Medien: $e");
} finally {
setState(() => isLoading = false);
}
}
void _showMsg(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _downloadMedia() async {
final MediaState mediaState = ref.read(mediaProvider);
final MediaItem currentItem = mediaState.mediaItems[_currentIndex];
final File file = await DefaultCacheManager().getSingleFile(
currentItem.mediaUrl,
);
final MethodChannel methodChannel = const MethodChannel('MediaShit');
bool? success = await methodChannel.invokeMethod<bool>('saveFile', {
'filePath': file.path,
'fileName': currentItem.dest,
});
success == true
? _showMsg(
'${currentItem.dest} wurde in Downloads/fApp neigespeichert.',
)
: _showMsg('${currentItem.dest} konnte nicht heruntergeladen werden.');
}
@override
void dispose() {
_pageController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final MediaState mediaState = ref.watch(mediaProvider);
final int itemIndex = mediaState.mediaItems.indexWhere(
(item) => item.id == widget.initialItemId,
);
if (itemIndex == -1) {
Future.microtask(() {
ref.read(mediaProvider.notifier).loadMedia(id: widget.initialItemId + 50);
});
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_pageController == null) {
_pageController = PageController(initialPage: itemIndex);
_currentIndex = itemIndex;
_pageController!.addListener(() {
setState(() => _currentIndex = _pageController!.page?.round() ?? 0);
});
_preloadAdjacentMedia(itemIndex);
}
return Scaffold(
endDrawer: EndDrawer(ref: ref),
endDrawerEnableOpenDragGesture: false,
persistentFooterButtons: mediaState.tag != null
? [
Center(
child: InputChip(
label: Text(mediaState.tag!),
onDeleted: () {
ref.read(mediaProvider.notifier).setTag(null);
//context.push('/', extra: true);
Navigator.pushNamed(context, '/');
},
),
),
]
: null,
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
snap: true,
centerTitle: true,
title: Text('f0ck #${mediaState.mediaItems[_currentIndex].id}'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.canPop(context) ? Navigator.pop(context) : Navigator.pushNamed(context, '/');
//context.canPop() ? context.pop() : context.go('/', extra: true);
},
),
actions: [
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
final currentItem = mediaState.mediaItems[_currentIndex];
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => FullScreenMediaView(item: currentItem),
),
);
},
),
IconButton(
icon: const Icon(Icons.download),
onPressed: _downloadMedia,
),
PopupMenuButton<String>(
onSelected: (value) async {
final item = mediaState.mediaItems[_currentIndex];
switch (value) {
case 'media':
File file = await DefaultCacheManager().getSingleFile(
item.mediaUrl,
);
Uint8List bytes = await file.readAsBytes();
final params = ShareParams(
files: [XFile.fromData(bytes, mimeType: item.mime)],
);
await SharePlus.instance.share(params);
break;
case 'direct_link':
await SharePlus.instance.share(
ShareParams(text: item.mediaUrl),
);
break;
case 'post_link':
await SharePlus.instance.share(
ShareParams(text: item.postUrl),
);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'media',
child: ListTile(
leading: const Icon(Icons.image),
title: const Text('Als Datei'),
),
),
PopupMenuItem(
value: 'direct_link',
child: ListTile(
leading: const Icon(Icons.link),
title: const Text('Link zur Datei'),
),
),
PopupMenuItem(
value: 'post_link',
child: ListTile(
leading: const Icon(Icons.article),
title: const Text('Link zum f0ck'),
),
),
],
icon: const Icon(Icons.share),
),
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
),
),
],
),
SliverPadding(
padding: EdgeInsets.zero,
sliver: SliverFillRemaining(
child: PageTransformer(
controller: _pageController!,
pages: mediaState.mediaItems.map((item) {
int pageIndex = mediaState.mediaItems.indexOf(item);
return SafeArea(
top: false,
child: SmartRefreshIndicator(
onRefresh: _loadMoreMedia,
child: _buildMediaItem(item, _currentIndex == pageIndex),
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _buildMediaItem(MediaItem item, bool isActive) {
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return SingleChildScrollView(
child: Column(
children: [
if (item.mime.startsWith('image'))
CachedNetworkImage(
imageUrl: item.mediaUrl,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) =>
const Center(child: Icon(Icons.error)),
)
else
VideoWidget(details: item, isActive: isActive),
const SizedBox(height: 10, width: double.infinity),
Wrap(
alignment: WrapAlignment.center,
spacing: 5.0,
children: item.tags.map((tag) {
return ActionChip(
onPressed: () {
if (tag.tag == 'sfw' || tag.tag == 'nsfw') return;
setState(() {
mediaNotifier.setTag(tag.tag);
Navigator.pushReplacementNamed(context, '/');
});
},
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),
],
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/widgets/video_widget.dart';
class FullScreenMediaView extends StatefulWidget {
final MediaItem item;
const FullScreenMediaView({super.key, required this.item});
@override
State createState() => _FullScreenMediaViewState();
}
class _FullScreenMediaViewState extends State<FullScreenMediaView> {
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
@override
void dispose() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: widget.item.mime.startsWith('image')
? InteractiveViewer(
minScale: 1.0,
maxScale: 7.0,
child: CachedNetworkImage(
imageUrl: widget.item.mediaUrl,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) =>
const Icon(Icons.error),
),
)
: SizedBox.expand(
child: VideoWidget(
details: widget.item,
isActive: true,
fullScreen: true,
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:f0ckapp/utils/customsearchdelegate_util.dart';
import 'package:f0ckapp/widgets/media_tile.dart';
import 'package:f0ckapp/widgets/filter_bar.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
class MediaGrid extends ConsumerStatefulWidget {
const MediaGrid({super.key});
@override
ConsumerState<MediaGrid> createState() => _MediaGridState();
}
class _MediaGridState extends ConsumerState<MediaGrid> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(mediaProvider.notifier).loadMedia();
});
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(mediaProvider.notifier).loadMedia();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final MediaState mediaState = ref.watch(mediaProvider);
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
await mediaNotifier.loadMedia();
},
child: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
floating: true,
snap: true,
title: GestureDetector(
onTap: () {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
},
child: Row(
children: [
Image.asset(
'assets/images/f0ck_small.webp',
fit: BoxFit.fitHeight,
),
const SizedBox(width: 10),
const Text('fApp', style: TextStyle(fontSize: 24)),
],
),
),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () async {
await showSearch(
context: context,
delegate: CustomSearchDelegate(),
);
},
),
IconButton(
icon: Icon(
mediaState.random
? Icons.shuffle_on_outlined
: Icons.shuffle,
),
onPressed: () {
mediaNotifier.toggleRandom();
_scrollController.jumpTo(0);
},
),
Builder(
builder: (context) {
return IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
);
},
),
],
),
SliverPadding(
padding: EdgeInsets.zero,
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= mediaState.mediaItems.length) {
return const Center(child: CircularProgressIndicator());
}
return MediaTile(item: mediaState.mediaItems[index]);
},
childCount:
mediaState.mediaItems.length +
(mediaState.isLoading ? 1 : 0),
),
gridDelegate: mediaState.crossAxisCount == 0
? const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 5,
mainAxisSpacing: 5,
childAspectRatio: 1,
)
: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: mediaState.crossAxisCount,
crossAxisSpacing: 5,
mainAxisSpacing: 5,
childAspectRatio: 1,
),
),
),
],
),
),
bottomNavigationBar: FilterBar(
mediaNotifier: mediaNotifier,
mediaState: mediaState,
scrollController: _scrollController,
),
endDrawer: EndDrawer(ref: ref),
endDrawerEnableOpenDragGesture: false,
persistentFooterButtons: mediaState.tag != null
? [
Center(
child: InputChip(
label: Text(mediaState.tag!),
onDeleted: () {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
},
),
),
]
: null,
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
int _columns = 3;
bool _drawerSwipeEnabled = true;
@override
Widget build(BuildContext context) {
final MediaState mediaState = ref.watch(mediaProvider);
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Scaffold(
endDrawerEnableOpenDragGesture: _drawerSwipeEnabled,
endDrawer: EndDrawer(ref: ref),
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: false,
pinned: true,
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.canPop(context) ? Navigator.pop(context) : Navigator.pushReplacementNamed(context, '/');
},
),
),
SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
"Anzahl der Spalten",
style: Theme.of(context).textTheme.titleLarge,
),
),
ListTile(
title: const Text("Spaltenanzahl"),
trailing: DropdownButton<int>(
value: mediaState.crossAxisCount,
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
iconEnabledColor: Colors.white,
items: [0, 3, 4, 5].map((int value) {
return DropdownMenuItem<int>(
value: value,
child: Text(value == 0 ? 'auto' : '$value Spalten'),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_columns = newValue;
mediaNotifier.setCrossAxisCount(newValue);
});
}
},
),
),
const Divider(),
SwitchListTile(
title: const Text("Drawer per Geste öffnen"),
subtitle: const Text(
"Wähle, ob der Drawer mit einer Wischgeste geschlossen/ geöffnet werden kann.",
),
value: _drawerSwipeEnabled,
onChanged: (bool value) {
setState(() {
_drawerSwipeEnabled = value;
});
},
),
const Divider(),
ListTile(
title: const Text("Cache löschen"),
trailing: ElevatedButton(
onPressed: () async {
await DefaultCacheManager().emptyCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Cache wurde geleert.")),
);
},
child: const Text("Löschen"),
),
),
const SizedBox(height: 20),
]),
),
],
),
);
}
}

View File

@ -1,69 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:f0ckapp/models/MediaItem.dart';
final storage = FlutterSecureStorage();
Future<List<MediaItem>> fetchMedia({
int? older,
String? type,
int? mode,
bool? random,
String? tag,
}) async {
final Uri url = Uri.parse('https://api.f0ck.me/items/get').replace(
queryParameters: {
'type': type ?? 'image',
'mode': (mode ?? 0).toString(),
'random': (random! ? 1 : 0).toString(),
if (tag != null) 'tag': tag,
if (older != null) 'older': older.toString(),
},
);
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((item) => MediaItem.fromJson(item)).toList();
} else {
throw Exception('Fehler beim Abrufen der Medien: ${response.statusCode}');
}
}
Future<MediaItem> fetchMediaDetail(int itemId) async {
final Uri url = Uri.parse('https://api.f0ck.me/item/${itemId.toString()}');
final response = await http.get(url);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
return MediaItem.fromJson(jsonResponse);
} else {
throw Exception(
'Fehler beim Abrufen der Media-Details: ${response.statusCode}',
);
}
}
Future<bool> login(String username, String password) async {
final Uri url = Uri.parse('https://api.f0ck.me/login');
final response = await http.post(
url,
body: {'username': username, 'password': password},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final token = data['token'];
await storage.write(key: "token", value: token);
return true;
} else {
return false;
}
}

View File

@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/models/suggestion_model.dart';
final FlutterSecureStorage storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<List<MediaItem>> fetchMedia({
int? older,
String type = 'image',
int mode = 0,
bool random = false,
String? tag,
}) async {
final Uri url = Uri.parse('https://api.f0ck.me/items/get').replace(
queryParameters: {
'type': type,
'mode': mode.toString(),
'random': (random ? 1 : 0).toString(),
if (tag != null) 'tag': tag,
if (older != null) 'older': older.toString(),
},
);
final http.Response response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((item) => MediaItem.fromJson(item)).toList();
} else {
throw Exception('Fehler beim Abrufen der Medien: ${response.statusCode}');
}
}
Future<MediaItem> fetchMediaDetail(int itemId) async {
final Uri url = Uri.parse('https://api.f0ck.me/item/${itemId.toString()}');
final http.Response response = await http.get(url);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
return MediaItem.fromJson(jsonResponse);
} else {
throw Exception(
'Fehler beim Abrufen der Media-Details: ${response.statusCode}',
);
}
}
Future<List<Suggestion>> fetchSuggestions(String query) async {
final Uri uri = Uri.parse('https://api.f0ck.me/search/?q=$query');
try {
final http.Response response = await http
.get(uri)
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final dynamic decoded = jsonDecode(response.body);
if (decoded is List) {
final suggestions = decoded
.map((item) => Suggestion.fromJson(item as Map<String, dynamic>))
.toList();
suggestions.sort((a, b) => b.score.compareTo(a.score));
return suggestions;
} else {
throw Exception('Unerwartetes Format: Es wurde eine Liste erwartet.');
}
} else if (response.statusCode == 400) {
final dynamic error = jsonDecode(response.body);
final String message = error is Map<String, dynamic>
? error['detail']?.toString() ?? 'Unbekannter Fehler.'
: 'Unbekannter Fehler.';
throw Exception('Client-Fehler 400: $message');
} else {
throw Exception(
'Fehler beim Abrufen der Vorschläge: ${response.statusCode}',
);
}
} on TimeoutException {
throw Exception('Anfrage an die API hat zu lange gedauert.');
} catch (e) {
throw Exception('Fehler bei der Verarbeitung der Anfrage: $e');
}
}
Future<bool> login(String username, String password) async {
final Uri url = Uri.parse('https://api.f0ck.me/login');
final http.Response response = await http.post(
url,
body: {'username': username, 'password': password},
);
if (response.statusCode == 200) {
final dynamic data = jsonDecode(response.body);
final token = data['token'];
if (token != null) {
await storage.write(key: "token", value: token);
return true;
} else {
throw Exception('Token nicht im Response enthalten.');
}
} else {
throw Exception('Login fehlgeschlagen: ${response.statusCode}');
}
}

View File

@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/services/api_service.dart';
import 'package:f0ckapp/models/suggestion_model.dart';
import 'package:f0ckapp/providers/media_provider.dart';
class CustomSearchDelegate extends SearchDelegate<String> {
Timer? _debounceTimer;
List<Suggestion>? _suggestions;
bool _isLoading = false;
String? _error;
String _lastFetchedQuery = "";
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
_clearResults();
showSuggestions(context);
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_debounceTimer?.cancel();
close(context, 'null');
},
);
}
@override
Widget buildResults(BuildContext context) {
return Center(child: Text('Suchergebnisse für: "$query"'));
}
@override
Widget buildSuggestions(BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setState) {
if (query.isEmpty) {
_debounceTimer?.cancel();
return Container(padding: const EdgeInsets.all(16.0), child: const Text(''));
}
if (query != _lastFetchedQuery) {
_debounceTimer?.cancel();
_isLoading = true;
_error = null;
_suggestions = null;
_debounceTimer = Timer(Duration(milliseconds: 500), () async {
try {
final List<Suggestion> results = await fetchSuggestions(query);
_lastFetchedQuery = query;
setState(() {
_suggestions = results;
_isLoading = false;
});
} catch (e) {
_lastFetchedQuery = query;
setState(() {
_error = e.toString();
_suggestions = [];
_isLoading = false;
});
}
});
return Center(child: _buildLoadingIndicator());
}
if (_isLoading) {
return Center(child: _buildLoadingIndicator());
}
if (_error != null) {
return Center(child: Text("Fehler: $_error"));
}
if (_suggestions == null || _suggestions!.isEmpty) {
return Center(child: const Text("Keine Ergebnisse gefunden."));
}
return Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
return ListView.builder(
itemCount: _suggestions!.length,
itemBuilder: (BuildContext context, int index) {
final Suggestion suggestion = _suggestions![index];
return ListTile(
title: Text(suggestion.tag),
subtitle: Text(
'Getaggt: ${suggestion.tagged}x • Score: ${suggestion.score.toStringAsFixed(2)}',
style: TextStyle(fontSize: 12),
),
onTap: () {
ref.read(mediaProvider.notifier).setTag(suggestion.tag);
close(context, suggestion.tag);
},
);
},
);
},
);
},
);
}
Widget _buildLoadingIndicator() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(strokeWidth: 3.0),
const SizedBox(height: 12),
const Text(
'Vorschläge werden geladen...',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
);
}
void _clearResults() {
_debounceTimer?.cancel();
_suggestions = null;
_isLoading = false;
_error = null;
_lastFetchedQuery = "";
}
@override
void close(BuildContext context, String result) {
_debounceTimer?.cancel();
super.close(context, result);
}
}

View File

@ -1,141 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/models/MediaItem.dart';
import 'package:f0ckapp/widgets/VideoOverlay.dart';
import 'package:f0ckapp/providers/MediaProvider.dart';
class VideoWidget extends ConsumerStatefulWidget {
final MediaItem details;
final bool isActive;
const VideoWidget({super.key, required this.details, required this.isActive});
@override
ConsumerState<VideoWidget> createState() => _VideoWidgetState();
}
class _VideoWidgetState extends ConsumerState<VideoWidget> {
late CachedVideoPlayerPlusController _controller;
bool _showControls = false;
Timer? _hideControlsTimer;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
_controller = CachedVideoPlayerPlusController.networkUrl(
Uri.parse(widget.details.mediaUrl),
);
await _controller.initialize();
setState(() {});
_controller.addListener(() => setState(() {}));
if (widget.isActive) {
_controller.play();
}
_controller.setLooping(true);
final muted = ref.read(mediaProvider).muted;
_controller.setVolume(muted ? 0.0 : 1.0);
}
@override
void didUpdateWidget(covariant VideoWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive != oldWidget.isActive) {
if (widget.isActive) {
_controller.play();
} else {
_controller.pause();
}
}
}
@override
void dispose() {
_controller.dispose();
_hideControlsTimer?.cancel();
super.dispose();
}
void _onTap({bool ctrlButton = false}) {
if (!ctrlButton) {
setState(() => _showControls = !_showControls);
}
if (_showControls) {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 2), () {
setState(() => _showControls = false);
});
}
}
@override
Widget build(BuildContext context) {
final muted = ref.watch(mediaProvider).muted;
if (_controller.value.isInitialized &&
_controller.value.volume != (muted ? 0.0 : 1.0)) {
_controller.setVolume(muted ? 0.0 : 1.0);
}
bool isAudio = widget.details.mime.startsWith('audio');
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: _controller.value.isInitialized
? _controller.value.aspectRatio
: 9 / 16,
child: Stack(
alignment: Alignment.topCenter,
children: [
GestureDetector(
onTap: _onTap,
child: isAudio
? CachedNetworkImage(
imageUrl: widget.details.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset(
'assets/images/music.webp',
fit: BoxFit.contain,
width: double.infinity,
),
)
: _controller.value.isInitialized
? CachedVideoPlayerPlus(_controller)
: const Center(child: CircularProgressIndicator()),
),
if (_controller.value.isInitialized && _showControls) ...[
IgnorePointer(
ignoring: true,
child: Container(
color: Colors.black.withValues(alpha: 0.5),
width: double.infinity,
height: double.infinity,
),
),
VideoControlsOverlay(
controller: _controller,
button: () => _onTap(ctrlButton: true),
),
],
],
),
),
],
);
}
}

132
lib/widgets/end_drawer.dart Normal file
View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/providers/theme_provider.dart';
import 'package:f0ckapp/utils/appversion_util.dart';
class EndDrawer extends StatelessWidget {
final WidgetRef ref;
const EndDrawer({super.key, required this.ref});
void _showMsg(String message, BuildContext context) {
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/menu.webp'),
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
child: null,
),
/*ExpansionTile(
title: const Text('Login'),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
readOnly: true,
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Benutzername',
),
),
const SizedBox(height: 10),
TextField(
readOnly: true,
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("noch nicht implementiert lol"),
),
final success = await login(
_usernameController.text,
_passwordController.text,
);
if (success) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Login fehlgeschlagen!")),
);
}
);
},
child: const Text('Login'),
),
],
),
),
],
),*/
ExpansionTile(
title: const Text('Theme'),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: themeMap.entries.map((entry) {
final String themeName = entry.key;
final ThemeData themeData = entry.value;
final ThemeData currentTheme = ref.watch(
themeNotifierProvider,
);
final bool isSelected = currentTheme == themeData;
return ListTile(
title: Text(themeName),
selected: isSelected,
selectedTileColor: Colors.blue.withValues(alpha: 0.2),
onTap: () async {
await ref
.read(themeNotifierProvider.notifier)
.updateTheme(themeName);
},
);
}).toList(),
),
),
],
),
ListTile(
title: const Text('Einstellungen'),
onTap: () {
//context.go('/settings');
Navigator.pushReplacementNamed(context, '/settings');
},
),
ListTile(
title: Text('v${AppVersion.version}'),
onTap: () {
Navigator.pop(context);
_showMsg('jooong lass das, hier ist nichts', context);
},
),
],
),
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:f0ckapp/providers/media_provider.dart';
class FilterBar extends StatelessWidget {
final MediaState mediaState;
final MediaNotifier mediaNotifier;
final ScrollController scrollController;
const FilterBar({
super.key,
required this.mediaState,
required this.mediaNotifier,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
return BottomAppBar(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const Text('type: '),
DropdownButton<String>(
value: mediaTypes[mediaState.typeIndex],
isDense: true,
items: mediaTypes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
mediaNotifier.setType(newValue);
scrollController.jumpTo(0);
}
},
),
const Text('mode: '),
DropdownButton<String>(
value: mediaModes[mediaState.modeIndex],
isDense: true,
items: mediaModes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
mediaNotifier.setMode(mediaModes.indexOf(newValue));
scrollController.jumpTo(0);
}
},
),
],
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
class MediaTile extends StatelessWidget {
final MediaItem item;
const MediaTile({super.key, required this.item});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.pushNamed(context, '/${item.id}');
},
child: Stack(
fit: StackFit.expand,
children: [
Hero(
tag: 'media-${item.id}',
child: CachedNetworkImage(
imageUrl: item.thumbnailUrl,
fit: BoxFit.cover,
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.square,
color: switch (item.mode) {
1 => Colors.green,
2 => Colors.red,
_ => Colors.yellow,
},
size: 15.0,
),
),
],
),
);
}
}

View File

@ -0,0 +1,192 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/widgets/videooverlay_widget.dart';
import 'package:f0ckapp/providers/media_provider.dart';
class VideoWidget extends ConsumerStatefulWidget {
final MediaItem details;
final bool isActive;
final bool fullScreen;
const VideoWidget({
super.key,
required this.details,
required this.isActive,
this.fullScreen = false,
});
@override
ConsumerState<VideoWidget> createState() => _VideoWidgetState();
}
class _VideoWidgetState extends ConsumerState<VideoWidget> {
late CachedVideoPlayerPlusController _controller;
bool _showControls = false;
Timer? _hideControlsTimer;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
_controller = CachedVideoPlayerPlusController.networkUrl(
Uri.parse(widget.details.mediaUrl),
);
await _controller.initialize();
setState(() {});
_controller.addListener(() => setState(() {}));
if (widget.isActive) {
_controller.play();
}
_controller.setLooping(true);
final bool muted = ref.read(mediaProvider).muted;
_controller.setVolume(muted ? 0.0 : 1.0);
}
@override
void didUpdateWidget(covariant VideoWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive != oldWidget.isActive) {
if (widget.isActive) {
_controller.play();
} else {
_controller.pause();
}
}
}
@override
void dispose() {
_controller.dispose();
_hideControlsTimer?.cancel();
super.dispose();
}
void _onTap({bool ctrlButton = false}) {
if (!ctrlButton) {
setState(() => _showControls = !_showControls);
}
if (_showControls) {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 2), () {
setState(() => _showControls = false);
});
}
}
@override
Widget build(BuildContext context) {
final bool muted = ref.watch(mediaProvider).muted;
if (_controller.value.isInitialized &&
_controller.value.volume != (muted ? 0.0 : 1.0)) {
_controller.setVolume(muted ? 0.0 : 1.0);
}
bool isAudio = widget.details.mime.startsWith('audio');
if (widget.fullScreen) {
return Stack(
children: [
Center(
child: AspectRatio(
aspectRatio: _controller.value.isInitialized
? _controller.value.aspectRatio
: 9 / 16,
child: GestureDetector(
onTap: _onTap,
child: isAudio
? CachedNetworkImage(
imageUrl: widget.details.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset(
'assets/images/music.webp',
fit: BoxFit.contain,
width: double.infinity,
),
)
: _controller.value.isInitialized
? CachedVideoPlayerPlus(_controller)
: const Center(child: CircularProgressIndicator()),
),
),
),
if (_controller.value.isInitialized && _showControls)
Positioned.fill(
child: GestureDetector(
onTap: _onTap,
child: Container(
color: Colors.black.withValues(alpha: 0.5),
child: VideoControlsOverlay(
controller: _controller,
button: () => _onTap(ctrlButton: true),
),
),
),
),
],
);
} else {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: _controller.value.isInitialized
? _controller.value.aspectRatio
: 9 / 16,
child: Stack(
alignment: Alignment.topCenter,
children: [
GestureDetector(
onTap: _onTap,
child: isAudio
? CachedNetworkImage(
imageUrl: widget.details.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset(
'assets/images/music.webp',
fit: BoxFit.contain,
width: double.infinity,
),
)
: _controller.value.isInitialized
? CachedVideoPlayerPlus(_controller)
: const Center(child: CircularProgressIndicator()),
),
if (_controller.value.isInitialized && _showControls) ...[
IgnorePointer(
ignoring: true,
child: Container(
color: Colors.black.withValues(alpha: 0.5),
width: double.infinity,
height: double.infinity,
),
),
VideoControlsOverlay(
controller: _controller,
button: () => _onTap(ctrlButton: true),
),
],
],
),
),
],
);
}
}
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart'; import 'package:cached_video_player_plus/cached_video_player_plus.dart';
import 'package:f0ckapp/providers/MediaProvider.dart'; import 'package:f0ckapp/providers/media_provider.dart';
class VideoControlsOverlay extends ConsumerWidget { class VideoControlsOverlay extends ConsumerWidget {
final CachedVideoPlayerPlusController controller; final CachedVideoPlayerPlusController controller;
@ -17,15 +17,15 @@ class VideoControlsOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaState = ref.watch(mediaProvider); final MediaState mediaState = ref.watch(mediaProvider);
final mediaNotifier = ref.read(mediaProvider.notifier); final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Positioned( Positioned(
right: 12, right: 12,
top: 12, bottom: 12,
child: _ControlButton( child: _ControlButton(
mediaState.muted ? Icons.volume_off : Icons.volume_up, mediaState.muted ? Icons.volume_off : Icons.volume_up,
() { () {
@ -35,13 +35,6 @@ class VideoControlsOverlay extends ConsumerWidget {
size: 16, size: 16,
), ),
), ),
Positioned(
right: 12,
bottom: 12,
child: _ControlButton(Icons.fullscreen, () {
button();
}, size: 16),
),
Center( Center(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -89,7 +82,6 @@ class VideoControlsOverlay extends ConsumerWidget {
bottom: 12, bottom: 12,
child: Text( child: Text(
'${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}',
style: TextStyle(color: Colors.white),
), ),
), ),
Listener( Listener(
@ -153,7 +145,7 @@ class _ControlButton extends StatelessWidget {
color: Colors.black.withValues(alpha: 0.4), color: Colors.black.withValues(alpha: 0.4),
), ),
child: IconButton( child: IconButton(
icon: Icon(icon, color: Colors.white, size: size), icon: Icon(icon, size: size),
onPressed: onPressed, onPressed: onPressed,
), ),
); );

View File

@ -1,5 +1,5 @@
name: f0ckapp name: f0ckapp
description: "A new Flutter project." description: "f0ck schm0ck"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.5+35 version: 1.1.23+53
environment: environment:
sdk: ^3.9.0-100.2.beta sdk: ^3.9.0-100.2.beta

View File

@ -13,7 +13,7 @@ import 'package:f0ckapp/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const F0ckApp()); await tester.pumpWidget(F0ckApp());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);