v1.1.11+41
All checks were successful
Flutter Schmutter / build (push) Successful in 3m57s

- fixed: duplicates on the frontpage
- new: search by tag
This commit is contained in:
Flummi 2025-06-10 08:39:55 +02:00
parent e945844151
commit c35308fbc1
11 changed files with 187 additions and 35 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,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<String, dynamic> json) {
return Suggestion(
tag: json['tag'].toString(),
tagged: int.tryParse(json['tagged'].toString()) ?? 0,
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

@ -7,6 +7,7 @@ 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/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> mediaTypes = ["alles", "image", "video", "audio"];
const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"]; const List<String> mediaModes = ["sfw", "nsfw", "untagged", "all"];
@ -78,6 +79,17 @@ class _MediaGridState extends ConsumerState<MediaGrid> {
}, },
), ),
actions: [ actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: CustomSearchDelegate(),
);
//mediaNotifier.setTag('drachenlord');
//_scrollController.jumpTo(0);
},
),
IconButton( IconButton(
icon: Icon( icon: Icon(
mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle, mediaState.random ? Icons.shuffle_on_outlined : Icons.shuffle,

View File

@ -4,8 +4,9 @@ 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 = FlutterSecureStorage();
Future<List<MediaItem>> fetchMedia({ Future<List<MediaItem>> fetchMedia({
int? older, int? older,
@ -48,6 +49,36 @@ Future<MediaItem> fetchMediaDetail(int itemId) async {
} }
} }
Future<List<Suggestion>> 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<String, dynamic> decoded = jsonDecode(response.body);
if (decoded['success'] == true && decoded.containsKey('suggestions')) {
final List<dynamic> suggestionsList = decoded['suggestions'];
return suggestionsList
.map(
(dynamic jsonItem) =>
Suggestion.fromJson(jsonItem as Map<String, dynamic>),
)
.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<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');
@ -57,8 +88,8 @@ Future<bool> login(String username, String password) async {
); );
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,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<String> {
@override
List<Widget> 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<List<Suggestion>> futureSuggestions = Future.delayed(
Duration(milliseconds: 300),
() => fetchSuggestions(query),
);
return FutureBuilder<List<Suggestion>>(
future: futureSuggestions,
builder: (BuildContext context, AsyncSnapshot<List<Suggestion>> 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<Suggestion> 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);
},
);
},
);
},
);
},
);
}
}

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.11+41
environment: environment:
sdk: ^3.9.0-100.2.beta sdk: ^3.9.0-100.2.beta