diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cb09871..21a7961 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,7 +30,7 @@ - + diff --git a/lib/main.dart b/lib/main.dart index 54aa3af..26dd4fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,20 +28,20 @@ class F0ckApp extends ConsumerWidget { routes: [ GoRoute( path: '/', - builder: (context, state) { + builder: (BuildContext context, GoRouterState state) { return const MediaGrid(); }, ), GoRoute( path: '/:rest(.*)', builder: (context, state) { - final isInternalLink = (state.extra is bool && state.extra == true); - final fullPath = state.matchedLocation; + final bool isInternalLink = (state.extra is bool && state.extra == true); + final String fullPath = state.matchedLocation; final regExp = RegExp( r'^(?:/tag/(?.+?))?(?:/(?image|audio|video))?(?:/(?\d+))?$', ); - final match = regExp.firstMatch(fullPath); + final RegExpMatch? match = regExp.firstMatch(fullPath); if (match == null) { 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? idStr = match.namedGroup('itemid'); final int? itemId = idStr != null ? int.tryParse(idStr) : null; - const preloadOffset = 50; + const int preloadOffset = 50; return Consumer( - builder: (context, ref, child) { + builder: (BuildContext context, WidgetRef ref, Widget? child) { WidgetsBinding.instance.addPostFrameCallback((_) async { - final mediaNotifier = ref.read(mediaProvider.notifier); + final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier); if (!isInternalLink) { mediaNotifier.setType(mime ?? "alles"); mediaNotifier.setTag(tag); @@ -79,7 +79,7 @@ class F0ckApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = ref.watch(themeNotifierProvider); + final ThemeData theme = ref.watch(themeNotifierProvider); return MaterialApp.router( debugShowCheckedModeBanner: false, routerConfig: _router, diff --git a/lib/models/suggestion_model.dart b/lib/models/suggestion_model.dart new file mode 100644 index 0000000..8949f70 --- /dev/null +++ b/lib/models/suggestion_model.dart @@ -0,0 +1,15 @@ +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 json) { + return Suggestion( + tag: json['tag'].toString(), + tagged: int.tryParse(json['tagged'].toString()) ?? 0, + score: (json['score'] as num).toDouble(), + ); + } +} diff --git a/lib/providers/media_provider.dart b/lib/providers/media_provider.dart index ad098bc..4dc0242 100644 --- a/lib/providers/media_provider.dart +++ b/lib/providers/media_provider.dart @@ -102,8 +102,17 @@ class MediaNotifier extends StateNotifier { } void addMediaItems(List newItems) { - final updated = List.from(state.mediaItems)..addAll(newItems); - state = state.replace(mediaItems: updated); + final Set existingIds = state.mediaItems + .map((item) => item.id) + .toSet(); + final List filteredItems = newItems + .where((item) => !existingIds.contains(item.id)) + .toList(); + if (filteredItems.isNotEmpty) { + final List updated = List.from(state.mediaItems) + ..addAll(filteredItems); + state = state.replace(mediaItems: updated); + } } Future loadMedia({int? id}) async { diff --git a/lib/screens/detailview_screen.dart b/lib/screens/detailview_screen.dart index 008099a..5e819ff 100644 --- a/lib/screens/detailview_screen.dart +++ b/lib/screens/detailview_screen.dart @@ -56,13 +56,13 @@ class _DetailViewState extends ConsumerState { try { await ref.read(mediaProvider.notifier).loadMedia(); } catch (e) { - _showError("Fehler beim Laden der Medien: $e"); + _showMsg("Fehler beim Laden der Medien: $e"); } finally { setState(() => isLoading = false); } } - void _showError(String message) { + void _showMsg(String message) { if (!mounted) return; ScaffoldMessenger.of(context) ..removeCurrentSnackBar() @@ -70,15 +70,15 @@ class _DetailViewState extends ConsumerState { } Future _downloadMedia() async { - final mediaState = ref.read(mediaProvider); - final currentItem = mediaState.mediaItems[_currentIndex]; + final MediaState mediaState = ref.read(mediaProvider); + final MediaItem currentItem = mediaState.mediaItems[_currentIndex]; if (Platform.isAndroid || Platform.isIOS) { - var status = await Permission.storage.status; + PermissionStatus status = await Permission.storage.status; if (!status.isGranted) { status = await Permission.storage.request(); if (!status.isGranted) { - _showError("Speicherberechtigung wurde nicht erteilt."); + _showMsg("Speicherberechtigung wurde nicht erteilt."); return; } } @@ -86,17 +86,17 @@ class _DetailViewState extends ConsumerState { String localPath; if (Platform.isAndroid) { - final directory = await getExternalStorageDirectory(); + final Directory? directory = await getExternalStorageDirectory(); localPath = "${directory!.path}/Download/fApp"; } else if (Platform.isIOS) { - final directory = await getApplicationDocumentsDirectory(); + final Directory directory = await getApplicationDocumentsDirectory(); localPath = directory.path; } else { - final directory = await getTemporaryDirectory(); + final Directory directory = await getTemporaryDirectory(); localPath = directory.path; } - final savedDir = Directory(localPath); + final Directory savedDir = Directory(localPath); if (!await savedDir.exists()) { await savedDir.create(recursive: true); } @@ -111,10 +111,10 @@ class _DetailViewState extends ConsumerState { ); if (mounted) { - _showError('Download gestartet: ${currentItem.mediaUrl}'); + _showMsg('Download gestartet: ${currentItem.mediaUrl}'); } } catch (e) { - _showError('Download fehlgeschlagen: $e'); + _showMsg('Download fehlgeschlagen: $e'); } } @@ -126,11 +126,10 @@ class _DetailViewState extends ConsumerState { @override Widget build(BuildContext context) { - final mediaState = ref.watch(mediaProvider); + final MediaState mediaState = ref.watch(mediaProvider); final int itemIndex = mediaState.mediaItems.indexWhere( (item) => item.id == widget.initialItemId, ); - print('itemIndex: ${itemIndex}; initial: ${widget.initialItemId}'); if (itemIndex == -1) { Future.microtask(() { @@ -168,7 +167,7 @@ class _DetailViewState extends ConsumerState { IconButton( icon: const Icon(Icons.fullscreen), onPressed: () { - _showError('download ist wip'); + _showMsg('fullscreen ist wip'); }, ), IconButton( @@ -261,7 +260,7 @@ class _DetailViewState extends ConsumerState { } Widget _buildMediaItem(MediaItem item, bool isActive) { - final mediaNotifier = ref.read(mediaProvider.notifier); + final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier); return SingleChildScrollView( child: Column( diff --git a/lib/screens/mediagrid_screen.dart b/lib/screens/mediagrid_screen.dart index af3aa31..feabe47 100644 --- a/lib/screens/mediagrid_screen.dart +++ b/lib/screens/mediagrid_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.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'; const List mediaTypes = ["alles", "image", "video", "audio"]; const List mediaModes = ["sfw", "nsfw", "untagged", "all"]; @@ -78,6 +79,17 @@ class _MediaGridState extends ConsumerState { }, ), actions: [ + IconButton( + icon: Icon(Icons.search), + onPressed: () { + showSearch( + context: context, + delegate: CustomSearchDelegate(), + ); + //mediaNotifier.setTag('drachenlord'); + //_scrollController.jumpTo(0); + }, + ), IconButton( icon: Icon( mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle, diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index c5dbeb2..3ab755a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -4,8 +4,9 @@ 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 storage = FlutterSecureStorage(); +final FlutterSecureStorage storage = FlutterSecureStorage(); Future> fetchMedia({ int? older, @@ -48,6 +49,36 @@ Future fetchMediaDetail(int itemId) async { } } +Future> fetchSuggestions(String query) async { + final Uri uri = Uri.parse( + 'https://f0ck.me/api/v2/admin/tags/suggest?q=$query', + ); // wip: new route in pyapi + final response = await http.get(uri); + + if (response.statusCode == 200) { + final Map decoded = jsonDecode(response.body); + if (decoded['success'] == true && decoded.containsKey('suggestions')) { + final List suggestionsList = decoded['suggestions']; + return suggestionsList + .map( + (dynamic jsonItem) => + Suggestion.fromJson(jsonItem as Map), + ) + .toList() + ..sort( + (Suggestion a, Suggestion b) => + (b.score * b.tagged).compareTo(a.score * a.tagged), + ); + } else { + throw Exception('Nichts gefunden.'); + } + } else { + throw Exception( + 'Fehler beim Abrufen der Vorschläge: ${response.statusCode}', + ); + } +} + Future login(String username, String password) async { final Uri url = Uri.parse('https://api.f0ck.me/login'); @@ -57,8 +88,8 @@ Future login(String username, String password) async { ); if (response.statusCode == 200) { - final data = jsonDecode(response.body); - final token = data['token']; + final dynamic data = jsonDecode(response.body); + final dynamic token = data['token']; await storage.write(key: "token", value: token); diff --git a/lib/utils/customsearchdelegate_util.dart b/lib/utils/customsearchdelegate_util.dart new file mode 100644 index 0000000..f661837 --- /dev/null +++ b/lib/utils/customsearchdelegate_util.dart @@ -0,0 +1,86 @@ +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 { + @override + List buildActions(BuildContext context) { + return [ + IconButton( + icon: Icon(Icons.clear), + onPressed: () { + query = ''; + showSuggestions(context); + }, + ), + ]; + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () => close(context, 'null'), + ); + } + + @override + Widget buildResults(BuildContext context) { + return Center(child: Text('Suchergebnisse für: "$query"')); + } + + @override + Widget buildSuggestions(BuildContext context) { + if (query.isEmpty) { + return Container(padding: EdgeInsets.all(16.0), child: Text('')); + } + + final Future> futureSuggestions = Future.delayed( + Duration(milliseconds: 300), + () => fetchSuggestions(query), + ); + + return FutureBuilder>( + future: futureSuggestions, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text("Fehler: ${snapshot.error}")); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Center(child: Text("Keine Vorschläge gefunden.")); + } + + final List suggestions = snapshot.data!; + 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); + }, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/video_widget.dart b/lib/widgets/video_widget.dart index 61debb4..cac9383 100644 --- a/lib/widgets/video_widget.dart +++ b/lib/widgets/video_widget.dart @@ -45,7 +45,7 @@ class _VideoWidgetState extends ConsumerState { } _controller.setLooping(true); - final muted = ref.read(mediaProvider).muted; + final bool muted = ref.read(mediaProvider).muted; _controller.setVolume(muted ? 0.0 : 1.0); } @@ -82,7 +82,7 @@ class _VideoWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - final muted = ref.watch(mediaProvider).muted; + 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); diff --git a/lib/widgets/videooverlay_widget.dart b/lib/widgets/videooverlay_widget.dart index 07b6a2b..d9beaf8 100644 --- a/lib/widgets/videooverlay_widget.dart +++ b/lib/widgets/videooverlay_widget.dart @@ -17,8 +17,8 @@ class VideoControlsOverlay extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - final mediaState = ref.watch(mediaProvider); - final mediaNotifier = ref.read(mediaProvider.notifier); + final MediaState mediaState = ref.watch(mediaProvider); + final MediaNotifier mediaNotifier = ref.read(mediaProvider.notifier); return Stack( alignment: Alignment.center, diff --git a/pubspec.yaml b/pubspec.yaml index d7106e1..756cc84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.10+40 +version: 1.1.11+41 environment: sdk: ^3.9.0-100.2.beta