diff --git a/lib/models/suggestion_model.dart b/lib/models/suggestion_model.dart index 8949f70..0f35b2d 100644 --- a/lib/models/suggestion_model.dart +++ b/lib/models/suggestion_model.dart @@ -3,12 +3,16 @@ class Suggestion { final int tagged; 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 json) { return Suggestion( tag: json['tag'].toString(), - tagged: int.tryParse(json['tagged'].toString()) ?? 0, + tagged: json['tagged'], score: (json['score'] as num).toDouble(), ); } diff --git a/lib/screens/mediagrid_screen.dart b/lib/screens/mediagrid_screen.dart index feabe47..09bf1c8 100644 --- a/lib/screens/mediagrid_screen.dart +++ b/lib/screens/mediagrid_screen.dart @@ -4,14 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:f0ckapp/models/mediaitem_model.dart'; import 'package:f0ckapp/providers/media_provider.dart'; import 'package:f0ckapp/utils/appversion_util.dart'; import 'package:f0ckapp/providers/theme_provider.dart'; import 'package:f0ckapp/utils/customsearchdelegate_util.dart'; -const List mediaTypes = ["alles", "image", "video", "audio"]; -const List mediaModes = ["sfw", "nsfw", "untagged", "all"]; - class MediaGrid extends ConsumerStatefulWidget { const MediaGrid({super.key}); @@ -56,8 +54,8 @@ class _MediaGridState extends ConsumerState { @override Widget build(BuildContext context) { - 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 Scaffold( key: _scaffoldKey, @@ -80,14 +78,12 @@ class _MediaGridState extends ConsumerState { ), actions: [ IconButton( - icon: Icon(Icons.search), - onPressed: () { - showSearch( + icon: const Icon(Icons.search), + onPressed: () async { + await showSearch( context: context, delegate: CustomSearchDelegate(), ); - //mediaNotifier.setTag('drachenlord'); - //_scrollController.jumpTo(0); }, ), IconButton( @@ -163,7 +159,7 @@ class _MediaGridState extends ConsumerState { ), child: null, ), - ExpansionTile( + /*ExpansionTile( title: const Text('Login'), children: [ Padding( @@ -193,7 +189,7 @@ class _MediaGridState extends ConsumerState { const SnackBar( content: Text("noch nicht implementiert lol"), ), - /*final success = await login( + final success = await login( _usernameController.text, _passwordController.text, ); @@ -204,7 +200,7 @@ class _MediaGridState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Login fehlgeschlagen!")), ); - }*/ + } ); }, child: const Text('Login'), @@ -213,7 +209,7 @@ class _MediaGridState extends ConsumerState { ), ), ], - ), + ),*/ ExpansionTile( title: const Text('Theme'), children: [ @@ -221,10 +217,10 @@ class _MediaGridState extends ConsumerState { 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; + 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, @@ -284,11 +280,11 @@ class _MediaGridState extends ConsumerState { ), itemCount: mediaState.mediaItems.length + (mediaState.isLoading ? 1 : 0), - itemBuilder: (context, index) { + itemBuilder: (BuildContext context, int index) { if (index >= mediaState.mediaItems.length) { return const Center(child: CircularProgressIndicator()); } - final item = mediaState.mediaItems[index]; + final MediaItem item = mediaState.mediaItems[index]; return InkWell( onTap: () async { diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 3ab755a..4287644 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -6,7 +7,9 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:f0ckapp/models/mediaitem_model.dart'; import 'package:f0ckapp/models/suggestion_model.dart'; -final FlutterSecureStorage storage = FlutterSecureStorage(); +final FlutterSecureStorage storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), +); Future> fetchMedia({ int? older, @@ -25,7 +28,7 @@ Future> fetchMedia({ }, ); - final response = await http.get(url); + final http.Response response = await http.get(url); if (response.statusCode == 200) { final List jsonList = jsonDecode(response.body); return jsonList.map((item) => MediaItem.fromJson(item)).toList(); @@ -37,7 +40,7 @@ Future> fetchMedia({ Future fetchMediaDetail(int itemId) async { 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) { final Map jsonResponse = jsonDecode(response.body); @@ -50,39 +53,45 @@ 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); + 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 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), - ); + if (response.statusCode == 200) { + final dynamic decoded = jsonDecode(response.body); + + if (decoded is List) { + return decoded + .map((item) => Suggestion.fromJson(item as Map)) + .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 + ? error['detail']?.toString() ?? 'Unbekannter Fehler.' + : 'Unbekannter Fehler.'; + throw Exception('Client-Fehler 400: $message'); } else { - throw Exception('Nichts gefunden.'); + throw Exception( + 'Fehler beim Abrufen der Vorschläge: ${response.statusCode}', + ); } - } 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 login(String username, String password) async { final Uri url = Uri.parse('https://api.f0ck.me/login'); - final response = await http.post( + final http.Response response = await http.post( url, body: {'username': username, 'password': password}, ); diff --git a/lib/utils/customsearchdelegate_util.dart b/lib/utils/customsearchdelegate_util.dart index f661837..3bac980 100644 --- a/lib/utils/customsearchdelegate_util.dart +++ b/lib/utils/customsearchdelegate_util.dart @@ -1,20 +1,25 @@ 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 { + Timer? _debounceTimer; + List? _suggestions; + bool _isLoading = false; + String? _error; + String _lastFetchedQuery = ""; + @override List buildActions(BuildContext context) { return [ IconButton( - icon: Icon(Icons.clear), + icon: const Icon(Icons.clear), onPressed: () { query = ''; + _clearResults(); showSuggestions(context); }, ), @@ -24,8 +29,11 @@ class CustomSearchDelegate extends SearchDelegate { @override Widget buildLeading(BuildContext context) { return IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () => close(context, 'null'), + icon: const Icon(Icons.arrow_back), + onPressed: () { + _debounceTimer?.cancel(); + close(context, 'null'); + }, ); } @@ -36,35 +44,58 @@ class CustomSearchDelegate extends SearchDelegate { @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.")); + 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 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.")); } - final List suggestions = snapshot.data!; return Consumer( builder: (BuildContext context, WidgetRef ref, Widget? child) { return ListView.builder( - itemCount: suggestions.length, + itemCount: _suggestions!.length, itemBuilder: (BuildContext context, int index) { - final Suggestion suggestion = suggestions[index]; + final Suggestion suggestion = _suggestions![index]; return ListTile( title: Text(suggestion.tag), subtitle: Text( @@ -83,4 +114,32 @@ class CustomSearchDelegate extends SearchDelegate { }, ); } + + 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); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 756cc84..b8b0352 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.11+41 +version: 1.1.12+42 environment: sdk: ^3.9.0-100.2.beta