Compare commits

...

14 Commits

Author SHA1 Message Date
dff9cda829 v1.1.23+53
All checks were successful
Flutter Schmutter / build (push) Successful in 3m40s
- bye go_router
2025-06-12 11:39:31 +02:00
16ebc51e77 v1.1.22+52
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-11 21:30:05 +02:00
7981436374 v1.1.21+51
All checks were successful
Flutter Schmutter / build (push) Successful in 3m40s
2025-06-11 20:45:37 +02:00
e38d2086b3 v1.1.20+50
All checks were successful
Flutter Schmutter / build (push) Successful in 3m39s
2025-06-11 18:56:55 +02:00
a4d50289c2 v1.1.19+49
All checks were successful
Flutter Schmutter / build (push) Successful in 3m36s
2025-06-11 14:53:26 +02:00
82fb23dbfd v1.1.18+48
All checks were successful
Flutter Schmutter / build (push) Successful in 3m36s
- fullscreen
2025-06-11 13:41:12 +02:00
13f957f016 test schmest
All checks were successful
Flutter Schmutter / build (push) Successful in 3m32s
2025-06-11 12:16:32 +02:00
707f14c5fb testbuild, rebranding
All checks were successful
Flutter Schmutter / build (push) Successful in 3m43s
2025-06-11 11:31:45 +02:00
493422e724 v1.1.15+45
All checks were successful
Flutter Schmutter / build (push) Successful in 3m35s
- buildtest lol
2025-06-11 11:22:00 +02:00
3b95d128e1 fk gitea 2025-06-11 11:01:40 +02:00
57636c5de6 v1.1.14+44
All checks were successful
Flutter Schmutter / build (push) Successful in 3m32s
2025-06-11 10:52:15 +02:00
f75299f0d4 xd
All checks were successful
Flutter Schmutter / build (push) Successful in 3m53s
2025-06-10 18:53:52 +02:00
03c6431eca v1.1.13+43
- fk android
2025-06-10 18:53:07 +02:00
5876c809a5 v1.1.12+42
All checks were successful
Flutter Schmutter / build (push) Successful in 3m45s
- search schmearch
2025-06-10 11:07:00 +02:00
20 changed files with 1022 additions and 657 deletions

View File

@ -3,7 +3,7 @@ name: Flutter Schmutter
on: on:
push: push:
tags: tags:
- 'v*' - '*'
jobs: jobs:
build: build:
@ -51,3 +51,11 @@ jobs:
files: |- files: |-
build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/app-release.apk
token: '${{secrets.RELEASE_TOKEN}}' token: '${{secrets.RELEASE_TOKEN}}'
- name: upload apk to f-droid server
run: |
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
curl -X POST "https://flumm.io/pullfdroid.php" \
-F "token=${{ secrets.PULLER_TOKEN }}" \
-F "apk=@build/app/outputs/flutter-apk/app-release.apk" \
-F "build=$BUILD_NUMBER"

View File

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

View File

@ -1,5 +1,69 @@
package com.f0ck.f0ckapp package com.f0ck.f0ckapp
import android.content.ContentValues
import android.content.Context
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileInputStream
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
private val CHANNEL = "MediaShit"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine): Unit {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call,
result ->
if (call.method == "saveFile") {
val filePath = call.argument<String>("filePath")
val fileName = call.argument<String>("fileName")
val subDir = call.argument<String?>("subDir")
if (filePath == null || fileName == null)
result.error("SAVE_FAILED", "file not found", null)
if (!saveFileUsingMediaStore(applicationContext, filePath!!, fileName!!, subDir))
result.error("COPY_FAILED", "Datei konnte nicht gespeichert werden", null)
result.success(true)
} else result.notImplemented()
}
}
private fun saveFileUsingMediaStore(
context: Context,
filePath: String,
fileName: String,
subDir: String?
): Boolean {
val srcFile = File(filePath)
if (!srcFile.exists()) return false
val values =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/" + (subDir ?: "f0ck"))
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val resolver = context.contentResolver
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
val uri = resolver.insert(collection, values) ?: return false
resolver.openOutputStream(uri).use { out ->
FileInputStream(srcFile).use { input -> input.copyTo(out!!, 4096) }
}
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
return true
}
}

View File

@ -1,89 +1,78 @@
import 'package:f0ckapp/providers/media_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:f0ckapp/screens/mediagrid_screen.dart'; import 'package:f0ckapp/screens/mediagrid_screen.dart';
import 'package:f0ckapp/screens/detailview_screen.dart'; import 'package:f0ckapp/screens/detailview_screen.dart';
import 'package:f0ckapp/screens/settings_screen.dart';
import 'package:f0ckapp/utils/appversion_util.dart'; import 'package:f0ckapp/utils/appversion_util.dart';
import 'package:f0ckapp/providers/theme_provider.dart'; import 'package:f0ckapp/providers/theme_provider.dart';
import 'package:f0ckapp/providers/media_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await FlutterDownloader.initialize();
await AppVersion.init(); await AppVersion.init();
runApp(ProviderScope(child: F0ckApp())); runApp(ProviderScope(child: F0ckApp()));
} }
class F0ckApp extends ConsumerWidget { class F0ckApp extends ConsumerWidget {
F0ckApp({super.key}); const F0ckApp({super.key});
final GoRouter _router = GoRouter( @override
initialLocation: '/', Widget build(BuildContext context, WidgetRef ref) {
routes: [ final ThemeData theme = ref.watch(themeNotifierProvider);
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( return MaterialApp(
r'^(?:/tag/(?<tag>.+?))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$', debugShowCheckedModeBanner: false,
theme: theme,
initialRoute: '/',
routes: {
'/': (context) => const MediaGrid(),
'/settings': (context) => const SettingsPage(),
},
onGenerateRoute: (RouteSettings settings) {
final String? name = settings.name;
if (name == null) {
return MaterialPageRoute(
builder: (_) =>
const Scaffold(body: Center(child: Text('Ungültiger Link'))),
settings: settings,
); );
final RegExpMatch? match = regExp.firstMatch(fullPath); }
if (match == null) { final RegExp regExp = RegExp(
return const Scaffold(body: Center(child: Text('Ungültiger Link'))); r'^(?:/tag/(?<tag>[^/]+))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$',
} );
final RegExpMatch? match = regExp.firstMatch(name);
if (match != null) {
final String? tag = match.namedGroup('tag'); final String? tag = match.namedGroup('tag');
final String? mime = match.namedGroup('mime'); final String? mime = match.namedGroup('mime');
final String? idStr = match.namedGroup('itemid'); final String? idStr = match.namedGroup('itemid');
final int? itemId = idStr != null ? int.tryParse(idStr) : null; final int? itemId = idStr != null ? int.tryParse(idStr) : null;
const int preloadOffset = 50; const int preloadOffset = 50;
return Consumer( if (itemId != null) {
builder: (BuildContext context, WidgetRef ref, Widget? child) { return MaterialPageRoute(
WidgetsBinding.instance.addPostFrameCallback((_) async { builder: (context) => DetailView(initialItemId: itemId),
final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier); settings: settings,
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();
}
},
);
},
),
],
);
@override return MaterialPageRoute(
Widget build(BuildContext context, WidgetRef ref) { builder: (context) => const MediaGrid(),
final ThemeData theme = ref.watch(themeNotifierProvider); settings: settings,
return MaterialApp.router( );
debugShowCheckedModeBanner: false, }
routerConfig: _router,
theme: theme, return MaterialPageRoute(
builder: (context) =>
const Scaffold(body: Center(child: Text('Ungültiger Link'))),
settings: settings,
);
},
); );
} }
} }

View File

@ -3,12 +3,16 @@ class Suggestion {
final int tagged; final int tagged;
final double score; final double score;
Suggestion({required this.tag, required this.tagged, required this.score}); Suggestion({
required this.tag,
required this.tagged,
required this.score,
});
factory Suggestion.fromJson(Map<String, dynamic> json) { factory Suggestion.fromJson(Map<String, dynamic> json) {
return Suggestion( return Suggestion(
tag: json['tag'].toString(), tag: json['tag'].toString(),
tagged: int.tryParse(json['tagged'].toString()) ?? 0, tagged: json['tagged'],
score: (json['score'] as num).toDouble(), score: (json['score'] as num).toDouble(),
); );
} }

View File

@ -115,6 +115,17 @@ class MediaNotifier extends StateNotifier<MediaState> {
} }
} }
List<MediaItem> mergeMediaItems(
List<MediaItem> current,
List<MediaItem> incoming,
) {
final existingIds = current.map((item) => item.id).toSet();
final newItems = incoming
.where((item) => !existingIds.contains(item.id))
.toList();
return [...current, ...newItems];
}
Future<void> loadMedia({int? id}) async { Future<void> loadMedia({int? id}) async {
if (state.isLoading) return; if (state.isLoading) return;
state = state.replace(isLoading: true); state = state.replace(isLoading: true);
@ -128,8 +139,11 @@ class MediaNotifier extends StateNotifier<MediaState> {
random: state.random, random: state.random,
tag: state.tag, tag: state.tag,
); );
if (newMedia.isNotEmpty) { if (newMedia.isNotEmpty) {
addMediaItems(newMedia); state = state.replace(
mediaItems: mergeMediaItems(state.mediaItems, newMedia),
);
} }
} catch (e) { } catch (e) {
print('Fehler beim Laden der Medien: $e'); print('Fehler beim Laden der Medien: $e');

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -232,10 +233,11 @@ final ThemeData f0ck95Theme = ThemeData(
onPrimary: Colors.black, onPrimary: Colors.black,
onSecondary: Colors.white, onSecondary: Colors.white,
), ),
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Color(0xFFC0C0C0), backgroundColor: const Color(0xFFE0E0E0),
foregroundColor: Colors.black, foregroundColor: Colors.black,
elevation: 2, elevation: 4,
centerTitle: true
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.black), bodyLarge: TextStyle(color: Colors.black),

View File

@ -1,14 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:f0ckapp/screens/fullscreen_screen.dart';
import 'package:f0ckapp/widgets/end_drawer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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:cached_network_image/cached_network_image.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:f0ckapp/models/mediaitem_model.dart'; import 'package:f0ckapp/models/mediaitem_model.dart';
@ -72,50 +71,21 @@ class _DetailViewState extends ConsumerState<DetailView> {
Future<void> _downloadMedia() async { Future<void> _downloadMedia() async {
final MediaState mediaState = ref.read(mediaProvider); final MediaState mediaState = ref.read(mediaProvider);
final MediaItem currentItem = mediaState.mediaItems[_currentIndex]; final MediaItem currentItem = mediaState.mediaItems[_currentIndex];
final File file = await DefaultCacheManager().getSingleFile(
currentItem.mediaUrl,
);
final MethodChannel methodChannel = const MethodChannel('MediaShit');
if (Platform.isAndroid || Platform.isIOS) { bool? success = await methodChannel.invokeMethod<bool>('saveFile', {
PermissionStatus status = await Permission.storage.status; 'filePath': file.path,
if (!status.isGranted) { 'fileName': currentItem.dest,
status = await Permission.storage.request(); });
if (!status.isGranted) {
_showMsg("Speicherberechtigung wurde nicht erteilt.");
return;
}
}
}
String localPath; success == true
if (Platform.isAndroid) { ? _showMsg(
final Directory? directory = await getExternalStorageDirectory(); '${currentItem.dest} wurde in Downloads/fApp neigespeichert.',
localPath = "${directory!.path}/Download/fApp"; )
} else if (Platform.isIOS) { : _showMsg('${currentItem.dest} konnte nicht heruntergeladen werden.');
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 @override
@ -133,9 +103,7 @@ class _DetailViewState extends ConsumerState<DetailView> {
if (itemIndex == -1) { if (itemIndex == -1) {
Future.microtask(() { Future.microtask(() {
ref ref.read(mediaProvider.notifier).loadMedia(id: widget.initialItemId + 50);
.read(mediaProvider.notifier)
.loadMedia(id: widget.initialItemId + 50);
}); });
return Scaffold( return Scaffold(
appBar: AppBar(), appBar: AppBar(),
@ -153,96 +121,8 @@ class _DetailViewState extends ConsumerState<DetailView> {
} }
return Scaffold( return Scaffold(
appBar: AppBar( endDrawer: EndDrawer(ref: ref),
centerTitle: true, endDrawerEnableOpenDragGesture: false,
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 persistentFooterButtons: mediaState.tag != null
? [ ? [
Center( Center(
@ -250,12 +130,124 @@ class _DetailViewState extends ConsumerState<DetailView> {
label: Text(mediaState.tag!), label: Text(mediaState.tag!),
onDeleted: () { onDeleted: () {
ref.read(mediaProvider.notifier).setTag(null); ref.read(mediaProvider.notifier).setTag(null);
context.go('/', extra: true); //context.push('/', extra: true);
Navigator.pushNamed(context, '/');
}, },
), ),
), ),
] ]
: null, : null,
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
snap: true,
centerTitle: true,
title: Text('f0ck #${mediaState.mediaItems[_currentIndex].id}'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.canPop(context) ? Navigator.pop(context) : Navigator.pushNamed(context, '/');
//context.canPop() ? context.pop() : context.go('/', extra: true);
},
),
actions: [
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
final currentItem = mediaState.mediaItems[_currentIndex];
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => FullScreenMediaView(item: currentItem),
),
);
},
),
IconButton(
icon: const Icon(Icons.download),
onPressed: _downloadMedia,
),
PopupMenuButton<String>(
onSelected: (value) async {
final item = mediaState.mediaItems[_currentIndex];
switch (value) {
case 'media':
File file = await DefaultCacheManager().getSingleFile(
item.mediaUrl,
);
Uint8List bytes = await file.readAsBytes();
final params = ShareParams(
files: [XFile.fromData(bytes, mimeType: item.mime)],
);
await SharePlus.instance.share(params);
break;
case 'direct_link':
await SharePlus.instance.share(
ShareParams(text: item.mediaUrl),
);
break;
case 'post_link':
await SharePlus.instance.share(
ShareParams(text: item.postUrl),
);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'media',
child: ListTile(
leading: const Icon(Icons.image),
title: const Text('Als Datei'),
),
),
PopupMenuItem(
value: 'direct_link',
child: ListTile(
leading: const Icon(Icons.link),
title: const Text('Link zur Datei'),
),
),
PopupMenuItem(
value: 'post_link',
child: ListTile(
leading: const Icon(Icons.article),
title: const Text('Link zum f0ck'),
),
),
],
icon: const Icon(Icons.share),
),
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
),
),
],
),
SliverPadding(
padding: EdgeInsets.zero,
sliver: SliverFillRemaining(
child: PageTransformer(
controller: _pageController!,
pages: mediaState.mediaItems.map((item) {
int pageIndex = mediaState.mediaItems.indexOf(item);
return SafeArea(
top: false,
child: SmartRefreshIndicator(
onRefresh: _loadMoreMedia,
child: _buildMediaItem(item, _currentIndex == pageIndex),
),
);
}).toList(),
),
),
),
],
),
); );
} }
@ -269,8 +261,10 @@ class _DetailViewState extends ConsumerState<DetailView> {
CachedNetworkImage( CachedNetworkImage(
imageUrl: item.mediaUrl, imageUrl: item.mediaUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
placeholder: (context, url) => const CircularProgressIndicator(), placeholder: (context, url) =>
errorWidget: (context, url, error) => const Icon(Icons.error), const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) =>
const Center(child: Icon(Icons.error)),
) )
else else
VideoWidget(details: item, isActive: isActive), VideoWidget(details: item, isActive: isActive),
@ -284,7 +278,7 @@ class _DetailViewState extends ConsumerState<DetailView> {
if (tag.tag == 'sfw' || tag.tag == 'nsfw') return; if (tag.tag == 'sfw' || tag.tag == 'nsfw') return;
setState(() { setState(() {
mediaNotifier.setTag(tag.tag); mediaNotifier.setTag(tag.tag);
context.go('/', extra: true); Navigator.pushReplacementNamed(context, '/');
}); });
}, },
label: Text(tag.tag), label: Text(tag.tag),

View File

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

View File

@ -1,16 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:f0ckapp/providers/media_provider.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'; import 'package:f0ckapp/utils/customsearchdelegate_util.dart';
import 'package:f0ckapp/widgets/media_tile.dart';
const List<String> mediaTypes = ["alles", "image", "video", "audio"]; import 'package:f0ckapp/widgets/filter_bar.dart';
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"]; import 'package:f0ckapp/widgets/end_drawer.dart';
class MediaGrid extends ConsumerStatefulWidget { class MediaGrid extends ConsumerStatefulWidget {
const MediaGrid({super.key}); const MediaGrid({super.key});
@ -21,16 +17,6 @@ class MediaGrid extends ConsumerStatefulWidget {
class _MediaGridState extends ConsumerState<MediaGrid> { class _MediaGridState extends ConsumerState<MediaGrid> {
final ScrollController _scrollController = ScrollController(); 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 @override
void initState() { void initState() {
@ -49,210 +35,115 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaState = ref.watch(mediaProvider); final MediaState mediaState = ref.watch(mediaProvider);
final mediaNotifier = ref.read(mediaProvider.notifier); final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Scaffold( return Scaffold(
key: _scaffoldKey, body: RefreshIndicator(
appBar: AppBar( onRefresh: () async {
title: GestureDetector( mediaNotifier.setTag(null);
child: Row( _scrollController.jumpTo(0);
spacing: 10, await mediaNotifier.loadMedia();
children: [ },
Image.asset( child: CustomScrollView(
'assets/images/f0ck_small.webp', controller: _scrollController,
fit: BoxFit.fitHeight, slivers: [
SliverAppBar(
floating: true,
snap: true,
title: GestureDetector(
onTap: () {
mediaNotifier.setTag(null);
_scrollController.jumpTo(0);
},
child: Row(
children: [
Image.asset(
'assets/images/f0ck_small.webp',
fit: BoxFit.fitHeight,
),
const SizedBox(width: 10),
const Text('fApp', style: TextStyle(fontSize: 24)),
],
),
), ),
Text('fApp', style: TextStyle(fontSize: 24)), actions: [
], IconButton(
), icon: const Icon(Icons.search),
onTap: () { onPressed: () async {
mediaNotifier.setTag(null); await showSearch(
_scrollController.jumpTo(0); context: context,
}, delegate: CustomSearchDelegate(),
), );
actions: [ },
IconButton( ),
icon: Icon(Icons.search), IconButton(
onPressed: () { icon: Icon(
showSearch( mediaState.random
context: context, ? Icons.shuffle_on_outlined
delegate: CustomSearchDelegate(), : Icons.shuffle,
); ),
//mediaNotifier.setTag('drachenlord'); onPressed: () {
//_scrollController.jumpTo(0); mediaNotifier.toggleRandom();
}, _scrollController.jumpTo(0);
), },
IconButton( ),
icon: Icon( Builder(
mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle, builder: (context) {
return IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
);
},
),
],
), ),
onPressed: () { SliverPadding(
mediaNotifier.toggleRandom(); padding: EdgeInsets.zero,
_scrollController.jumpTo(0); sliver: SliverGrid(
}, delegate: SliverChildBuilderDelegate(
), (context, index) {
IconButton( if (index >= mediaState.mediaItems.length) {
icon: const Icon(Icons.menu), return const Center(child: CircularProgressIndicator());
onPressed: () { }
_scaffoldKey.currentState?.openEndDrawer(); return MediaTile(item: mediaState.mediaItems[index]);
}, },
), childCount:
], mediaState.mediaItems.length +
), (mediaState.isLoading ? 1 : 0),
bottomNavigationBar: BottomAppBar( ),
height: 50, gridDelegate: mediaState.crossAxisCount == 0
child: Row( ? const SliverGridDelegateWithMaxCrossAxisExtent(
mainAxisAlignment: MainAxisAlignment.spaceAround, maxCrossAxisExtent: 150,
children: [ crossAxisSpacing: 5,
const Text('type: '), mainAxisSpacing: 5,
DropdownButton<String>( childAspectRatio: 1,
value: mediaTypes[mediaState.typeIndex], )
isDense: true, : SliverGridDelegateWithFixedCrossAxisCount(
items: mediaTypes.map((String value) { crossAxisCount: mediaState.crossAxisCount,
return DropdownMenuItem<String>( crossAxisSpacing: 5,
value: value, mainAxisSpacing: 5,
child: Text(value), childAspectRatio: 1,
); ),
}).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( bottomNavigationBar: FilterBar(
child: ListView( mediaNotifier: mediaNotifier,
padding: EdgeInsets.zero, mediaState: mediaState,
children: [ scrollController: _scrollController,
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 themeName = entry.key;
final themeData = entry.value;
final currentTheme = ref.watch(themeNotifierProvider);
final 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'),
),
);
},
),
],
),
), ),
endDrawer: EndDrawer(ref: ref),
endDrawerEnableOpenDragGesture: false,
persistentFooterButtons: mediaState.tag != null persistentFooterButtons: mediaState.tag != null
? [ ? [
Center( Center(
@ -266,62 +157,6 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
), ),
] ]
: null, : null,
body: RefreshIndicator(
onRefresh: () async {
mediaNotifier.resetMedia();
_scrollController.jumpTo(0);
},
child: GridView.builder(
key: const PageStorageKey('mediaGrid'),
controller: _scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _calculateCrossAxisCount(
context,
mediaState.crossAxisCount,
),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5.0,
),
itemCount:
mediaState.mediaItems.length + (mediaState.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= mediaState.mediaItems.length) {
return const Center(child: CircularProgressIndicator());
}
final item = mediaState.mediaItems[index];
return InkWell(
onTap: () async {
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,
),
),
],
),
);
},
),
),
); );
} }
} }

View File

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

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -6,26 +7,28 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:f0ckapp/models/mediaitem_model.dart'; import 'package:f0ckapp/models/mediaitem_model.dart';
import 'package:f0ckapp/models/suggestion_model.dart'; import 'package:f0ckapp/models/suggestion_model.dart';
final FlutterSecureStorage storage = FlutterSecureStorage(); final FlutterSecureStorage storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<List<MediaItem>> fetchMedia({ Future<List<MediaItem>> fetchMedia({
int? older, int? older,
String? type, String type = 'image',
int? mode, int mode = 0,
bool? random, bool random = false,
String? tag, String? tag,
}) async { }) async {
final Uri url = Uri.parse('https://api.f0ck.me/items/get').replace( final Uri url = Uri.parse('https://api.f0ck.me/items/get').replace(
queryParameters: { queryParameters: {
'type': type ?? 'image', 'type': type,
'mode': (mode ?? 0).toString(), 'mode': mode.toString(),
'random': (random! ? 1 : 0).toString(), 'random': (random ? 1 : 0).toString(),
if (tag != null) 'tag': tag, if (tag != null) 'tag': tag,
if (older != null) 'older': older.toString(), if (older != null) 'older': older.toString(),
}, },
); );
final response = await http.get(url); final http.Response response = await http.get(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body); final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((item) => MediaItem.fromJson(item)).toList(); return jsonList.map((item) => MediaItem.fromJson(item)).toList();
@ -37,7 +40,7 @@ Future<List<MediaItem>> fetchMedia({
Future<MediaItem> fetchMediaDetail(int itemId) async { Future<MediaItem> fetchMediaDetail(int itemId) async {
final Uri url = Uri.parse('https://api.f0ck.me/item/${itemId.toString()}'); final Uri url = Uri.parse('https://api.f0ck.me/item/${itemId.toString()}');
final response = await http.get(url); final http.Response response = await http.get(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final Map<String, dynamic> jsonResponse = jsonDecode(response.body); final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
@ -50,51 +53,59 @@ Future<MediaItem> fetchMediaDetail(int itemId) async {
} }
Future<List<Suggestion>> fetchSuggestions(String query) async { Future<List<Suggestion>> fetchSuggestions(String query) async {
final Uri uri = Uri.parse( final Uri uri = Uri.parse('https://api.f0ck.me/search/?q=$query');
'https://f0ck.me/api/v2/admin/tags/suggest?q=$query', try {
); // wip: new route in pyapi final http.Response response = await http
final response = await http.get(uri); .get(uri)
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final Map<String, dynamic> decoded = jsonDecode(response.body); final dynamic decoded = jsonDecode(response.body);
if (decoded['success'] == true && decoded.containsKey('suggestions')) { if (decoded is List) {
final List<dynamic> suggestionsList = decoded['suggestions']; final suggestions = decoded
return suggestionsList .map((item) => Suggestion.fromJson(item as Map<String, dynamic>))
.map( .toList();
(dynamic jsonItem) => suggestions.sort((a, b) => b.score.compareTo(a.score));
Suggestion.fromJson(jsonItem as Map<String, dynamic>), return suggestions;
) } else {
.toList() throw Exception('Unerwartetes Format: Es wurde eine Liste erwartet.');
..sort( }
(Suggestion a, Suggestion b) => } else if (response.statusCode == 400) {
(b.score * b.tagged).compareTo(a.score * a.tagged), 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 { } else {
throw Exception('Nichts gefunden.'); throw Exception(
'Fehler beim Abrufen der Vorschläge: ${response.statusCode}',
);
} }
} else { } on TimeoutException {
throw Exception( throw Exception('Anfrage an die API hat zu lange gedauert.');
'Fehler beim Abrufen der Vorschläge: ${response.statusCode}', } catch (e) {
); throw Exception('Fehler bei der Verarbeitung der Anfrage: $e');
} }
} }
Future<bool> login(String username, String password) async { Future<bool> login(String username, String password) async {
final Uri url = Uri.parse('https://api.f0ck.me/login'); final Uri url = Uri.parse('https://api.f0ck.me/login');
final response = await http.post( final http.Response response = await http.post(
url, url,
body: {'username': username, 'password': password}, body: {'username': username, 'password': password},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final dynamic data = jsonDecode(response.body); final dynamic data = jsonDecode(response.body);
final dynamic token = data['token']; final token = data['token'];
if (token != null) {
await storage.write(key: "token", value: token); await storage.write(key: "token", value: token);
return true;
return true; } else {
throw Exception('Token nicht im Response enthalten.');
}
} else { } else {
return false; throw Exception('Login fehlgeschlagen: ${response.statusCode}');
} }
} }

View File

@ -1,20 +1,25 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:f0ckapp/services/api_service.dart'; import 'package:f0ckapp/services/api_service.dart';
import 'package:f0ckapp/models/suggestion_model.dart'; import 'package:f0ckapp/models/suggestion_model.dart';
import 'package:f0ckapp/providers/media_provider.dart'; import 'package:f0ckapp/providers/media_provider.dart';
class CustomSearchDelegate extends SearchDelegate<String> { class CustomSearchDelegate extends SearchDelegate<String> {
Timer? _debounceTimer;
List<Suggestion>? _suggestions;
bool _isLoading = false;
String? _error;
String _lastFetchedQuery = "";
@override @override
List<Widget> buildActions(BuildContext context) { List<Widget> buildActions(BuildContext context) {
return [ return [
IconButton( IconButton(
icon: Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
query = ''; query = '';
_clearResults();
showSuggestions(context); showSuggestions(context);
}, },
), ),
@ -24,8 +29,11 @@ class CustomSearchDelegate extends SearchDelegate<String> {
@override @override
Widget buildLeading(BuildContext context) { Widget buildLeading(BuildContext context) {
return IconButton( return IconButton(
icon: Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, 'null'), onPressed: () {
_debounceTimer?.cancel();
close(context, 'null');
},
); );
} }
@ -36,35 +44,58 @@ class CustomSearchDelegate extends SearchDelegate<String> {
@override @override
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
if (query.isEmpty) { return StatefulBuilder(
return Container(padding: EdgeInsets.all(16.0), child: Text('')); builder: (BuildContext context, void Function(void Function()) setState) {
} if (query.isEmpty) {
_debounceTimer?.cancel();
final Future<List<Suggestion>> futureSuggestions = Future.delayed( return Container(padding: const EdgeInsets.all(16.0), child: const Text(''));
Duration(milliseconds: 300), }
() => fetchSuggestions(query),
); if (query != _lastFetchedQuery) {
_debounceTimer?.cancel();
return FutureBuilder<List<Suggestion>>( _isLoading = true;
future: futureSuggestions, _error = null;
builder: (BuildContext context, AsyncSnapshot<List<Suggestion>> snapshot) { _suggestions = null;
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator()); _debounceTimer = Timer(Duration(milliseconds: 500), () async {
} try {
if (snapshot.hasError) { final List<Suggestion> results = await fetchSuggestions(query);
return Center(child: Text("Fehler: ${snapshot.error}")); _lastFetchedQuery = query;
} setState(() {
if (!snapshot.hasData || snapshot.data!.isEmpty) { _suggestions = results;
return Center(child: Text("Keine Vorschläge gefunden.")); _isLoading = false;
});
} catch (e) {
_lastFetchedQuery = query;
setState(() {
_error = e.toString();
_suggestions = [];
_isLoading = false;
});
}
});
return Center(child: _buildLoadingIndicator());
}
if (_isLoading) {
return Center(child: _buildLoadingIndicator());
}
if (_error != null) {
return Center(child: Text("Fehler: $_error"));
}
if (_suggestions == null || _suggestions!.isEmpty) {
return Center(child: const Text("Keine Ergebnisse gefunden."));
} }
final List<Suggestion> suggestions = snapshot.data!;
return Consumer( return Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) { builder: (BuildContext context, WidgetRef ref, Widget? child) {
return ListView.builder( return ListView.builder(
itemCount: suggestions.length, itemCount: _suggestions!.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final Suggestion suggestion = suggestions[index]; final Suggestion suggestion = _suggestions![index];
return ListTile( return ListTile(
title: Text(suggestion.tag), title: Text(suggestion.tag),
subtitle: Text( subtitle: Text(
@ -83,4 +114,32 @@ class CustomSearchDelegate extends SearchDelegate<String> {
}, },
); );
} }
Widget _buildLoadingIndicator() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(strokeWidth: 3.0),
const SizedBox(height: 12),
const Text(
'Vorschläge werden geladen...',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
);
}
void _clearResults() {
_debounceTimer?.cancel();
_suggestions = null;
_isLoading = false;
_error = null;
_lastFetchedQuery = "";
}
@override
void close(BuildContext context, String result) {
_debounceTimer?.cancel();
super.close(context, result);
}
} }

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

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

View File

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

View File

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

View File

@ -13,8 +13,14 @@ import 'package:f0ckapp/providers/media_provider.dart';
class VideoWidget extends ConsumerStatefulWidget { class VideoWidget extends ConsumerStatefulWidget {
final MediaItem details; final MediaItem details;
final bool isActive; final bool isActive;
final bool fullScreen;
const VideoWidget({super.key, required this.details, required this.isActive}); const VideoWidget({
super.key,
required this.details,
required this.isActive,
this.fullScreen = false,
});
@override @override
ConsumerState<VideoWidget> createState() => _VideoWidgetState(); ConsumerState<VideoWidget> createState() => _VideoWidgetState();
@ -90,17 +96,15 @@ class _VideoWidgetState extends ConsumerState<VideoWidget> {
bool isAudio = widget.details.mime.startsWith('audio'); bool isAudio = widget.details.mime.startsWith('audio');
return Column( if (widget.fullScreen) {
mainAxisSize: MainAxisSize.min, return Stack(
children: [ children: [
AspectRatio( Center(
aspectRatio: _controller.value.isInitialized child: AspectRatio(
? _controller.value.aspectRatio aspectRatio: _controller.value.isInitialized
: 9 / 16, ? _controller.value.aspectRatio
child: Stack( : 9 / 16,
alignment: Alignment.topCenter, child: GestureDetector(
children: [
GestureDetector(
onTap: _onTap, onTap: _onTap,
child: isAudio child: isAudio
? CachedNetworkImage( ? CachedNetworkImage(
@ -118,24 +122,71 @@ class _VideoWidgetState extends ConsumerState<VideoWidget> {
? CachedVideoPlayerPlus(_controller) ? CachedVideoPlayerPlus(_controller)
: const Center(child: CircularProgressIndicator()), : const Center(child: CircularProgressIndicator()),
), ),
if (_controller.value.isInitialized && _showControls) ...[ ),
IgnorePointer( ),
ignoring: true, if (_controller.value.isInitialized && _showControls)
child: Container( Positioned.fill(
color: Colors.black.withValues(alpha: 0.5), child: GestureDetector(
width: double.infinity, onTap: _onTap,
height: double.infinity, child: Container(
color: Colors.black.withValues(alpha: 0.5),
child: VideoControlsOverlay(
controller: _controller,
button: () => _onTap(ctrlButton: true),
), ),
), ),
VideoControlsOverlay( ),
controller: _controller, ),
button: () => _onTap(ctrlButton: true), ],
);
} else {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: _controller.value.isInitialized
? _controller.value.aspectRatio
: 9 / 16,
child: Stack(
alignment: Alignment.topCenter,
children: [
GestureDetector(
onTap: _onTap,
child: isAudio
? CachedNetworkImage(
imageUrl: widget.details.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset(
'assets/images/music.webp',
fit: BoxFit.contain,
width: double.infinity,
),
)
: _controller.value.isInitialized
? CachedVideoPlayerPlus(_controller)
: const Center(child: CircularProgressIndicator()),
), ),
if (_controller.value.isInitialized && _showControls) ...[
IgnorePointer(
ignoring: true,
child: Container(
color: Colors.black.withValues(alpha: 0.5),
width: double.infinity,
height: double.infinity,
),
),
VideoControlsOverlay(
controller: _controller,
button: () => _onTap(ctrlButton: true),
),
],
], ],
], ),
), ),
), ],
], );
); }
} }
} }

View File

@ -150,14 +150,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -248,14 +240,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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: html:
dependency: transitive dependency: transitive
description: description:
@ -320,14 +304,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -393,7 +369,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: "direct main" dependency: transitive
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -440,54 +416,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: platform:
dependency: transitive dependency: transitive
description: description:

View File

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

View File

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