Compare commits

..

2 Commits

Author SHA1 Message Date
5876c809a5 v1.1.12+42
All checks were successful
Flutter Schmutter / build (push) Successful in 3m45s
- search schmearch
2025-06-10 11:07:00 +02:00
c35308fbc1 v1.1.11+41
All checks were successful
Flutter Schmutter / build (push) Successful in 3m57s
- fixed: duplicates on the frontpage
- new: search by tag
2025-06-10 08:39:55 +02:00
11 changed files with 273 additions and 53 deletions

View File

@ -30,7 +30,7 @@
<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="true"/> <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"/>

View File

@ -28,20 +28,20 @@ class F0ckApp extends ConsumerWidget {
routes: [ routes: [
GoRoute( GoRoute(
path: '/', path: '/',
builder: (context, state) { builder: (BuildContext context, GoRouterState state) {
return const MediaGrid(); return const MediaGrid();
}, },
), ),
GoRoute( GoRoute(
path: '/:rest(.*)', path: '/:rest(.*)',
builder: (context, state) { builder: (context, state) {
final isInternalLink = (state.extra is bool && state.extra == true); final bool isInternalLink = (state.extra is bool && state.extra == true);
final fullPath = state.matchedLocation; final String fullPath = state.matchedLocation;
final regExp = RegExp( final regExp = RegExp(
r'^(?:/tag/(?<tag>.+?))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$', r'^(?:/tag/(?<tag>.+?))?(?:/(?<mime>image|audio|video))?(?:/(?<itemid>\d+))?$',
); );
final match = regExp.firstMatch(fullPath); final RegExpMatch? match = regExp.firstMatch(fullPath);
if (match == null) { if (match == null) {
return const Scaffold(body: Center(child: Text('Ungültiger Link'))); return const Scaffold(body: Center(child: Text('Ungültiger Link')));
@ -51,12 +51,12 @@ class F0ckApp extends ConsumerWidget {
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 preloadOffset = 50; const int preloadOffset = 50;
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (BuildContext context, WidgetRef ref, Widget? child) {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final mediaNotifier = ref.read(mediaProvider.notifier); final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
if (!isInternalLink) { if (!isInternalLink) {
mediaNotifier.setType(mime ?? "alles"); mediaNotifier.setType(mime ?? "alles");
mediaNotifier.setTag(tag); mediaNotifier.setTag(tag);
@ -79,7 +79,7 @@ class F0ckApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeNotifierProvider); final ThemeData theme = ref.watch(themeNotifierProvider);
return MaterialApp.router( return MaterialApp.router(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
routerConfig: _router, routerConfig: _router,

View File

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

View File

@ -102,9 +102,18 @@ class MediaNotifier extends StateNotifier<MediaState> {
} }
void addMediaItems(List<MediaItem> newItems) { void addMediaItems(List<MediaItem> newItems) {
final updated = List<MediaItem>.from(state.mediaItems)..addAll(newItems); final Set<int> existingIds = state.mediaItems
.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); state = state.replace(mediaItems: updated);
} }
}
Future<void> loadMedia({int? id}) async { Future<void> loadMedia({int? id}) async {
if (state.isLoading) return; if (state.isLoading) return;

View File

@ -56,13 +56,13 @@ class _DetailViewState extends ConsumerState<DetailView> {
try { try {
await ref.read(mediaProvider.notifier).loadMedia(); await ref.read(mediaProvider.notifier).loadMedia();
} catch (e) { } catch (e) {
_showError("Fehler beim Laden der Medien: $e"); _showMsg("Fehler beim Laden der Medien: $e");
} finally { } finally {
setState(() => isLoading = false); setState(() => isLoading = false);
} }
} }
void _showError(String message) { void _showMsg(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
..removeCurrentSnackBar() ..removeCurrentSnackBar()
@ -70,15 +70,15 @@ class _DetailViewState extends ConsumerState<DetailView> {
} }
Future<void> _downloadMedia() async { Future<void> _downloadMedia() async {
final mediaState = ref.read(mediaProvider); final MediaState mediaState = ref.read(mediaProvider);
final currentItem = mediaState.mediaItems[_currentIndex]; final MediaItem currentItem = mediaState.mediaItems[_currentIndex];
if (Platform.isAndroid || Platform.isIOS) { if (Platform.isAndroid || Platform.isIOS) {
var status = await Permission.storage.status; PermissionStatus status = await Permission.storage.status;
if (!status.isGranted) { if (!status.isGranted) {
status = await Permission.storage.request(); status = await Permission.storage.request();
if (!status.isGranted) { if (!status.isGranted) {
_showError("Speicherberechtigung wurde nicht erteilt."); _showMsg("Speicherberechtigung wurde nicht erteilt.");
return; return;
} }
} }
@ -86,17 +86,17 @@ class _DetailViewState extends ConsumerState<DetailView> {
String localPath; String localPath;
if (Platform.isAndroid) { if (Platform.isAndroid) {
final directory = await getExternalStorageDirectory(); final Directory? directory = await getExternalStorageDirectory();
localPath = "${directory!.path}/Download/fApp"; localPath = "${directory!.path}/Download/fApp";
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final directory = await getApplicationDocumentsDirectory(); final Directory directory = await getApplicationDocumentsDirectory();
localPath = directory.path; localPath = directory.path;
} else { } else {
final directory = await getTemporaryDirectory(); final Directory directory = await getTemporaryDirectory();
localPath = directory.path; localPath = directory.path;
} }
final savedDir = Directory(localPath); final Directory savedDir = Directory(localPath);
if (!await savedDir.exists()) { if (!await savedDir.exists()) {
await savedDir.create(recursive: true); await savedDir.create(recursive: true);
} }
@ -111,10 +111,10 @@ class _DetailViewState extends ConsumerState<DetailView> {
); );
if (mounted) { if (mounted) {
_showError('Download gestartet: ${currentItem.mediaUrl}'); _showMsg('Download gestartet: ${currentItem.mediaUrl}');
} }
} catch (e) { } catch (e) {
_showError('Download fehlgeschlagen: $e'); _showMsg('Download fehlgeschlagen: $e');
} }
} }
@ -126,11 +126,10 @@ class _DetailViewState extends ConsumerState<DetailView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaState = ref.watch(mediaProvider); final MediaState mediaState = ref.watch(mediaProvider);
final int itemIndex = mediaState.mediaItems.indexWhere( final int itemIndex = mediaState.mediaItems.indexWhere(
(item) => item.id == widget.initialItemId, (item) => item.id == widget.initialItemId,
); );
print('itemIndex: ${itemIndex}; initial: ${widget.initialItemId}');
if (itemIndex == -1) { if (itemIndex == -1) {
Future.microtask(() { Future.microtask(() {
@ -168,7 +167,7 @@ class _DetailViewState extends ConsumerState<DetailView> {
IconButton( IconButton(
icon: const Icon(Icons.fullscreen), icon: const Icon(Icons.fullscreen),
onPressed: () { onPressed: () {
_showError('download ist wip'); _showMsg('fullscreen ist wip');
}, },
), ),
IconButton( IconButton(
@ -261,7 +260,7 @@ class _DetailViewState extends ConsumerState<DetailView> {
} }
Widget _buildMediaItem(MediaItem item, bool isActive) { Widget _buildMediaItem(MediaItem item, bool isActive) {
final mediaNotifier = ref.read(mediaProvider.notifier); final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(

View File

@ -4,12 +4,11 @@ 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:go_router/go_router.dart';
import 'package:f0ckapp/models/mediaitem_model.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/utils/appversion_util.dart';
import 'package:f0ckapp/providers/theme_provider.dart'; import 'package:f0ckapp/providers/theme_provider.dart';
import 'package:f0ckapp/utils/customsearchdelegate_util.dart';
const List<String> mediaTypes = ["alles", "image", "video", "audio"];
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
class MediaGrid extends ConsumerStatefulWidget { class MediaGrid extends ConsumerStatefulWidget {
const MediaGrid({super.key}); const MediaGrid({super.key});
@ -55,8 +54,8 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
@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, key: _scaffoldKey,
@ -78,6 +77,15 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
}, },
), ),
actions: [ actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () async {
await showSearch(
context: context,
delegate: CustomSearchDelegate(),
);
},
),
IconButton( IconButton(
icon: Icon( icon: Icon(
mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle, mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle,
@ -151,7 +159,7 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
), ),
child: null, child: null,
), ),
ExpansionTile( /*ExpansionTile(
title: const Text('Login'), title: const Text('Login'),
children: [ children: [
Padding( Padding(
@ -181,7 +189,7 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
const SnackBar( const SnackBar(
content: Text("noch nicht implementiert lol"), content: Text("noch nicht implementiert lol"),
), ),
/*final success = await login( final success = await login(
_usernameController.text, _usernameController.text,
_passwordController.text, _passwordController.text,
); );
@ -192,7 +200,7 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Login fehlgeschlagen!")), SnackBar(content: Text("Login fehlgeschlagen!")),
); );
}*/ }
); );
}, },
child: const Text('Login'), child: const Text('Login'),
@ -201,7 +209,7 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
), ),
), ),
], ],
), ),*/
ExpansionTile( ExpansionTile(
title: const Text('Theme'), title: const Text('Theme'),
children: [ children: [
@ -209,10 +217,10 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
children: themeMap.entries.map((entry) { children: themeMap.entries.map((entry) {
final themeName = entry.key; final String themeName = entry.key;
final themeData = entry.value; final ThemeData themeData = entry.value;
final currentTheme = ref.watch(themeNotifierProvider); final ThemeData currentTheme = ref.watch(themeNotifierProvider);
final isSelected = currentTheme == themeData; final bool isSelected = currentTheme == themeData;
return ListTile( return ListTile(
title: Text(themeName), title: Text(themeName),
selected: isSelected, selected: isSelected,
@ -272,11 +280,11 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
), ),
itemCount: itemCount:
mediaState.mediaItems.length + (mediaState.isLoading ? 1 : 0), mediaState.mediaItems.length + (mediaState.isLoading ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (BuildContext context, int index) {
if (index >= mediaState.mediaItems.length) { if (index >= mediaState.mediaItems.length) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final item = mediaState.mediaItems[index]; final MediaItem item = mediaState.mediaItems[index];
return InkWell( return InkWell(
onTap: () async { onTap: () async {

View File

@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 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';
final storage = FlutterSecureStorage(); final FlutterSecureStorage storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<List<MediaItem>> fetchMedia({ Future<List<MediaItem>> fetchMedia({
int? older, int? older,
@ -24,7 +28,7 @@ Future<List<MediaItem>> fetchMedia({
}, },
); );
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();
@ -36,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);
@ -48,17 +52,53 @@ Future<MediaItem> fetchMediaDetail(int itemId) async {
} }
} }
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 { 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 data = jsonDecode(response.body); final dynamic data = jsonDecode(response.body);
final token = data['token']; final dynamic token = data['token'];
await storage.write(key: "token", value: token); await storage.write(key: "token", value: token);

View File

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

View File

@ -45,7 +45,7 @@ class _VideoWidgetState extends ConsumerState<VideoWidget> {
} }
_controller.setLooping(true); _controller.setLooping(true);
final muted = ref.read(mediaProvider).muted; final bool muted = ref.read(mediaProvider).muted;
_controller.setVolume(muted ? 0.0 : 1.0); _controller.setVolume(muted ? 0.0 : 1.0);
} }
@ -82,7 +82,7 @@ class _VideoWidgetState extends ConsumerState<VideoWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final muted = ref.watch(mediaProvider).muted; final bool muted = ref.watch(mediaProvider).muted;
if (_controller.value.isInitialized && if (_controller.value.isInitialized &&
_controller.value.volume != (muted ? 0.0 : 1.0)) { _controller.value.volume != (muted ? 0.0 : 1.0)) {
_controller.setVolume(muted ? 0.0 : 1.0); _controller.setVolume(muted ? 0.0 : 1.0);

View File

@ -17,8 +17,8 @@ class VideoControlsOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaState = ref.watch(mediaProvider); final MediaState mediaState = ref.watch(mediaProvider);
final mediaNotifier = ref.read(mediaProvider.notifier); final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier);
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,

View File

@ -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.10+40 version: 1.1.12+42
environment: environment:
sdk: ^3.9.0-100.2.beta sdk: ^3.9.0-100.2.beta