Compare commits

...

34 Commits

Author SHA1 Message Date
5e8983e347 v1.4.9+70
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-24 14:19:41 +02:00
93a89ba4b9 .. 2025-06-24 13:05:08 +02:00
ba7505c2b3 v1.4.8+69
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-24 03:02:39 +02:00
39fadc009f ... 2025-06-24 02:21:06 +02:00
0d42fad708 v1.4.7+68
All checks were successful
Flutter Schmutter / build (push) Successful in 3m37s
2025-06-23 16:32:15 +02:00
405d388db0 v1.4.6+67
All checks were successful
Flutter Schmutter / build (push) Successful in 3m46s
2025-06-23 02:51:49 +02:00
e30635304b ... 2025-06-22 17:24:43 +02:00
7a1f76ee85 v1.4.5+66
All checks were successful
Flutter Schmutter / build (push) Successful in 3m34s
2025-06-22 03:40:13 +02:00
95f6dcfe2b v1.4.4+65
All checks were successful
Flutter Schmutter / build (push) Successful in 3m35s
2025-06-22 03:02:18 +02:00
7f0743808a logo schmogo 2025-06-21 18:50:00 +02:00
840395bb69 v1.4.3+64
All checks were successful
Flutter Schmutter / build (push) Successful in 3m33s
2025-06-21 16:48:28 +02:00
7a88c23e57 v1.4.2+63
All checks were successful
Flutter Schmutter / build (push) Successful in 3m51s
2025-06-21 16:28:57 +02:00
73a44bb269 v1.4.1+62
All checks were successful
Flutter Schmutter / build (push) Successful in 3m48s
2025-06-21 13:40:44 +02:00
2b5aaad331 v1.4.0+61
All checks were successful
Flutter Schmutter / build (push) Successful in 3m48s
2025-06-19 21:45:00 +02:00
0d792fdf46 v1.3.4+60
All checks were successful
Flutter Schmutter / build (push) Successful in 5m30s
2025-06-18 04:48:19 +02:00
ee2db04a36 v1.3.3+59
All checks were successful
Flutter Schmutter / build (push) Successful in 3m47s
2025-06-17 19:03:40 +02:00
089fe1f8df v1.3.2+58
All checks were successful
Flutter Schmutter / build (push) Successful in 3m50s
2025-06-16 19:10:00 +02:00
e9107a7f62 v1.3.1+57
All checks were successful
Flutter Schmutter / build (push) Successful in 3m52s
- oops xd
2025-06-16 15:12:28 +02:00
14081489cc v1.3.0+56
All checks were successful
Flutter Schmutter / build (push) Successful in 3m54s
2025-06-16 15:05:39 +02:00
2a500144f5 v1.2.1+55
All checks were successful
Flutter Schmutter / build (push) Successful in 3m42s
- fix deeplink
- add mute button
2025-06-13 15:01:48 +02:00
9655f15927 v1.2.0+54
All checks were successful
Flutter Schmutter / build (push) Successful in 3m37s
- screaming_possum.gif
2025-06-13 13:55:05 +02:00
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
54 changed files with 3305 additions and 1637 deletions

View File

@ -3,7 +3,7 @@ name: Flutter Schmutter
on:
push:
tags:
- 'v*'
- '*'
jobs:
build:
@ -41,7 +41,7 @@ jobs:
TAR_OPTIONS: --no-same-owner
- name: build apk
run: flutter build apk --release
run: flutter build apk --release --split-per-abi
- name: release-build
uses: akkuman/gitea-release-action@v1
@ -49,5 +49,15 @@ jobs:
NODE_OPTIONS: '--experimental-fetch'
with:
files: |-
build/app/outputs/flutter-apk/app-release.apk
build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
build/app/outputs/flutter-apk/app-x86_64-release.apk
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-arm64-v8a-release.apk" \
-F "build=$BUILD_NUMBER"

View File

@ -4,8 +4,8 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "8b18dde77fa59ba7f87540c05d1aba787198e77a"
channel: "master"
revision: "01fde956f0d13551843a44ae16eda7ca87478603"
channel: "beta"
project_type: app
@ -13,27 +13,8 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: android
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: ios
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: linux
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: macos
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: web
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
- platform: windows
create_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
base_revision: 8b18dde77fa59ba7f87540c05d1aba787198e77a
create_revision: 01fde956f0d13551843a44ae16eda7ca87478603
base_revision: 01fde956f0d13551843a44ae16eda7ca87478603
# User provided section
# List of Local paths (relative to this file) that should be

View File

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

View File

@ -1,10 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="f0ckapp"
android:label="f0ck"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
@ -16,8 +15,7 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:requestLegacyExternalStorage="true">
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@ -30,19 +28,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data android:name="flutter_deeplinking_enabled" android:value="false"/>
<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>
<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="f0ck" android:host="com.f0ck.f0ckapp"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,5 +1,69 @@
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.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
}
}

11
assets/i18n/de_DE.json Normal file
View File

@ -0,0 +1,11 @@
{
"settings_title": "Einstellungen",
"settings_language": "Sprache",
"settings_drawer_title": "Drawer per Geste öffnen",
"settings_drawer_subtitle": "Wähle, ob der Drawer mit einer Wischgeste geschlossen/geöffnet werden kann.",
"settings_numberofcolumns_title": "Spaltenanzahl",
"settings_numberofcolumns_columns": "@count Spalten",
"settings_pageanimation_title": "Seitenwechselanimation",
"settings_cache_title": "Cache leeren",
"settings_cache_clear_button": "Leeren"
}

11
assets/i18n/en_US.json Normal file
View File

@ -0,0 +1,11 @@
{
"settings_title": "Settings",
"settings_language": "Language",
"settings_drawer_title": "Open drawer with gesture",
"settings_drawer_subtitle": "Choose whether the drawer can be closed/opened with a swipe gesture.",
"settings_numberofcolumns_title": "Number of columns",
"settings_numberofcolumns_columns": "@count columns",
"settings_pageanimation_title": "Page change animation",
"settings_cache_title": "Clear Cache",
"settings_cache_clear_button": "Clear"
}

11
assets/i18n/fr_FR.json Normal file
View File

@ -0,0 +1,11 @@
{
"settings_title": "Paramètres",
"settings_language": "Langue",
"settings_drawer_title": "Ouvrir le tiroir avec un geste",
"settings_drawer_subtitle": "Choisissez si le tiroir peut être ouvert/fermé avec un geste de balayage.",
"settings_numberofcolumns_title": "Nombre de colonnes",
"settings_numberofcolumns_columns": "@count colonnes",
"settings_pageanimation_title": "Animation de changement de page",
"settings_cache_title": "Vider le cache",
"settings_cache_clear_button": "Vider"
}

11
assets/i18n/nl_NL.json Normal file
View File

@ -0,0 +1,11 @@
{
"settings_title": "Instellingen",
"settings_language": "Taal",
"settings_drawer_title": "Lade openen met een gebaar",
"settings_drawer_subtitle": "Kies of de lade geopend/gesloten kan worden met een veeggebaar.",
"settings_numberofcolumns_title": "Aantal kolommen",
"settings_numberofcolumns_columns": "@count kolommen",
"settings_pageanimation_title": "Pagina-overgangsanimatie",
"settings_cache_title": "Cache wissen",
"settings_cache_clear_button": "Wissen"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

View File

@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:encrypt_shared_preferences/provider.dart';
import 'package:f0ckapp/models/user.dart';
class AuthController extends GetxController {
final EncryptedSharedPreferencesAsync storage =
EncryptedSharedPreferencesAsync.getInstance();
final GetConnect http = GetConnect();
RxnString token = RxnString();
Rxn<User> user = Rxn<User>();
RxBool isLoading = false.obs;
RxnString error = RxnString();
@override
void onInit() {
super.onInit();
loadToken();
}
Future<void> loadToken() async {
token.value = await storage.getString('token');
if (token.value != null) {
await fetchUserInfo();
}
}
Future<void> saveToken(String newToken) async {
token.value = newToken;
await storage.setString('token', newToken);
await fetchUserInfo();
}
Future<void> logout() async {
if (token.value != null) {
try {
await http.post(
'https://api.f0ck.me/logout',
{},
headers: {
'Authorization': 'Bearer ${token.value}',
'Content-Type': 'application/json',
},
);
} catch (_) {}
}
token.value = null;
user.value = null;
await storage.remove('token');
}
Future<bool> login(String username, String password) async {
isLoading.value = true;
error.value = null;
try {
final Response<dynamic> response = await http.post(
'https://api.f0ck.me/login',
json.encode({'username': username, 'password': password}),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final dynamic data = response.body is String
? json.decode(response.body)
: response.body;
if (data['token'] != null) {
await saveToken(data['token']);
user.value = User.fromJson(data);
return true;
} else {
error.value = 'Kein Token erhalten';
}
} else {
error.value = 'Login fehlgeschlagen';
}
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
return false;
}
Future<void> fetchUserInfo() async {
if (token.value == null) return;
try {
final Response<dynamic> response = await http.get(
'https://api.f0ck.me/login/check',
headers: {'Authorization': 'Bearer ${token.value}'},
);
if (response.statusCode == 200) {
final dynamic data = response.body is String
? json.decode(response.body)
: response.body;
user.value = User.fromJson(data);
} else {
await logout();
}
} catch (_) {
await logout();
}
}
bool get isLoggedIn => token.value != null && token.value!.isNotEmpty;
}

View File

@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:encrypt_shared_preferences/provider.dart';
const Map<String, Locale> supportedLocales = {
'en_US': Locale('en', 'US'),
'de_DE': Locale('de', 'DE'),
'fr_FR': Locale('fr', 'FR'),
'nl_NL': Locale('nl', 'NL'),
};
class MyTranslations extends Translations {
static final MyTranslations instance = MyTranslations._internal();
MyTranslations._internal();
static final Map<String, Map<String, String>> _translations = {};
static Future<void> loadTranslations() async {
for (final localeKey in supportedLocales.keys) {
try {
final String jsonString = await rootBundle.loadString(
'assets/i18n/$localeKey.json',
);
final Map<String, dynamic> jsonMap = json.decode(jsonString);
_translations[localeKey] = jsonMap.map(
(key, value) => MapEntry(key, value.toString()),
);
} catch (e) {
debugPrint('Konnte Übersetzung für $localeKey nicht laden: $e');
}
}
}
@override
Map<String, Map<String, String>> get keys => _translations;
}
class LocalizationController extends GetxController {
final EncryptedSharedPreferencesAsync storage =
EncryptedSharedPreferencesAsync.getInstance();
Rx<Locale> currentLocale = supportedLocales['en_US']!.obs;
@override
void onInit() {
super.onInit();
loadLocale();
}
Future<void> loadLocale() async {
String? savedLocaleKey = await storage.getString(
'locale',
defaultValue: 'en_US',
);
final Locale locale =
supportedLocales[savedLocaleKey ?? 'en_US'] ??
supportedLocales['en_US']!;
currentLocale.value = locale;
Get.locale = locale;
}
Future<void> changeLocale(Locale newLocale) async {
final String localeKey = supportedLocales.entries
.firstWhere(
(entry) => entry.value == newLocale,
orElse: () => supportedLocales.entries.first,
)
.key;
currentLocale.value = newLocale;
Get.updateLocale(newLocale);
await storage.setString('locale', localeKey);
}
}

View File

@ -0,0 +1,133 @@
import 'package:get/get.dart';
import 'package:f0ckapp/models/feed.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/services/api.dart';
const List<String> mediaTypes = ["alles", "image", "video", "audio"];
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
class MediaController extends GetxController {
final ApiService _api = Get.find<ApiService>();
RxList<MediaItem> items = <MediaItem>[].obs;
RxBool loading = false.obs;
RxBool atEnd = false.obs;
RxBool atStart = false.obs;
Rxn<String> errorMessage = Rxn<String>();
RxInt typeIndex = 0.obs;
RxInt modeIndex = 0.obs;
RxInt random = 0.obs;
Rxn<String> tag = Rxn<String>(null);
void setTypeIndex(int idx) {
typeIndex.value = idx;
}
void setModeIndex(int idx) {
modeIndex.value = idx;
}
void setTag(String? newTag, {bool reload = true}) {
tag.value = newTag;
if (reload) {
fetchInitial();
}
}
void toggleRandom() {
random.value = random.value == 0 ? 1 : 0;
}
Future<List<Favorite>?> toggleFavorite(
MediaItem item,
bool isFavorite,
) async {
try {
return await _api.toggleFavorite(item, isFavorite);
} catch (e) {
return [];
}
}
Future<Feed?> _fetchItems({int? older, int? newer}) async {
if (loading.value) return null;
loading.value = true;
errorMessage.value = null;
try {
return await _api.fetchItems(
older: older,
newer: newer,
type: typeIndex.value,
mode: modeIndex.value,
random: random.value,
tag: tag.value,
);
} catch (e) {
final String errorText =
'Die Daten konnten nicht abgerufen werden. Wo Internet?';
errorMessage.value = errorText;
Get.snackbar('Fehler beim Laden', errorText);
return null;
} finally {
loading.value = false;
}
}
Future<void> fetchInitial({int? id}) async {
final Feed? result = await _fetchItems(older: id);
if (result != null) {
items.assignAll(result.items);
atEnd.value = result.atEnd;
atStart.value = result.atStart;
}
}
Future<void> fetchMore() async {
if (items.isEmpty || atEnd.value) return;
final Feed? result = await _fetchItems(older: items.last.id);
if (result != null) {
final Set<int> existingIds = items.map((e) => e.id).toSet();
final List<MediaItem> newItems = result.items
.where((item) => !existingIds.contains(item.id))
.toList();
items.addAll(newItems);
items.refresh();
atEnd.value = result.atEnd;
}
}
Future<void> fetchNewer() async {
if (items.isEmpty || atStart.value) return;
final Feed? result = await _fetchItems(newer: items.first.id);
if (result != null) {
final Set<int> existingIds = items.map((e) => e.id).toSet();
final List<MediaItem> newItems = result.items
.where((item) => !existingIds.contains(item.id))
.toList();
items.insertAll(0, newItems);
items.refresh();
atStart.value = result.atStart;
}
return;
}
Future<void> handleRefresh() async {
if (loading.value) return;
if (!atStart.value) {
await fetchNewer();
} else {
await fetchInitial();
}
}
Future<void> handleLoading() async {
if (loading.value) return;
if (!loading.value && !atEnd.value) {
await fetchMore();
}
}
bool get isRandomEnabled => random.value == 1;
}

View File

@ -0,0 +1,75 @@
import 'package:encrypt_shared_preferences/provider.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/utils/animatedtransition.dart';
class _StorageKeys {
static const String muted = 'muted';
static const String crossAxisCount = 'crossAxisCount';
static const String drawerSwipeEnabled = 'drawerSwipeEnabled';
static const String transitionType = 'transitionType';
}
class SettingsController extends GetxController {
final EncryptedSharedPreferencesAsync storage =
EncryptedSharedPreferencesAsync.getInstance();
RxBool muted = false.obs;
Rx<PageTransition> transitionType = PageTransition.opacity.obs;
RxBool drawerSwipeEnabled = true.obs;
RxInt crossAxisCount = 0.obs;
@override
void onInit() {
super.onInit();
loadSettings();
}
void toggleMuted() {
muted.value = !muted.value;
saveSettings();
}
void setMuted(bool value) {
muted.value = value;
saveSettings();
}
Future<void> setTransitionType(PageTransition type) async {
transitionType.value = type;
await saveSettings();
}
Future<void> setCrossAxisCount(int value) async {
crossAxisCount.value = value;
await saveSettings();
}
Future<void> setDrawerSwipeEnabled(bool enabled) async {
drawerSwipeEnabled.value = enabled;
await saveSettings();
}
Future<void> loadSettings() async {
muted.value = await storage.getBoolean(_StorageKeys.muted) ?? false;
crossAxisCount.value =
await storage.getInt(_StorageKeys.crossAxisCount) ?? 0;
drawerSwipeEnabled.value =
await storage.getBoolean(_StorageKeys.drawerSwipeEnabled) ?? true;
transitionType.value = PageTransition
.values[await storage.getInt(_StorageKeys.transitionType) ?? 0];
}
Future<void> saveSettings() async {
await storage.setBoolean(_StorageKeys.muted, muted.value);
await storage.setInt(_StorageKeys.crossAxisCount, crossAxisCount.value);
await storage.setBoolean(
_StorageKeys.drawerSwipeEnabled,
drawerSwipeEnabled.value,
);
await storage.setInt(
_StorageKeys.transitionType,
transitionType.value.index,
);
}
}

View File

@ -1,57 +1,7 @@
import 'package:flutter/material.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),
),
);
});
import 'package:get/get.dart';
import 'package:encrypt_shared_preferences/provider.dart';
final ThemeData f0ckTheme = ThemeData(
brightness: Brightness.dark,
@ -232,10 +182,10 @@ final ThemeData f0ck95Theme = ThemeData(
onPrimary: Colors.black,
onSecondary: Colors.white,
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFC0C0C0),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFFE0E0E0),
foregroundColor: Colors.black,
elevation: 2,
elevation: 4,
),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.black),
@ -284,3 +234,55 @@ final ThemeData f0ck95dTheme = ThemeData(
trackColor: WidgetStateProperty.all<Color>(const Color(0xFF424242)),
),
);
class ThemeController extends GetxController {
final EncryptedSharedPreferencesAsync storage =
EncryptedSharedPreferencesAsync.getInstance();
final Rx<ThemeData> currentTheme = f0ckTheme.obs;
final Map<String, ThemeData> themeMap = {
'f0ck': f0ckTheme,
'P1nk': p1nkTheme,
'Orange': orangeTheme,
'Amoled': amoledTheme,
'Paper': paperTheme,
'f0ck95': f0ck95Theme,
'f0ck95d': f0ck95dTheme,
};
@override
void onInit() {
super.onInit();
_loadTheme();
}
Future<void> _loadTheme() async {
try {
final String? savedThemeName = await storage.getString('theme');
if (savedThemeName != null && themeMap.containsKey(savedThemeName)) {
currentTheme.value = themeMap[savedThemeName]!;
Get.changeTheme(currentTheme.value);
}
} catch (error) {
Get.snackbar('', 'Fehler beim Laden des Themes: $error');
currentTheme.value = f0ckTheme;
Get.changeTheme(f0ckTheme);
}
}
Future<void> updateTheme(String themeName) async {
try {
await storage.setString('theme', themeName);
if (themeMap.containsKey(themeName)) {
currentTheme.value = themeMap[themeName]!;
Get.changeTheme(currentTheme.value);
} else {
currentTheme.value = f0ckTheme;
Get.changeTheme(f0ckTheme);
}
} catch (error) {
Get.snackbar('', 'Fehler beim Aktualisieren des Themes: $error');
}
}
}

View File

@ -1,89 +1,76 @@
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:encrypt_shared_preferences/provider.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/screens/mediagrid_screen.dart';
import 'package:f0ckapp/screens/detailview_screen.dart';
import 'package:f0ckapp/utils/appversion_util.dart';
import 'package:f0ckapp/providers/theme_provider.dart';
import 'package:f0ckapp/services/api.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
import 'package:f0ckapp/controller/localizationcontroller.dart';
import 'package:f0ckapp/controller/themecontroller.dart';
import 'package:f0ckapp/screens/mediadetail.dart';
import 'package:f0ckapp/utils/appversion.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
import 'package:f0ckapp/screens/mediagrid.dart';
import 'package:f0ckapp/screens/login.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await FlutterDownloader.initialize();
await AppVersion.init();
runApp(ProviderScope(child: F0ckApp()));
}
await Future.wait([
EncryptedSharedPreferencesAsync.initialize('VokTnbAbemBUa2j9'),
MyTranslations.loadTranslations(),
AppVersion.init(),
]);
class F0ckApp extends ConsumerWidget {
F0ckApp({super.key});
Get.put(AuthController());
Get.put(ApiService());
Get.put(SettingsController());
Get.put(MediaController());
final GoRouter _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MediaGrid();
},
),
GoRoute(
path: '/:rest(.*)',
builder: (context, state) {
final bool isInternalLink = (state.extra is bool && state.extra == true);
final String fullPath = state.matchedLocation;
final regExp = RegExp(
r'^(?:/tag/(?<tag>.+?))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$',
);
final RegExpMatch? match = regExp.firstMatch(fullPath);
if (match == null) {
return const Scaffold(body: Center(child: Text('Ungültiger Link')));
}
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;
return Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
if (!isInternalLink) {
mediaNotifier.setType(mime ?? "alles");
mediaNotifier.setTag(tag);
}
if (itemId != null) {
await mediaNotifier.loadMedia(id: itemId + preloadOffset);
}
});
if (itemId != null) {
return DetailView(initialItemId: itemId);
} else {
return MediaGrid();
}
},
);
},
),
],
final ThemeController themeController = Get.put(ThemeController());
final LocalizationController localizationController = Get.put(
LocalizationController(),
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final ThemeData theme = ref.watch(themeNotifierProvider);
return MaterialApp.router(
Get.addTranslations(MyTranslations.instance.keys);
Get.locale = localizationController.currentLocale.value;
//Locale systemLocale = WidgetsBinding.instance.platformDispatcher.locale;
runApp(
Obx(
() => MaterialApp(
locale: Get.locale,
navigatorKey: Get.key,
theme: themeController.currentTheme.value,
debugShowCheckedModeBanner: false,
routerConfig: _router,
theme: theme,
);
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
final Uri uri = Uri.parse(settings.name ?? '/');
if (uri.path == '/' || uri.pathSegments.isEmpty) {
return MaterialPageRoute(builder: (_) => const MediaGrid());
}
if (uri.path == '/login') {
return MaterialPageRoute(builder: (_) => LoginScreen());
}
if (uri.pathSegments.length == 1) {
try {
final int id = int.parse(uri.pathSegments.first);
return MaterialPageRoute(
builder: (_) => MediaDetailScreen(initialId: id),
);
} catch (e) {
return MaterialPageRoute(builder: (_) => const MediaGrid());
}
}
return MaterialPageRoute(builder: (_) => const MediaGrid());
},
),
),
);
}

17
lib/models/feed.dart Normal file
View File

@ -0,0 +1,17 @@
import 'package:f0ckapp/models/item.dart';
class Feed {
final bool atEnd;
final bool atStart;
final List<MediaItem> items;
Feed({required this.atEnd, required this.atStart, required this.items});
factory Feed.fromJson(Map<String, dynamic> json) {
return Feed(
atEnd: json['atEnd'] ?? false,
atStart: json['atStart'] ?? false,
items: (json['items'] as List).map((e) => MediaItem.fromJson(e)).toList(),
);
}
}

122
lib/models/item.dart Normal file
View File

@ -0,0 +1,122 @@
class MediaItem {
final int id;
final String mime;
final int size;
final int stamp;
final String dest;
final int mode;
final List<Tag>? tags;
final List<Favorite>? favorites;
final String? username;
final String? userchannel;
final String? usernetwork;
MediaItem({
required this.id,
required this.mime,
required this.size,
required this.stamp,
required this.dest,
required this.mode,
this.tags = const [],
this.favorites = const [],
this.username,
this.userchannel,
this.usernetwork,
});
String get thumbnailUrl => 'https://f0ck.me/t/$id.webp';
String get mediaUrl => 'https://f0ck.me/b/$dest';
String get coverUrl => 'https://f0ck.me/ca/$id.webp';
String get postUrl => 'https://f0ck.me/$id';
MediaItem copyWith({
int? id,
String? mime,
int? size,
int? stamp,
String? dest,
int? mode,
List<Tag>? tags,
List<Favorite>? favorites,
String? username,
String? userchannel,
String? usernetwork,
}) {
return MediaItem(
id: id ?? this.id,
mime: mime ?? this.mime,
size: size ?? this.size,
stamp: stamp ?? this.stamp,
dest: dest ?? this.dest,
mode: mode ?? this.mode,
tags: tags ?? this.tags,
favorites: favorites ?? this.favorites,
username: username ?? this.username,
userchannel: userchannel ?? this.userchannel,
usernetwork: usernetwork ?? this.usernetwork,
);
}
factory MediaItem.fromJson(Map<String, dynamic> json) {
return MediaItem(
id: json['id'],
mime: json['mime'],
size: json['size'],
stamp: json['stamp'],
dest: json['dest'],
mode: json['mode'],
tags:
(json['tags'] as List<dynamic>?)
?.map((e) => Tag.fromJson(e))
.toList() ??
[],
favorites:
(json['favorites'] as List<dynamic>?)
?.map((e) => Favorite.fromJson(e))
.toList() ??
[],
username: json['username'],
userchannel: json['userchannel'],
usernetwork: json['usernetwork'],
);
}
}
class Tag {
final int id;
final String tag;
final String normalized;
Tag({required this.id, required this.tag, required this.normalized});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(
id: json['id'],
tag: json['tag'],
normalized: json['normalized'],
);
}
}
class Favorite {
final int userId;
final String username;
final int avatar;
Favorite({
required this.userId,
required this.username,
required this.avatar,
});
factory Favorite.fromJson(Map<String, dynamic> json) {
return Favorite(
userId: json['userId'],
username: json['username'],
avatar: json['avatar'],
);
}
String get avatarUrl => 'https://f0ck.me/t/$avatar.webp';
}

View File

@ -1,54 +0,0 @@
class MediaItem {
final int id;
final String mime;
final int size;
final int stamp;
final String dest;
final int mode;
final List<Tag> tags;
MediaItem({
required this.id,
required this.mime,
required this.size,
required this.stamp,
required this.dest,
required this.mode,
required this.tags,
});
factory MediaItem.fromJson(Map<String, dynamic> json) {
return MediaItem(
id: json['id'],
mime: json['mime'],
size: json['size'],
stamp: json['stamp'],
dest: json['dest'],
mode: json['mode'],
tags: (json['tags'] as List<dynamic>)
.map((tagJson) => Tag.fromJson(tagJson))
.toList(),
);
}
String get thumbnailUrl => 'https://f0ck.me/t/$id.webp';
String get mediaUrl => 'https://f0ck.me/b/$dest';
String get coverUrl => 'https://f0ck.me/ca/$id.webp';
String get postUrl => 'https://f0ck.me/$id';
}
class Tag {
final int id;
final String tag;
final String normalized;
Tag({required this.id, required this.tag, required this.normalized});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(
id: json['id'],
tag: json['tag'],
normalized: json['normalized'],
);
}
}

17
lib/models/user.dart Normal file
View File

@ -0,0 +1,17 @@
class User {
final int id;
final String username;
final String? avatarUrl;
User({required this.id, required this.username, this.avatarUrl});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['userid'],
username: json['user'],
avatarUrl: json['avatar'] != null
? 'https://f0ck.me/t/${json['avatar']}.webp'
: null,
);
}
}

View File

@ -1,149 +0,0 @@
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);
}
}
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) {
addMediaItems(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(),
);

View File

@ -1,305 +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:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:path_provider/path_provider.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];
if (Platform.isAndroid || Platform.isIOS) {
PermissionStatus status = await Permission.storage.status;
if (!status.isGranted) {
status = await Permission.storage.request();
if (!status.isGranted) {
_showMsg("Speicherberechtigung wurde nicht erteilt.");
return;
}
}
}
String localPath;
if (Platform.isAndroid) {
final Directory? directory = await getExternalStorageDirectory();
localPath = "${directory!.path}/Download/fApp";
} else if (Platform.isIOS) {
final Directory directory = await getApplicationDocumentsDirectory();
localPath = directory.path;
} else {
final Directory directory = await getTemporaryDirectory();
localPath = directory.path;
}
final Directory savedDir = Directory(localPath);
if (!await savedDir.exists()) {
await savedDir.create(recursive: true);
}
try {
await FlutterDownloader.enqueue(
url: currentItem.mediaUrl,
savedDir: localPath,
fileName: currentItem.mediaUrl.split('/').last,
showNotification: true,
openFileFromNotification: true,
);
if (mounted) {
_showMsg('Download gestartet: ${currentItem.mediaUrl}');
}
} catch (e) {
_showMsg('Download fehlgeschlagen: $e');
}
}
@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(
appBar: AppBar(
centerTitle: true,
title: Text('f0ck #${mediaState.mediaItems[_currentIndex].id}'),
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
context.canPop() ? context.pop() : context.go('/', extra: true);
},
),
actions: [
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
_showMsg('fullscreen ist wip');
},
),
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),
),
],
),
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: () {
ref.read(mediaProvider.notifier).setTag(null);
context.go('/', extra: true);
},
),
),
]
: null,
);
}
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 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);
context.go('/', extra: 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

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/widgets/video_widget.dart';
class FullScreenMediaView extends StatefulWidget {
final MediaItem item;
final Duration? initialPosition;
const FullScreenMediaView({
super.key,
required this.item,
this.initialPosition,
});
@override
State createState() => _FullScreenMediaViewState();
}
class _FullScreenMediaViewState extends State<FullScreenMediaView> {
final GlobalKey<VideoWidgetState> _videoKey = GlobalKey<VideoWidgetState>();
@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();
}
void _popWithPosition() {
Duration? currentPosition;
if (widget.item.mime.startsWith('video') && _videoKey.currentState != null) {
currentPosition = _videoKey.currentState!.videoController.value.position;
}
Navigator.of(context).pop(currentPosition);
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (bool didPop, Object? result) async {
return _popWithPosition();
},
child: 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),
),
)
: Center(
child: VideoWidget(
key: _videoKey,
details: widget.item,
isActive: true,
fullScreen: true,
initialPosition: widget.initialPosition,
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _popWithPosition,
),
),
),
],
),
),
);
}
}

134
lib/screens/login.dart Normal file
View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final AuthController authController = Get.put(AuthController());
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
bool _isLoading = false;
void _showMsg(String message, {String title = ''}) {
Get
..closeAllSnackbars()
..snackbar(title, message, snackPosition: SnackPosition.BOTTOM);
}
@override
void dispose() {
usernameController.dispose();
passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => Get.back(),
),
const SizedBox(width: 8),
Text(
'Login',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
const SizedBox(height: 24),
TextField(
controller: usernameController,
decoration: const InputDecoration(
labelText: 'Benutzername',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
if (authController.error.value != null)
Text(
authController.error.value!,
style: const TextStyle(color: Colors.red),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading
? null
: () async {
setState(() => _isLoading = true);
final bool success = await authController.login(
usernameController.text,
passwordController.text,
);
setState(() => _isLoading = false);
if (!success) {
_showMsg(
'Login fehlgeschlagen!',
title: 'Fehler',
);
} else {
Get.offAllNamed('/');
_showMsg(
'Erfolgreich eingeloggt.',
title: 'Login',
);
}
},
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Login'),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,526 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/file.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:get/get.dart';
import 'package:pullex/pullex.dart';
import 'package:share_plus/share_plus.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:f0ckapp/services/api.dart';
import 'package:f0ckapp/widgets/tagfooter.dart';
import 'package:f0ckapp/utils/animatedtransition.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
import 'package:f0ckapp/widgets/favoritesection.dart';
import 'package:f0ckapp/screens/fullscreen.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/widgets/tagsection.dart';
import 'package:f0ckapp/widgets/video_widget.dart';
enum ShareAction { media, directLink, postLink }
class MediaDetailScreen extends StatefulWidget {
final int initialId;
const MediaDetailScreen({super.key, required this.initialId});
@override
State<MediaDetailScreen> createState() => _MediaDetailScreenState();
}
class _MediaDetailScreenState extends State<MediaDetailScreen> {
PageController? _pageController;
final MediaController mediaController = Get.find<MediaController>();
final SettingsController settingsController = Get.find<SettingsController>();
final AuthController authController = Get.find<AuthController>();
final RxInt _currentIndex = 0.obs;
final MethodChannel _mediaSaverChannel = const MethodChannel('MediaShit');
final Map<int, PullexRefreshController> _refreshControllers = {};
final Map<int, GlobalKey<VideoWidgetState>> _videoWidgetKeys = {};
bool _isLoading = true;
bool _itemNotFound = false;
final RxSet<int> _readyItemIds = <int>{}.obs;
final Rxn<int> _animatingFavoriteId = Rxn<int>();
final List<PopupMenuEntry<ShareAction>> _shareMenuItems = const [
PopupMenuItem(
value: ShareAction.media,
child: ListTile(leading: Icon(Icons.image), title: Text('Als Datei')),
),
PopupMenuItem(
value: ShareAction.directLink,
child: ListTile(leading: Icon(Icons.link), title: Text('Link zur Datei')),
),
PopupMenuItem(
value: ShareAction.postLink,
child: ListTile(
leading: Icon(Icons.article),
title: Text('Link zum f0ck'),
),
),
];
@override
void initState() {
super.initState();
timeago.setLocaleMessages('de', timeago.DeMessages());
_loadInitialItem();
}
Future<void> _loadInitialItem() async {
int initialIndex = mediaController.items.indexWhere(
(item) => item.id == widget.initialId,
);
if (initialIndex < 0) {
await mediaController.fetchInitial(id: widget.initialId + 20);
initialIndex = mediaController.items.indexWhere(
(item) => item.id == widget.initialId,
);
}
if (initialIndex < 0) {
if (mounted) {
setState(() {
_itemNotFound = true;
_isLoading = false;
});
}
return;
}
if (mounted) {
_currentIndex.value = initialIndex;
_pageController = PageController(initialPage: initialIndex);
if (mediaController.items[initialIndex].mime.startsWith('image/')) {
_readyItemIds.add(mediaController.items[initialIndex].id);
}
setState(() => _isLoading = false);
}
}
void _showMsg(String message) {
if (!mounted) return;
Get
..closeAllSnackbars()
..snackbar('hehe', message, snackPosition: SnackPosition.BOTTOM);
}
Future<void> _onRefresh(
int itemId,
PullexRefreshController controller,
) async {
if (mediaController.loading.value) {
controller.refreshCompleted();
return;
}
try {
final MediaItem item = await ApiService().fetchItemById(itemId);
final int index = mediaController.items.indexWhere(
(item) => item.id == itemId,
);
if (index != -1) {
mediaController.items[index] = item;
mediaController.items.refresh();
}
controller.refreshCompleted();
} catch (e) {
_showMsg('Fehler beim Aktualisieren: $e');
controller.refreshFailed();
}
}
void _onPageChanged(int idx) {
if (idx != _currentIndex.value) {
_currentIndex.value = idx;
final MediaItem item = mediaController.items[idx];
if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) {
_readyItemIds.add(item.id);
}
}
if (idx + 1 < mediaController.items.length) {
DefaultCacheManager().downloadFile(
mediaController.items[idx + 1].mediaUrl,
);
}
if (idx - 1 >= 0) {
DefaultCacheManager().downloadFile(
mediaController.items[idx - 1].mediaUrl,
);
}
if (idx >= mediaController.items.length - 2 &&
!mediaController.loading.value &&
!mediaController.atEnd.value) {
mediaController.fetchMore();
} else if (idx <= 1 &&
!mediaController.loading.value &&
!mediaController.atStart.value) {
mediaController.fetchNewer();
}
}
Future<void> _downloadMedia(MediaItem item) async {
try {
final File file = await DefaultCacheManager().getSingleFile(
item.mediaUrl,
);
final bool? success = await _mediaSaverChannel.invokeMethod<bool>(
'saveFile',
{'filePath': file.path, 'fileName': item.dest},
);
success == true
? _showMsg('${item.dest} wurde in Downloads/fApp neigespeichert.')
: _showMsg('${item.dest} konnte nicht heruntergeladen werden.');
} catch (e) {
_showMsg('Fehler beim Download: $e');
}
}
Future<void> _handleShareAction(ShareAction value, MediaItem item) async {
try {
if (value == ShareAction.media) {
final File file = await DefaultCacheManager().getSingleFile(
item.mediaUrl,
);
final Uint8List bytes = await file.readAsBytes();
final ShareParams params = ShareParams(
files: [XFile.fromData(bytes, mimeType: item.mime)],
);
await SharePlus.instance.share(params);
return;
}
final String textToShare;
switch (value) {
case ShareAction.directLink:
textToShare = item.mediaUrl;
break;
case ShareAction.postLink:
textToShare = item.postUrl;
break;
case ShareAction.media:
return;
}
await SharePlus.instance.share(ShareParams(text: textToShare));
} catch (e) {
_showMsg('Fehler beim Teilen: $e');
}
}
Future<void> _handleFullScreen(MediaItem currentItem) async {
if (currentItem.mime.startsWith('image')) {
Get.to(
() => FullScreenMediaView(item: currentItem),
fullscreenDialog: true,
);
return;
}
final GlobalKey<VideoWidgetState>? key = _videoWidgetKeys[currentItem.id];
final VideoWidgetState? videoState = key?.currentState;
if (videoState == null || !videoState.videoController.value.isInitialized) {
return;
}
final Duration position = videoState.videoController.value.position;
await videoState.videoController.pause();
final Duration? newPosition = await Get.to<Duration?>(
() => FullScreenMediaView(item: currentItem, initialPosition: position),
fullscreenDialog: true,
);
if (mounted && videoState.mounted) {
if (newPosition != null) {
await videoState.videoController.seekTo(newPosition);
}
await videoState.videoController.play();
}
}
@override
void dispose() {
_pageController?.dispose();
for (PullexRefreshController controller in _refreshControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _handleFavoriteToggle(MediaItem item, bool isFavorite) async {
if (!authController.isLoggedIn) return;
HapticFeedback.lightImpact();
final List<Favorite>? newFavorites = await mediaController.toggleFavorite(
item,
isFavorite,
);
final int index = mediaController.items.indexWhere((i) => i.id == item.id);
if (newFavorites != null && index != -1) {
mediaController.items[index] = item.copyWith(favorites: newFavorites);
mediaController.items.refresh();
}
_animatingFavoriteId.value = item.id;
Future.delayed(const Duration(milliseconds: 700), () {
if (_animatingFavoriteId.value == item.id) {
_animatingFavoriteId.value = null;
}
});
}
Widget _buildMedia(MediaItem item, bool isActive) {
Widget mediaWidget;
final bool isFavorite =
item.favorites?.any((f) => f.userId == authController.user.value?.id) ??
false;
if (item.mime.startsWith('image/')) {
mediaWidget = CachedNetworkImage(
imageUrl: item.mediaUrl,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (c, e, s) => const Icon(Icons.broken_image, size: 100),
);
} else if (item.mime.startsWith('video/') ||
item.mime.startsWith('audio/')) {
final GlobalKey<VideoWidgetState> key = _videoWidgetKeys.putIfAbsent(
item.id,
() => GlobalKey<VideoWidgetState>(),
);
mediaWidget = VideoWidget(
key: key,
details: item,
isActive: isActive,
onInitialized: () {
if (mounted && !_readyItemIds.contains(item.id)) {
_readyItemIds.add(item.id);
}
},
);
} else {
mediaWidget = const Icon(Icons.help_outline, size: 100);
}
return Hero(
tag: 'media_${item.id}',
child: Stack(
alignment: Alignment.center,
children: [
GestureDetector(
onDoubleTap: () => _handleFavoriteToggle(item, isFavorite),
child: mediaWidget,
),
Obx(() {
final showAnimation = _animatingFavoriteId.value == item.id;
return AnimatedOpacity(
opacity: showAnimation ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: AnimatedScale(
scale: showAnimation ? 1.0 : 0.5,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_outline,
color: Colors.red,
size: 100,
),
),
);
}),
],
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
title: Obx(() {
if (_isLoading) {
return Text('Lade f0ck #${widget.initialId}...');
}
if (_itemNotFound ||
mediaController.items.isEmpty ||
_currentIndex.value >= mediaController.items.length) {
return const Text('Fehler');
}
final MediaItem currentItem =
mediaController.items[_currentIndex.value];
return Text('f0ck #${currentItem.id}');
}),
actions: [
Obx(() {
final bool showActions =
!_isLoading &&
!_itemNotFound &&
mediaController.items.isNotEmpty &&
_currentIndex.value < mediaController.items.length;
if (!showActions) {
return const SizedBox.shrink();
}
final MediaItem currentItem =
mediaController.items[_currentIndex.value];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () => _handleFullScreen(currentItem),
),
IconButton(
icon: const Icon(Icons.download),
onPressed: () async => await _downloadMedia(currentItem),
),
PopupMenuButton<ShareAction>(
onSelected: (value) => _handleShareAction(value, currentItem),
itemBuilder: (context) => _shareMenuItems,
icon: const Icon(Icons.share),
),
],
);
}),
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(context).openEndDrawer(),
),
),
],
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_itemNotFound) {
return const Center(child: Text('f0ck nicht gefunden.'));
}
return Obx(() {
if (mediaController.items.isEmpty) {
return const Center(child: Text('Keine Items zum Anzeigen.'));
}
return PageView.builder(
controller: _pageController!,
itemCount: mediaController.items.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, index) {
if (index >= mediaController.items.length) {
return const SizedBox.shrink();
}
final MediaItem item = mediaController.items[index];
final PullexRefreshController refreshController = _refreshControllers
.putIfAbsent(item.id, () => PullexRefreshController());
return Obx(() {
final bool isReady = _readyItemIds.contains(item.id);
return PullexRefresh(
onRefresh: () => _onRefresh(item.id, refreshController),
header: const WaterDropHeader(),
controller: refreshController,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: AnimatedBuilder(
animation: _pageController!,
builder: (context, child) {
return buildAnimatedTransition(
context: context,
pageController: _pageController!,
index: index,
child: child!,
);
},
child: Obx(
() => _buildMedia(item, index == _currentIndex.value),
),
),
),
if (isReady)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TagSection(tags: item.tags ?? []),
Obx(() {
if (!authController.isLoggedIn) {
return const SizedBox.shrink();
}
final TextStyle? infoTextStyle = Theme.of(
context,
).textTheme.bodySmall;
return Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
children: [
FavoriteSection(item: item, index: index),
const SizedBox(height: 16),
Text(
"Dateigröße: ${(item.size / 1024).toStringAsFixed(1)} KB",
style: infoTextStyle,
),
Text(
"Typ: ${item.mime}",
style: infoTextStyle,
),
Text(
"ID: ${item.id}",
style: infoTextStyle,
),
Text(
"Hochgeladen: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(item.stamp * 1000), locale: 'de')}",
style: infoTextStyle,
),
],
),
);
}),
],
),
),
),
const SliverToBoxAdapter(
child: SafeArea(child: SizedBox.shrink()),
),
],
),
);
});
},
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
endDrawer: const EndDrawer(),
endDrawerEnableOpenDragGesture:
settingsController.drawerSwipeEnabled.value,
appBar: _buildAppBar(context),
body: _buildBody(context),
persistentFooterButtons: mediaController.tag.value != null
? [TagFooter()]
: null,
);
}
}

264
lib/screens/mediagrid.dart Normal file
View File

@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pullex/pullex.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/widgets/tagfooter.dart';
import 'package:f0ckapp/utils/customsearchdelegate.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
import 'package:f0ckapp/widgets/filter_bar.dart';
import 'package:f0ckapp/widgets/media_tile.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
class MediaGrid extends StatefulWidget {
const MediaGrid({super.key});
@override
State<MediaGrid> createState() => _MediaGrid();
}
class _MediaGrid extends State<MediaGrid> {
final ScrollController _scrollController = ScrollController();
final MediaController _mediaController = Get.put(MediaController());
final SettingsController _settingsController = Get.put(SettingsController());
final PullexRefreshController _refreshController = PullexRefreshController(
initialRefresh: false,
);
late final _MediaGridAppBar _appBar;
late final _MediaGridBody _body;
Worker? _filterWorker;
@override
void initState() {
super.initState();
_mediaController.fetchInitial();
_filterWorker = everAll(
[
_mediaController.typeIndex,
_mediaController.modeIndex,
_mediaController.tag,
_mediaController.random,
],
(_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshController.requestRefresh();
});
},
);
_appBar = _MediaGridAppBar(mediaController: _mediaController);
_body = _MediaGridBody(
refreshController: _refreshController,
mediaController: _mediaController,
settingsController: _settingsController,
scrollController: _scrollController,
);
}
@override
void dispose() {
_filterWorker?.dispose();
_scrollController.dispose();
_refreshController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (_mediaController.loading.value && _mediaController.items.isEmpty) {
return Scaffold(
appBar: _appBar,
body: const Center(child: CircularProgressIndicator()),
);
}
if (_mediaController.errorMessage.value != null &&
_mediaController.items.isEmpty) {
return Scaffold(
appBar: _appBar,
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
'${_mediaController.errorMessage.value}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _mediaController.fetchInitial(),
child: const Text('Erneut versuchen'),
),
],
),
),
),
);
}
return Scaffold(
endDrawer: const EndDrawer(),
endDrawerEnableOpenDragGesture:
_settingsController.drawerSwipeEnabled.value,
bottomNavigationBar: FilterBar(),
appBar: _appBar,
body: _body,
persistentFooterButtons: _mediaController.tag.value != null
? [TagFooter()]
: null,
);
});
}
}
class _MediaGridAppBar extends StatelessWidget implements PreferredSizeWidget {
const _MediaGridAppBar({required this.mediaController});
final MediaController mediaController;
@override
Widget build(BuildContext context) {
return AppBar(
title: InkWell(
onTap: () {
if (mediaController.tag.value != null) {
mediaController.setTag(null);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
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(),
);
},
),
Obx(
() => IconButton(
icon: Icon(
mediaController.isRandomEnabled
? Icons.shuffle_on_outlined
: Icons.shuffle,
),
onPressed: () {
mediaController.toggleRandom();
},
),
),
IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(context).openEndDrawer(),
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _MediaGridBody extends StatelessWidget {
const _MediaGridBody({
required this.refreshController,
required this.mediaController,
required this.settingsController,
required this.scrollController,
});
final PullexRefreshController refreshController;
final MediaController mediaController;
final SettingsController settingsController;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
if (mediaController.items.isEmpty && !mediaController.loading.value) {
return const Center(
child: Text(
'Keine f0cks gefunden.\n\nVersuch mal andere Filter.',
textAlign: TextAlign.center,
),
);
}
return NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (!mediaController.loading.value &&
!mediaController.atEnd.value &&
scrollInfo.metrics.pixels >=
scrollInfo.metrics.maxScrollExtent - 600) {
mediaController.handleLoading();
}
return true;
},
child: PullexRefresh(
controller: refreshController,
onRefresh: () async {
try {
await mediaController.handleRefresh();
} finally {
refreshController.refreshCompleted();
}
},
header: const WaterDropHeader(),
child: Obx(
() => GridView.builder(
addAutomaticKeepAlives: false,
controller: scrollController,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(4),
itemCount: mediaController.items.length,
gridDelegate: settingsController.crossAxisCount.value == 0
? const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 5,
mainAxisSpacing: 5,
childAspectRatio: 1,
)
: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: settingsController.crossAxisCount.value,
crossAxisSpacing: 5,
mainAxisSpacing: 5,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
final MediaItem item = mediaController.items[index];
return Hero(
tag: 'media_${item.id}',
child: Material(
type: MaterialType.transparency,
child: GestureDetector(
key: ValueKey(item.id),
onTap: () => Get.toNamed('/${item.id}'),
child: MediaTile(item: item),
),
),
);
},
),
),
),
);
}
}

View File

@ -1,323 +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:go_router/go_router.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:f0ckapp/utils/appversion_util.dart';
import 'package:f0ckapp/providers/theme_provider.dart';
import 'package:f0ckapp/utils/customsearchdelegate_util.dart';
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 mediaState = ref.watch(mediaProvider);
final MediaNotifier 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: 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);
},
),
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: 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: 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: (BuildContext context, int index) {
if (index >= mediaState.mediaItems.length) {
return const Center(child: CircularProgressIndicator());
}
final MediaItem item = mediaState.mediaItems[index];
return InkWell(
onTap: () async {
context.push('/${item.id}', extra: true);
},
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,
),
),
],
),
);
},
),
),
);
}
}

163
lib/screens/settings.dart Normal file
View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/localizationcontroller.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
import 'package:f0ckapp/utils/animatedtransition.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<StatefulWidget> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final SettingsController settingsController = Get.find<SettingsController>();
final LocalizationController localizationController = Get.find<LocalizationController>();
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: false,
pinned: true,
title: Text('settings_title'.tr),
),
SliverList(
delegate: SliverChildListDelegate([
ListTile(
title: Text('settings_numberofcolumns_title'.tr),
trailing: Obx(
() => DropdownButton<int>(
value: settingsController.crossAxisCount.value,
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'
: 'settings_numberofcolumns_columns'.trParams({
'count': value.toString(),
}),
),
);
}).toList(),
onChanged: (int? newValue) async {
if (newValue != null) {
await settingsController.setCrossAxisCount(newValue);
}
},
),
),
),
const Divider(),
ListTile(
title: Text('settings_pageanimation_title'.tr),
trailing: Obx(
() => DropdownButton<PageTransition>(
value: settingsController.transitionType.value,
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
iconEnabledColor: Colors.white,
items: PageTransition.values.map((PageTransition type) {
String label;
switch (type) {
case PageTransition.opacity:
label = 'Opacity';
break;
case PageTransition.scale:
label = 'Scale';
break;
case PageTransition.slide:
label = 'Slide';
break;
case PageTransition.rotate:
label = 'Rotate';
break;
case PageTransition.flip:
label = 'Flip';
break;
}
return DropdownMenuItem<PageTransition>(
value: type,
child: Text(label),
);
}).toList(),
onChanged: (PageTransition? newValue) async {
if (newValue != null) {
await settingsController.setTransitionType(newValue);
}
},
),
),
),
const Divider(),
SwitchListTile(
title: Text('settings_drawer_title'.tr),
subtitle: Text('settings_drawer_subtitle'.tr),
value: settingsController.drawerSwipeEnabled.value,
onChanged: (bool value) async {
await settingsController.setDrawerSwipeEnabled(value);
setState(() {});
},
),
const Divider(),
ListTile(
title: Text('settings_language'.tr),
trailing: Obx(
() => DropdownButton<Locale>(
value: localizationController.currentLocale.value,
dropdownColor: const Color.fromARGB(255, 43, 43, 43),
iconEnabledColor: Colors.white,
items: const [
DropdownMenuItem<Locale>(
value: Locale('en', 'US'),
child: Text('English'),
),
DropdownMenuItem<Locale>(
value: Locale('de', 'DE'),
child: Text('Deutsch'),
),
DropdownMenuItem<Locale>(
value: Locale('fr', 'FR'),
child: Text('Français'),
),
DropdownMenuItem<Locale>(
value: Locale('nl', 'NL'),
child: Text('Nederlands'),
),
],
onChanged: (Locale? newLocale) async {
if (newLocale != null) {
await localizationController.changeLocale(newLocale);
}
},
),
),
),
const Divider(),
ListTile(
title: Text('settings_cache_title'.tr),
trailing: ElevatedButton(
onPressed: () async {
await DefaultCacheManager().emptyCache();
if (!mounted) return;
Get.snackbar('', 'der Cache wurde geleert.');
},
child: Text('settings_cache_clear_button'.tr),
),
),
]),
),
],
),
);
}
}

110
lib/services/api.dart Normal file
View File

@ -0,0 +1,110 @@
import 'package:get/get.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/models/feed.dart';
class ApiService extends GetConnect {
final AuthController _authController = Get.find<AuthController>();
Future<Feed> fetchItems({
int? older,
int? newer,
int type = 0,
int mode = 0,
int random = 0,
String? tag,
}) async {
String? token = _authController.token.value;
final params = <String, String>{
'type': type.toString(),
'mode': mode.toString(),
'random': random.toString(),
};
if (older != null) params['older'] = older.toString();
if (newer != null) params['newer'] = newer.toString();
if (tag != null) params['tag'] = tag;
final Map<String, String> headers = <String, String>{};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final Response<dynamic> response = await get(
'https://api.f0ck.me/items/get',
query: params,
headers: headers,
);
if (response.status.code == 200 && response.body is Map<String, dynamic>) {
final Feed feed = Feed.fromJson(response.body as Map<String, dynamic>);
feed.items.sort((a, b) => b.id.compareTo(a.id));
return feed;
} else {
if (!Get.isSnackbarOpen) {
Get.snackbar('Fehler', 'Fehler beim Laden der Items');
}
throw Exception('Fehler beim Laden der Items');
}
}
Future<MediaItem> fetchItemById(int itemId) async {
String? token = _authController.token.value;
final Map<String, String> headers = <String, String>{};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final Response<dynamic> response = await get(
'https://api.f0ck.me/item/$itemId',
headers: headers,
);
if (response.status.code == 200 && response.body is Map<String, dynamic>) {
return MediaItem.fromJson(response.body as Map<String, dynamic>);
} else {
if (!Get.isSnackbarOpen) {
Get.snackbar('Fehler', 'Fehler beim Laden des Items');
}
throw Exception('Fehler beim Laden des Items');
}
}
Future<List<Favorite>?> toggleFavorite(
MediaItem item,
bool isFavorite,
) async {
String? token = _authController.token.value;
if (token == null || token.isEmpty) return null;
final Map<String, String> headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
try {
Response response;
if (!isFavorite) {
response = await put(
'https://api.f0ck.me/favorites/${item.id}',
null,
headers: headers,
);
} else {
response = await delete(
'https://api.f0ck.me/favorites/${item.id}',
headers: headers,
);
}
if (response.status.code == 200 && response.body is List) {
return (response.body as List)
.map((json) => Favorite.fromJson(json))
.toList();
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View File

@ -1,109 +0,0 @@
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,
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 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) {
return decoded
.map((item) => Suggestion.fromJson(item as Map<String, dynamic>))
.toList()
..sort((a, b) => b.score.compareTo(a.score));
} else {
throw Exception('Unerwartetes Format: Erwartet wurde eine Liste.');
}
} 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 beim Verarbeiten 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 dynamic token = data['token'];
await storage.write(key: "token", value: token);
return true;
} else {
return false;
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
enum PageTransition { opacity, scale, slide, rotate, flip }
Widget buildAnimatedTransition({
required BuildContext context,
required Widget child,
required PageController pageController,
required int index,
}) {
final SettingsController settingsController = Get.find<SettingsController>();
final double value = pageController.position.haveDimensions
? pageController.page! - index
: 0;
switch (settingsController.transitionType.value) {
case PageTransition.opacity:
return Opacity(
opacity: Curves.easeOut.transform(1 - value.abs().clamp(0.0, 1.0)),
child: child,
);
case PageTransition.scale:
return Transform.scale(
scale:
0.8 +
Curves.easeOut.transform(1 - value.abs().clamp(0.0, 1.0)) * 0.2,
child: child,
);
case PageTransition.slide:
return child;
case PageTransition.rotate:
return Opacity(
opacity: (1 - value.abs()).clamp(0.0, 1.0),
child: Transform.rotate(angle: value.abs() * 0.5, child: child),
);
case PageTransition.flip:
return Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(value.abs()),
alignment: Alignment.center,
child: child,
);
}
}

12
lib/utils/appversion.dart Normal file
View File

@ -0,0 +1,12 @@
import 'package:flutter/services.dart';
class AppVersion {
static late String version;
static Future<void> init() async {
final String yaml = await rootBundle.loadString('pubspec.yaml');
final RegExpMatch? match = RegExp(r'^version:\s*(.*)$', multiLine: true).firstMatch(yaml);
final String? v = match?.group(1)!.replaceAll('"', '').replaceAll("'", '').trim();
version = v ?? "";
}
}

View File

@ -1,10 +0,0 @@
import 'package:package_info_plus/package_info_plus.dart';
class AppVersion {
static String version = "";
static Future<void> init() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
version = '${packageInfo.version}+${packageInfo.buildNumber}';
}
}

View File

@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:convert';
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';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
import 'package:f0ckapp/models/suggestion.dart';
class CustomSearchDelegate extends SearchDelegate<String> {
final MediaController controller = Get.find<MediaController>();
final GetConnect http = GetConnect();
Timer? _debounceTimer;
List<Suggestion>? _suggestions;
bool _isLoading = false;
@ -42,13 +47,56 @@ class CustomSearchDelegate extends SearchDelegate<String> {
return Center(child: Text('Suchergebnisse für: "$query"'));
}
Future<List<Suggestion>> fetchSuggestions(String query) async {
final String url = 'https://api.f0ck.me/search/?q=$query';
try {
final Response<dynamic> response = await http
.get(url)
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final dynamic decoded = response.body is String
? jsonDecode(response.body)
: 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 = response.body is String
? jsonDecode(response.body)
: 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');
}
}
@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(''));
return Container(
padding: const EdgeInsets.all(16.0),
child: const Text(''),
);
}
if (query != _lastFetchedQuery) {
@ -90,8 +138,6 @@ class CustomSearchDelegate extends SearchDelegate<String> {
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) {
@ -103,7 +149,7 @@ class CustomSearchDelegate extends SearchDelegate<String> {
style: TextStyle(fontSize: 12),
),
onTap: () {
ref.read(mediaProvider.notifier).setTag(suggestion.tag);
controller.setTag(suggestion.tag);
close(context, suggestion.tag);
},
);
@ -111,8 +157,6 @@ class CustomSearchDelegate extends SearchDelegate<String> {
);
},
);
},
);
}
Widget _buildLoadingIndicator() {

View File

@ -1,42 +0,0 @@
import 'package:flutter/material.dart';
class PageTransformer extends StatelessWidget {
final List<Widget> pages;
final PageController controller;
const PageTransformer({
super.key,
required this.pages,
required this.controller,
});
@override
Widget build(BuildContext context) {
return PageView.builder(
controller: controller,
itemCount: pages.length,
itemBuilder: (context, index) {
return _buildPage(pages[index], index);
},
);
}
Widget _buildPage(Widget page, int index) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
double value = 1.0;
if (controller.position.haveDimensions) {
value = controller.page! - index;
value = (1 - (value.abs() * 0.5)).clamp(0.0, 1.0);
}
return Transform(
transform: Matrix4.identity()..scale(value, value),
alignment: Alignment.center,
child: child,
);
},
child: page,
);
}
}

View File

@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
class SmartRefreshIndicator extends StatelessWidget {
final Future<void> Function() onRefresh;
final Widget child;
const SmartRefreshIndicator({
super.key,
required this.onRefresh,
required this.child,
});
@override
Widget build(context) {
return LayoutBuilder(
builder: (context, constraints) => RefreshIndicator(
onRefresh: onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: child,
),
),
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:f0ckapp/models/item.dart';
class ActionTag extends StatelessWidget {
final Tag tag;
final void Function(String tag) onTagTap;
const ActionTag(this.tag, this.onTagTap, {super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTagTap(tag.tag),
child:
['german', 'dutch', 'ukraine', 'russia', 'belgium'].contains(tag.tag)
? Stack(
alignment: Alignment.center,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/images/tags/${tag.tag}.webp',
height: 27,
width: 60,
repeat: ImageRepeat.repeat,
fit: BoxFit.fill,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 6,
),
child: Text(
tag.tag,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 1),
color: switch (tag.id) {
1 => Colors.green,
2 => Colors.red,
_ => const Color(0xFF090909),
},
),
child: Text(
tag.tag,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

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

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
import 'package:f0ckapp/controller/themecontroller.dart';
import 'package:f0ckapp/screens/login.dart';
import 'package:f0ckapp/screens/settings.dart';
import 'package:f0ckapp/utils/appversion.dart';
class EndDrawer extends StatelessWidget {
const EndDrawer({super.key});
void _showMsg(String message, {String title = ''}) {
Get
..closeAllSnackbars()
..snackbar(message, title, snackPosition: SnackPosition.BOTTOM);
}
@override
Widget build(BuildContext context) {
final ThemeController themeController = Get.find();
final AuthController authController = Get.find();
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
Obx(() {
if (authController.token.value != null &&
authController.user.value?.avatarUrl != null) {
return DrawerHeader(
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(authController.user.value!.avatarUrl!),
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
child: null,
);
} else {
return DrawerHeader(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/menu.webp'),
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
child: null,
);
}
}),
Obx(() {
if (authController.token.value != null) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
if (authController.user.value?.username != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Hamlo ${authController.user.value?.username}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
ElevatedButton(
onPressed: () async {
await authController.logout();
_showMsg('Erfolgreich ausgeloggt.');
},
child: const Text('Logout'),
),
],
),
);
} else {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text(
'Du bist nicht eingeloggt.',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
Get.to(() => LoginScreen());
},
child: const Text('Login'),
),
],
),
);
}
}),
ExpansionTile(
title: const Text('Theme'),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Obx(() {
return Column(
children: themeController.themeMap.entries.map((entry) {
final String themeName = entry.key;
final ThemeData themeData = entry.value;
final bool isSelected =
themeController.currentTheme.value == themeData;
return ListTile(
title: Text(themeName),
selected: isSelected,
selectedTileColor: Colors.blue.withValues(alpha: 0.2),
onTap: () async {
await themeController.updateTheme(themeName);
},
);
}).toList(),
);
}),
),
],
),
ListTile(
title: const Text('Settings'),
onTap: () {
Navigator.pop(context);
Get.bottomSheet(SettingsPage());
},
),
ListTile(
title: Text('v${AppVersion.version}'),
onTap: () {
Navigator.pop(context);
_showMsg('jooong lass das, hier ist nichts');
},
),
],
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/authcontroller.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
import 'package:f0ckapp/models/item.dart';
class FavoriteSection extends StatelessWidget {
final MediaItem item;
final int index;
final MediaController mediaController = Get.find<MediaController>();
final AuthController authController = Get.find<AuthController>();
FavoriteSection({super.key, required this.item, required this.index});
@override
Widget build(BuildContext context) {
final bool isFavorite =
item.favorites?.any((f) => f.userId == authController.user.value?.id) ??
false;
return Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...(item.favorites ?? []).map((favorite) {
return Container(
height: 32,
width: 32,
margin: const EdgeInsets.only(right: 5.0),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
width: 1.0,
),
),
child: CachedNetworkImage(
imageUrl: favorite.avatarUrl,
fit: BoxFit.cover,
),
);
}),
],
),
),
),
IconButton(
icon: isFavorite
? const Icon(Icons.favorite)
: const Icon(Icons.favorite_outline),
color: Colors.red,
onPressed: () async {
final List<Favorite>? newFavorites = await mediaController
.toggleFavorite(item, isFavorite);
if (newFavorites != null) {
mediaController.items[index] = item.copyWith(
favorites: newFavorites,
);
mediaController.items.refresh();
}
},
),
],
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
class FilterBar extends StatelessWidget {
const FilterBar({super.key});
@override
Widget build(BuildContext context) {
final MediaController c = Get.find<MediaController>();
return BottomAppBar(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const Text('type: '),
Obx(
() => DropdownButton<String>(
value: mediaTypes[c.typeIndex.value],
isDense: true,
items: mediaTypes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
c.setTypeIndex(mediaTypes.indexOf(newValue));
}
},
),
),
const Text('mode: '),
Obx(
() => DropdownButton<String>(
value: mediaModes[c.modeIndex.value],
isDense: true,
items: mediaModes.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
c.setModeIndex(mediaModes.indexOf(newValue));
}
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:f0ckapp/models/item.dart';
class MediaTile extends StatelessWidget {
final MediaItem item;
const MediaTile({super.key, required this.item});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: InkWell(
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: item.thumbnailUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: Colors.grey[900]),
errorWidget: (context, url, error) =>
const Icon(Icons.broken_image),
),
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,30 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
class TagFooter extends StatelessWidget {
final MediaController mediaController = Get.find<MediaController>();
TagFooter({super.key});
@override
Widget build(BuildContext context) {
return Obx(() {
if (mediaController.tag.value != null) {
return Center(
child: InputChip(
label: Text(mediaController.tag.value!),
onDeleted: () {
mediaController.setTag(null);
Get.offAllNamed('/');
},
),
);
} else {
return const SizedBox.shrink();
}
});
}
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/widgets/actiontag.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
class TagSection extends StatefulWidget {
final List<Tag> tags;
const TagSection({super.key, required this.tags});
@override
State<TagSection> createState() => _TagSectionState();
}
class _TagSectionState extends State<TagSection> {
bool _areTagsExpanded = false;
@override
Widget build(BuildContext context) {
final MediaController mediaController = Get.find<MediaController>();
final bool hasMoreTags = widget.tags.length > 5;
final List<Tag> tagsToShow = _areTagsExpanded
? widget.tags
: widget.tags.take(5).toList();
return Column(
children: [
Wrap(
spacing: 6.0,
runSpacing: 4.0,
alignment: WrapAlignment.center,
children: [
...tagsToShow.map(
(tag) => ActionTag(
tag,
(tag.tag == 'sfw' || tag.tag == 'nsfw')
? (onTagTap) => {}
: (onTagTap) {
mediaController.setTag(onTagTap);
Get.offAllNamed('/');
},
),
),
],
),
if (hasMoreTags)
TextButton(
onPressed: () {
setState(() => _areTagsExpanded = !_areTagsExpanded);
},
child: Text(
_areTagsExpanded
? 'Weniger anzeigen'
: 'Alle ${widget.tags.length} Tags anzeigen',
),
),
],
);
}
}

View File

@ -0,0 +1,261 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart';
import 'package:get/get.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
class VideoControlsOverlay extends StatefulWidget {
final CachedVideoPlayerPlusController controller;
const VideoControlsOverlay({
super.key,
required this.controller,
});
@override
State<VideoControlsOverlay> createState() => _VideoControlsOverlayState();
}
class _VideoControlsOverlayState extends State<VideoControlsOverlay> {
final SettingsController _settingsController = Get.find();
Timer? _hideTimer;
bool _controlsVisible = false;
bool _isScrubbing = false;
Duration _scrubbingStartPosition = Duration.zero;
double _scrubbingStartDx = 0.0;
Duration _scrubbingSeekPosition = Duration.zero;
@override
void initState() {
super.initState();
widget.controller.addListener(_listener);
}
@override
void dispose() {
_hideTimer?.cancel();
widget.controller.removeListener(_listener);
super.dispose();
}
void _listener() {
if (mounted) {
setState(() {});
}
}
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 5), () {
if (mounted) {
setState(() => _controlsVisible = false);
}
});
}
void _toggleControlsVisibility() {
setState(() => _controlsVisible = !_controlsVisible);
if (_controlsVisible) {
_startHideTimer();
}
}
void _handlePlayPause() {
widget.controller.value.isPlaying
? widget.controller.pause()
: widget.controller.play();
_startHideTimer();
}
void _onHorizontalDragStart(DragStartDetails details) {
if (!widget.controller.value.isInitialized || !_controlsVisible) return;
setState(() {
_isScrubbing = true;
_scrubbingStartPosition = widget.controller.value.position;
_scrubbingStartDx = details.globalPosition.dx;
_scrubbingSeekPosition = widget.controller.value.position;
});
_hideTimer?.cancel();
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
if (!_isScrubbing) return;
final double delta = details.globalPosition.dx - _scrubbingStartDx;
final int seekMillis =
_scrubbingStartPosition.inMilliseconds + (delta * 300).toInt();
setState(() {
final Duration duration = widget.controller.value.duration;
final Duration seekDuration = Duration(milliseconds: seekMillis);
final Duration clampedSeekDuration = seekDuration < Duration.zero
? Duration.zero
: (seekDuration > duration ? duration : seekDuration);
_scrubbingSeekPosition = clampedSeekDuration;
});
}
void _onHorizontalDragEnd(DragEndDetails details) {
if (!_isScrubbing) return;
widget.controller.seekTo(_scrubbingSeekPosition);
setState(() => _isScrubbing = false);
_startHideTimer();
}
void _onHorizontalDragCancel() {
if (!_isScrubbing) return;
setState(() => _isScrubbing = false);
_startHideTimer();
}
String _formatDuration(Duration? duration) {
if (duration == null) return '00:00';
String twoDigits(int n) => n.toString().padLeft(2, '0');
return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}";
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _toggleControlsVisibility,
onHorizontalDragStart: _controlsVisible ? _onHorizontalDragStart : null,
onHorizontalDragUpdate: _controlsVisible ? _onHorizontalDragUpdate : null,
onHorizontalDragEnd: _controlsVisible ? _onHorizontalDragEnd : null,
onHorizontalDragCancel: _controlsVisible ? _onHorizontalDragCancel : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
color: _controlsVisible && !_isScrubbing
? Colors.black.withValues(alpha: 0.5)
: Colors.transparent,
),
),
),
AnimatedOpacity(
opacity: _isScrubbing ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: _buildScrubbingIndicator(),
),
AnimatedOpacity(
opacity: _controlsVisible && !_isScrubbing ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Align(
alignment: Alignment.bottomCenter,
child: _buildBottomBar(),
),
),
],
);
}
Widget _buildScrubbingIndicator() {
final Duration positionChange =
_scrubbingSeekPosition - _scrubbingStartPosition;
final String changeSign = positionChange.isNegative ? '-' : '+';
final String changeText = _formatDuration(positionChange.abs());
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatDuration(_scrubbingSeekPosition),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
Text(
'[$changeSign$changeText]',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 16,
),
),
],
),
);
}
Widget _buildBottomBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 5),
child: Row(
children: [
IconButton(
icon: Icon(
widget.controller.value.isPlaying
? Icons.pause
: Icons.play_arrow,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
onPressed: _handlePlayPause,
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
Text(
_formatDuration(widget.controller.value.position),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: VideoProgressIndicator(
widget.controller,
allowScrubbing: true,
colors: VideoProgressColors(
playedColor: Theme.of(context).colorScheme.primary,
backgroundColor: Colors.white.withValues(alpha: 0.3),
bufferedColor: Colors.white.withValues(alpha: 0.6),
),
),
),
),
Text(
_formatDuration(widget.controller.value.duration),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
const SizedBox(width: 8),
Obx(
() => IconButton(
icon: Icon(
_settingsController.muted.value
? Icons.volume_off
: Icons.volume_up,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
onPressed: () {
_settingsController.toggleMuted();
_startHideTimer();
},
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
),
],
),
);
}
}

View File

@ -1,141 +1,139 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart';
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:get/get.dart';
import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/widgets/videooverlay_widget.dart';
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:f0ckapp/models/item.dart';
import 'package:f0ckapp/widgets/video_controls_overlay.dart';
import 'package:f0ckapp/controller/settingscontroller.dart';
import 'package:f0ckapp/controller/mediacontroller.dart';
class VideoWidget extends ConsumerStatefulWidget {
class VideoWidget extends StatefulWidget {
final MediaItem details;
final bool isActive;
final bool fullScreen;
final VoidCallback? onInitialized;
final Duration? initialPosition;
const VideoWidget({super.key, required this.details, required this.isActive});
const VideoWidget({
super.key,
required this.details,
required this.isActive,
this.fullScreen = false,
this.onInitialized,
this.initialPosition,
});
@override
ConsumerState<VideoWidget> createState() => _VideoWidgetState();
State<VideoWidget> createState() => VideoWidgetState();
}
class _VideoWidgetState extends ConsumerState<VideoWidget> {
late CachedVideoPlayerPlusController _controller;
bool _showControls = false;
Timer? _hideControlsTimer;
class VideoWidgetState extends State<VideoWidget> {
final MediaController mediaController = Get.find<MediaController>();
final SettingsController settingsController = Get.find<SettingsController>();
late CachedVideoPlayerPlusController videoController;
late Worker _muteWorker;
@override
void initState() {
super.initState();
_initController();
_muteWorker = ever(settingsController.muted, (bool muted) {
if (videoController.value.isInitialized) {
videoController.setVolume(muted ? 0.0 : 1.0);
}
});
}
Future<void> _initController() async {
_controller = CachedVideoPlayerPlusController.networkUrl(
videoController = CachedVideoPlayerPlusController.networkUrl(
Uri.parse(widget.details.mediaUrl),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await _controller.initialize();
await videoController.initialize();
if (!mounted) return;
setState(() {});
_controller.addListener(() => setState(() {}));
if (widget.initialPosition != null) {
await videoController.seekTo(widget.initialPosition!);
}
widget.onInitialized?.call();
videoController.setLooping(true);
videoController.setVolume(settingsController.muted.value ? 0.0 : 1.0);
if (widget.isActive) {
_controller.play();
videoController.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.details.mediaUrl != oldWidget.details.mediaUrl) {
videoController.dispose();
_initController();
return;
}
if (widget.isActive != oldWidget.isActive) {
if (videoController.value.isInitialized) {
if (widget.isActive) {
_controller.play();
videoController.play();
} else {
_controller.pause();
videoController.pause();
}
}
}
}
@override
void dispose() {
_controller.dispose();
_hideControlsTimer?.cancel();
_muteWorker.dispose();
videoController.dispose();
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);
}
final bool isInitialized = videoController.value.isInitialized;
final bool isAudio = widget.details.mime.startsWith('audio');
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(
Widget mediaContent;
if (isAudio) {
mediaContent = CachedNetworkImage(
imageUrl: widget.details.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset(
errorWidget: (c, e, s) => 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),
);
} else {
mediaContent = isInitialized
? CachedVideoPlayerPlus(videoController)
: const Center(child: CircularProgressIndicator());
}
return AspectRatio(
aspectRatio: isInitialized
? videoController.value.aspectRatio
: (isAudio ? 16 / 9 : 9 / 16),
child: Stack(
alignment: Alignment.center,
children: [
mediaContent,
if (isInitialized)
Positioned.fill(
child: VideoControlsOverlay(controller: videoController),
),
],
],
),
),
],
);
}
}

View File

@ -1,153 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart';
import 'package:f0ckapp/providers/media_provider.dart';
class VideoControlsOverlay extends ConsumerWidget {
final CachedVideoPlayerPlusController controller;
final VoidCallback button;
const VideoControlsOverlay({
super.key,
required this.controller,
required this.button,
});
@override
Widget build(BuildContext context, ref) {
final MediaState mediaState = ref.watch(mediaProvider);
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Stack(
alignment: Alignment.center,
children: [
Positioned(
right: 12,
bottom: 12,
child: _ControlButton(
mediaState.muted ? Icons.volume_off : Icons.volume_up,
() {
button();
mediaNotifier.toggleMute();
},
size: 16,
),
),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ControlButton(Icons.replay_10, () {
button();
Duration newPosition =
controller.value.position - const Duration(seconds: 10);
if (newPosition < Duration.zero) newPosition = Duration.zero;
controller.seekTo(newPosition);
}),
SizedBox(width: 40),
_ControlButton(
controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
() {
button();
controller.value.isPlaying
? controller.pause()
: controller.play();
},
size: 64,
),
SizedBox(width: 40),
_ControlButton(Icons.forward_10, () {
button();
Duration newPosition =
controller.value.position + const Duration(seconds: 10);
if (newPosition > controller.value.duration) {
newPosition = controller.value.duration;
}
controller.seekTo(newPosition);
}),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 0),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 10,
bottom: 12,
child: Text(
'${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}',
),
),
Listener(
onPointerDown: (_) {
button();
},
child: VideoProgressIndicator(
controller,
allowScrubbing: true,
padding: const EdgeInsets.only(top: 25.0),
colors: const VideoProgressColors(
playedColor: Colors.red,
backgroundColor: Colors.grey,
bufferedColor: Colors.white54,
),
),
),
Positioned(
left:
(controller.value.position.inMilliseconds /
controller.value.duration.inMilliseconds) *
MediaQuery.of(context).size.width -
6,
bottom: -4,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red,
border: Border.all(color: Colors.red, width: 2),
),
),
),
],
),
),
),
],
);
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}";
}
}
class _ControlButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
final double size;
const _ControlButton(this.icon, this.onPressed, {this.size = 24});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.4),
),
child: IconButton(
icon: Icon(icon, size: size),
onPressed: onPressed,
),
);
}
}

View File

@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "0511d6be23b007e95105ae023db599aea731df604608978dada7f9faf2637623"
url: "https://pub.dev"
source: hosted
version: "1.6.4"
async:
dependency: transitive
description:
@ -73,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
@ -97,14 +121,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
devtools_shared:
dependency: transitive
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
name: devtools_shared
sha256: "659e2d65aa5ef5c3551163811c5c6fa1b973b3df80d8cac6f618035edcdc1096"
url: "https://pub.dev"
source: hosted
version: "1.0.8"
version: "11.2.1"
dtd:
dependency: transitive
description:
name: dtd
sha256: "14a0360d898ded87c3d99591fc386b8a6ea5d432927bee709b22130cd25b993a"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
encrypt:
dependency: transitive
description:
name: encrypt
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
encrypt_shared_preferences:
dependency: "direct main"
description:
name: encrypt_shared_preferences
sha256: ab8a957db7ae645c8b0341e8aee85c1cd046a5cb9a0529459ea417ebd6040ba2
url: "https://pub.dev"
source: hosted
version: "0.9.9"
extension_discovery:
dependency: transitive
description:
name: extension_discovery
sha256: de1fce715ab013cdfb00befc3bdf0914bea5e409c3a567b7f8f144bc061611a7
url: "https://pub.dev"
source: hosted
version: "2.1.0"
fake_async:
dependency: transitive
description:
@ -143,21 +199,13 @@ packages:
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_downloader:
dependency: "direct main"
description:
name: flutter_downloader
sha256: "93a9ddbd561f8a3f5483b4189453fba145a0a1014a88143c96a966296b78a118"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
flutter_lints:
dependency: "direct dev"
description:
@ -166,62 +214,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
@ -233,7 +225,7 @@ packages:
source: sdk
version: "0.0.0"
get:
dependency: transitive
dependency: "direct main"
description:
name: get
sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425
@ -248,14 +240,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: b453934c36e289cef06525734d1e676c1f91da9e22e2017d9dcab6ce0f999175
url: "https://pub.dev"
source: hosted
version: "15.1.3"
html:
dependency: transitive
description:
@ -265,7 +249,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
dependency: transitive
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
@ -280,14 +264,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
version: "0.7.2"
json_rpc_2:
dependency: transitive
description:
name: json_rpc_2
sha256: "246b321532f0e8e2ba474b4d757eaa558ae4fdd0688fdbc1e1ca9705f9b8ca0e"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
leak_tracker:
dependency: transitive
description:
@ -368,22 +368,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
url: "https://pub.dev"
source: hosted
version: "8.3.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
path:
dependency: transitive
description:
@ -393,7 +377,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -440,54 +424,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f"
url: "https://pub.dev"
source: hosted
version: "12.0.0+1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
platform:
dependency: transitive
description:
@ -504,14 +440,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
riverpod:
pointycastle:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
pullex:
dependency: "direct main"
description:
name: pullex
sha256: f29a0b5eef4c16e32ae4b32cf6ad1a6eea0778d5bad8ee6cb29edb7d44496c1c
url: "https://pub.dev"
source: hosted
version: "1.0.0"
rxdart:
dependency: transitive
description:
@ -536,6 +488,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
url: "https://pub.dev"
source: hosted
version: "2.4.10"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
sky_engine:
dependency: transitive
description: flutter
@ -597,6 +613,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sse:
dependency: transitive
description:
name: sse
sha256: fcc97470240bb37377f298e2bd816f09fd7216c07928641c0560719f50603643
url: "https://pub.dev"
source: hosted
version: "4.1.8"
stack_trace:
dependency: transitive
description:
@ -605,14 +629,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
@ -653,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.6"
timeago:
dependency: "direct main"
description:
name: timeago
sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e
url: "https://pub.dev"
source: hosted
version: "3.7.1"
typed_data:
dependency: transitive
description:
@ -661,6 +685,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
unified_analytics:
dependency: transitive
description:
name: unified_analytics
sha256: c8abdcad84b55b78f860358aae90077b8f54f98169a75e16d97796a1b3c95590
url: "https://pub.dev"
source: hosted
version: "8.0.1"
url_launcher_linux:
dependency: transitive
description:
@ -745,10 +777,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.0"
version: "15.0.2"
web:
dependency: transitive
description:
@ -757,14 +789,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
@ -773,6 +829,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
yaml_edit:
dependency: transitive
description:
name: yaml_edit
sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5
url: "https://pub.dev"
source: hosted
version: "2.2.2"
sdks:
dart: ">=3.9.0-100.2.beta <4.0.0"
flutter: ">=3.29.0"

View File

@ -1,5 +1,5 @@
name: f0ckapp
description: "A new Flutter project."
description: "f0ck schm0ck"
# The following line prevents the package from being accidentally published to
# 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
@ -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
# 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.
version: 1.1.12+42
version: 1.4.9+70
environment:
sdk: ^3.9.0-100.2.beta
@ -30,21 +30,14 @@ environment:
dependencies:
flutter:
sdk: flutter
http: ^1.4.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
get: ^4.7.2
encrypt_shared_preferences: ^0.9.9
cached_network_image: ^3.4.1
cached_video_player_plus: ^3.0.3
package_info_plus: ^8.3.0
share_plus: ^11.0.0
flutter_secure_storage: ^9.2.4
flutter_riverpod: ^2.6.1
go_router: ^15.1.3
flutter_downloader: ^1.12.0
permission_handler: ^12.0.0+1
path_provider: ^2.1.5
flutter_cache_manager: ^3.4.1
pullex: ^1.0.0
timeago: ^3.7.1
dev_dependencies:
flutter_test:
@ -71,6 +64,9 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- assets/images/
- assets/images/tags/
- assets/i18n/
- pubspec.yaml
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg

View File

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