From 73a44bb2692ac8035eb35d9c7c07992d18e2c24c Mon Sep 17 00:00:00 2001 From: Flummi Date: Sat, 21 Jun 2025 13:40:44 +0200 Subject: [PATCH] v1.4.1+62 --- lib/controller/localizationcontroller.dart | 58 +-- lib/controller/mediacontroller.dart | 92 +++-- lib/main.dart | 44 +-- lib/screens/fullscreen.dart | 2 +- lib/screens/mediadetail.dart | 440 +++++++++++---------- lib/screens/mediagrid.dart | 285 ++++++------- lib/screens/settings.dart | 2 - lib/services/api.dart | 3 + lib/utils/smartrefreshindicator.dart | 28 -- lib/widgets/favorite_avatars.dart | 42 -- lib/widgets/favoritesection.dart | 74 ++++ lib/widgets/media_tile.dart | 4 - lib/widgets/tagfooter.dart | 30 ++ lib/widgets/video_widget.dart | 17 +- pubspec.yaml | 2 +- 15 files changed, 608 insertions(+), 515 deletions(-) delete mode 100644 lib/utils/smartrefreshindicator.dart delete mode 100644 lib/widgets/favorite_avatars.dart create mode 100644 lib/widgets/favoritesection.dart create mode 100644 lib/widgets/tagfooter.dart diff --git a/lib/controller/localizationcontroller.dart b/lib/controller/localizationcontroller.dart index 7c2127a..f22453a 100644 --- a/lib/controller/localizationcontroller.dart +++ b/lib/controller/localizationcontroller.dart @@ -6,6 +6,13 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:encrypt_shared_preferences/provider.dart'; +const Map supportedLocales = { + 'en_US': Locale('en', 'US'), + 'de_DE': Locale('de', 'DE'), + 'fr_FR': Locale('fr', 'FR'), + 'nl_NL': Locale('nl', 'NL'), +}; + class MyTranslations extends Translations { static final MyTranslations instance = MyTranslations._internal(); MyTranslations._internal(); @@ -13,15 +20,18 @@ class MyTranslations extends Translations { static final Map> _translations = {}; static Future loadTranslations() async { - final locales = ['en_US', 'de_DE', 'fr_FR', 'nl_NL']; - for (final locale in locales) { - final String jsonString = await rootBundle.loadString( - 'assets/i18n/$locale.json', - ); - final Map jsonMap = json.decode(jsonString); - _translations[locale] = jsonMap.map( - (key, value) => MapEntry(key, value.toString()), - ); + for (final localeKey in supportedLocales.keys) { + try { + final String jsonString = await rootBundle.loadString( + 'assets/i18n/$localeKey.json', + ); + final Map jsonMap = json.decode(jsonString); + _translations[localeKey] = jsonMap.map( + (key, value) => MapEntry(key, value.toString()), + ); + } catch (e) { + debugPrint('Konnte Übersetzung für $localeKey nicht laden: $e'); + } } } @@ -32,7 +42,7 @@ class MyTranslations extends Translations { class LocalizationController extends GetxController { final EncryptedSharedPreferencesAsync storage = EncryptedSharedPreferencesAsync.getInstance(); - Rx currentLocale = const Locale('en', 'US').obs; + Rx currentLocale = supportedLocales['en_US']!.obs; @override void onInit() { @@ -41,25 +51,29 @@ class LocalizationController extends GetxController { } Future loadLocale() async { - String? savedLocale = await storage.getString( + String? savedLocaleKey = await storage.getString( 'locale', defaultValue: 'en_US', ); - if (savedLocale != null && savedLocale.isNotEmpty) { - final List parts = savedLocale.split('_'); - currentLocale.value = parts.length == 2 - ? Locale(parts[0], parts[1]) - : Locale(parts[0]); - Get.locale = currentLocale.value; - } + + final Locale locale = + supportedLocales[savedLocaleKey ?? 'en_US'] ?? + supportedLocales['en_US']!; + + currentLocale.value = locale; + Get.locale = locale; } Future changeLocale(Locale newLocale) async { + final String localeKey = supportedLocales.entries + .firstWhere( + (entry) => entry.value == newLocale, + orElse: () => supportedLocales.entries.first, + ) + .key; + currentLocale.value = newLocale; Get.updateLocale(newLocale); - await storage.setString( - 'locale', - '${newLocale.languageCode}_${newLocale.countryCode}', - ); + await storage.setString('locale', localeKey); } } diff --git a/lib/controller/mediacontroller.dart b/lib/controller/mediacontroller.dart index 4fa49ca..0ef9d58 100644 --- a/lib/controller/mediacontroller.dart +++ b/lib/controller/mediacontroller.dart @@ -10,7 +10,7 @@ const List mediaTypes = ["alles", "image", "video", "audio"]; const List mediaModes = ["sfw", "nsfw", "untagged", "all"]; class MediaController extends GetxController { - final ApiService _api = ApiService(); + final ApiService _api = Get.find(); final EncryptedSharedPreferencesAsync storage = EncryptedSharedPreferencesAsync.getInstance(); @@ -22,7 +22,7 @@ class MediaController extends GetxController { RxInt typeIndex = 0.obs; RxInt modeIndex = 0.obs; RxInt random = 0.obs; - Rxn tag = Rxn(); + Rxn tag = Rxn(null); RxBool muted = false.obs; Rx transitionType = PageTransition.opacity.obs; RxBool drawerSwipeEnabled = true.obs; @@ -45,14 +45,6 @@ class MediaController extends GetxController { } } - List get filteredItems { - final String typeStr = mediaTypes[typeIndex.value]; - return items.where((item) { - final bool typeOk = typeStr == "alles" || item.mime.startsWith(typeStr); - return typeOk; - }).toList(); - } - Future?> toggleFavorite( MediaItem item, bool isFavorite, @@ -64,68 +56,82 @@ class MediaController extends GetxController { } } - Future fetchInitial({int? id}) async { + Future _fetchItems({int? older, int? newer}) async { + if (loading.value) return null; loading.value = true; try { - final Feed result = await _api.fetchItems( + return await _api.fetchItems( + older: older, + newer: newer, type: typeIndex.value, mode: modeIndex.value, random: random.value, tag: tag.value, - older: id, ); + } catch (e) { + Get.snackbar( + 'Fehler beim Laden', + 'Die Daten konnten nicht abgerufen werden. Wo Internet?', + snackPosition: SnackPosition.BOTTOM, + ); + return null; + } finally { + loading.value = false; + } + } + + Future fetchInitial({int? id}) async { + final result = await _fetchItems(older: id); + if (result != null) { items.assignAll(result.items); atEnd.value = result.atEnd; atStart.value = result.atStart; - } finally { - loading.value = false; } } Future fetchMore() async { if (items.isEmpty || atEnd.value) return; - loading.value = true; - try { - final Feed result = await _api.fetchItems( - older: items.last.id, - type: typeIndex.value, - mode: modeIndex.value, - random: random.value, - tag: tag.value, - ); + final result = await _fetchItems(older: items.last.id); + if (result != null) { + final Set existingIds = items.map((e) => e.id).toSet(); final List newItems = result.items - .where((item) => !items.any((existing) => existing.id == item.id)) + .where((item) => !existingIds.contains(item.id)) .toList(); items.addAll(newItems); items.refresh(); atEnd.value = result.atEnd; - } finally { - loading.value = false; } } Future fetchNewer() async { if (items.isEmpty || atStart.value) return 0; - loading.value = true; - try { - final Feed result = await _api.fetchItems( - newer: items.first.id, - type: typeIndex.value, - mode: modeIndex.value, - random: random.value, - tag: tag.value, - ); - int oldLength = filteredItems.length; + final oldLength = items.length; + final result = await _fetchItems(newer: items.first.id); + if (result != null) { + final Set existingIds = items.map((e) => e.id).toSet(); final List newItems = result.items - .where((item) => !items.any((existing) => existing.id == item.id)) + .where((item) => !existingIds.contains(item.id)) .toList(); items.insertAll(0, newItems); items.refresh(); atStart.value = result.atStart; - int newLength = filteredItems.length; - return newLength - oldLength; - } finally { - loading.value = false; + return items.length - oldLength; + } + return 0; + } + + Future handleRefresh() async { + if (loading.value) return; + if (!atStart.value) { + await fetchNewer(); + } else { + await fetchInitial(); + } + } + + Future handleLoading() async { + if (!loading.value && !atEnd.value) { + await fetchMore(); } } @@ -178,4 +184,6 @@ class MediaController extends GetxController { await storage.setBoolean('drawerSwipeEnabled', drawerSwipeEnabled.value); await storage.setInt('transitionType', transitionType.value.index); } + + bool get isRandomEnabled => random.value == 1; } diff --git a/lib/main.dart b/lib/main.dart index 3cdf76a..c60493d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:encrypt_shared_preferences/provider.dart'; import 'package:get/get.dart'; +import 'package:f0ckapp/services/api.dart'; import 'package:f0ckapp/controller/authcontroller.dart'; import 'package:f0ckapp/controller/localizationcontroller.dart'; import 'package:f0ckapp/controller/themecontroller.dart'; @@ -14,11 +15,17 @@ import 'package:f0ckapp/screens/login.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await EncryptedSharedPreferencesAsync.initialize('VokTnbAbemBUa2j9'); - await MyTranslations.loadTranslations(); - await AppVersion.init(); + + await Future.wait([ + EncryptedSharedPreferencesAsync.initialize('VokTnbAbemBUa2j9'), + MyTranslations.loadTranslations(), + AppVersion.init(), + ]); + Get.put(AuthController()); - final MediaController mediaController = Get.put(MediaController()); + Get.put(ApiService()); + Get.put(MediaController()); + final ThemeController themeController = Get.put(ThemeController()); final LocalizationController localizationController = Get.put( LocalizationController(), @@ -41,7 +48,7 @@ void main() async { final Uri uri = Uri.parse(settings.name ?? '/'); if (uri.path == '/' || uri.pathSegments.isEmpty) { - return MaterialPageRoute(builder: (_) => MediaGrid()); + return MaterialPageRoute(builder: (_) => const MediaGrid()); } if (uri.path == '/login') { @@ -49,26 +56,17 @@ void main() async { } if (uri.pathSegments.length == 1) { - final int id = int.parse(uri.pathSegments.first); - - return MaterialPageRoute( - builder: (_) => FutureBuilder( - future: mediaController.items.isEmpty - ? mediaController.fetchInitial(id: id) - : Future.value(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return MediaDetailScreen(initialId: id); - } - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - }, - ), - ); + try { + final int id = int.parse(uri.pathSegments.first); + return MaterialPageRoute( + builder: (_) => MediaDetailScreen(initialId: id), + ); + } catch (e) { + return MaterialPageRoute(builder: (_) => const MediaGrid()); + } } - return MaterialPageRoute(builder: (_) => MediaGrid()); + return MaterialPageRoute(builder: (_) => const MediaGrid()); }, ), ), diff --git a/lib/screens/fullscreen.dart b/lib/screens/fullscreen.dart index a2e5334..cb64a98 100644 --- a/lib/screens/fullscreen.dart +++ b/lib/screens/fullscreen.dart @@ -50,7 +50,7 @@ class _FullScreenMediaViewState extends State { const Icon(Icons.error), ), ) - : SizedBox.expand( + : Center( child: VideoWidget( details: widget.item, isActive: true, diff --git a/lib/screens/mediadetail.dart b/lib/screens/mediadetail.dart index 6177eea..8a990a3 100644 --- a/lib/screens/mediadetail.dart +++ b/lib/screens/mediadetail.dart @@ -7,16 +7,19 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:get/get.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:f0ckapp/widgets/tagfooter.dart'; import 'package:f0ckapp/utils/animatedtransition.dart'; import 'package:f0ckapp/controller/authcontroller.dart'; import 'package:f0ckapp/widgets/actiontag.dart'; -import 'package:f0ckapp/widgets/favorite_avatars.dart'; +import 'package:f0ckapp/widgets/favoritesection.dart'; import 'package:f0ckapp/screens/fullscreen.dart'; import 'package:f0ckapp/widgets/end_drawer.dart'; import 'package:f0ckapp/controller/mediacontroller.dart'; import 'package:f0ckapp/models/item.dart'; import 'package:f0ckapp/widgets/video_widget.dart'; +enum ShareAction { media, directLink, postLink } + class MediaDetailScreen extends StatefulWidget { final int initialId; const MediaDetailScreen({super.key, required this.initialId}); @@ -26,19 +29,70 @@ class MediaDetailScreen extends StatefulWidget { } class _MediaDetailScreenState extends State { - late PageController _pageController; + PageController? _pageController; final MediaController mediaController = Get.find(); final AuthController authController = Get.find(); - int? _currentIndex; + final _currentIndex = 0.obs; + final _mediaSaverChannel = const MethodChannel('MediaShit'); + + bool _isLoading = true; + bool _itemNotFound = false; + final Set _readyItemIds = {}; + + final List> _shareMenuItems = const [ + PopupMenuItem( + value: ShareAction.media, + child: ListTile(leading: Icon(Icons.image), title: Text('Als Datei')), + ), + PopupMenuItem( + value: ShareAction.directLink, + child: ListTile(leading: Icon(Icons.link), title: Text('Link zur Datei')), + ), + PopupMenuItem( + value: ShareAction.postLink, + child: ListTile( + leading: Icon(Icons.article), + title: Text('Link zum f0ck'), + ), + ), + ]; @override void initState() { super.initState(); - final int idx = mediaController.items.indexWhere( + _loadInitialItem(); + } + + Future _loadInitialItem() async { + int initialIndex = mediaController.items.indexWhere( (item) => item.id == widget.initialId, ); - _currentIndex = idx >= 0 ? idx : 0; - _pageController = PageController(initialPage: _currentIndex!); + + if (initialIndex < 0) { + await mediaController.fetchInitial(id: widget.initialId + 20); + initialIndex = mediaController.items.indexWhere( + (item) => item.id == widget.initialId, + ); + } + + if (initialIndex < 0) { + if (mounted) { + setState(() { + _itemNotFound = true; + _isLoading = false; + }); + } + return; + } + + if (mounted) { + _currentIndex.value = initialIndex; + _pageController = PageController(initialPage: initialIndex); + if (mediaController.items[initialIndex].mime.startsWith('image/')) { + _readyItemIds.add(mediaController.items[initialIndex].id); + } + setState(() => _isLoading = false); + } } void _showMsg(String message) { @@ -49,15 +103,18 @@ class _MediaDetailScreenState extends State { } void _onPageChanged(int idx) { - if (idx != _currentIndex) { - setState(() => _currentIndex = idx); + if (idx != _currentIndex.value) { + _currentIndex.value = idx; + final item = mediaController.items[idx]; + if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) { + setState(() => _readyItemIds.add(item.id)); + } } if (idx >= mediaController.items.length - 2 && !mediaController.loading.value && !mediaController.atEnd.value) { mediaController.fetchMore(); - } - if (idx <= 1 && + } else if (idx <= 1 && !mediaController.loading.value && !mediaController.atStart.value) { mediaController.fetchNewer(); @@ -65,22 +122,58 @@ class _MediaDetailScreenState extends State { } Future _downloadMedia(MediaItem item) async { - final File file = await DefaultCacheManager().getSingleFile(item.mediaUrl); - final MethodChannel methodChannel = const MethodChannel('MediaShit'); + try { + final File file = await DefaultCacheManager().getSingleFile( + item.mediaUrl, + ); - bool? success = await methodChannel.invokeMethod('saveFile', { - 'filePath': file.path, - 'fileName': item.dest, - }); + final bool? success = await _mediaSaverChannel.invokeMethod( + 'saveFile', + {'filePath': file.path, 'fileName': item.dest}, + ); - success == true - ? _showMsg('${item.dest} wurde in Downloads/fApp neigespeichert.') - : _showMsg('${item.dest} konnte nicht heruntergeladen werden.'); + success == true + ? _showMsg('${item.dest} wurde in Downloads/fApp neigespeichert.') + : _showMsg('${item.dest} konnte nicht heruntergeladen werden.'); + } catch (e) { + _showMsg('Fehler beim Download: $e'); + } + } + + Future _handleShareAction(ShareAction value, MediaItem item) async { + try { + if (value == ShareAction.media) { + final File file = await DefaultCacheManager().getSingleFile( + item.mediaUrl, + ); + final Uint8List bytes = await file.readAsBytes(); + final params = ShareParams( + files: [XFile.fromData(bytes, mimeType: item.mime)], + ); + await SharePlus.instance.share(params); + return; + } + + final String textToShare; + switch (value) { + case ShareAction.directLink: + textToShare = item.mediaUrl; + break; + case ShareAction.postLink: + textToShare = item.postUrl; + break; + case ShareAction.media: + return; + } + await SharePlus.instance.share(ShareParams(text: textToShare)); + } catch (e) { + _showMsg('Fehler beim Teilen: $e'); + } } @override void dispose() { - _pageController.dispose(); + _pageController?.dispose(); super.dispose(); } @@ -93,7 +186,15 @@ class _MediaDetailScreenState extends State { ); } else if (item.mime.startsWith('video/') || item.mime.startsWith('audio/')) { - return VideoWidget(details: item, isActive: isActive); + return VideoWidget( + details: item, + isActive: isActive, + onInitialized: () { + if (mounted && !_readyItemIds.contains(item.id)) { + setState(() => _readyItemIds.add(item.id)); + } + }, + ); } else { return const Icon(Icons.help_outline, size: 100); } @@ -101,209 +202,134 @@ class _MediaDetailScreenState extends State { @override Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text('Lade f0ck #${widget.initialId}...')), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_itemNotFound) { + return Scaffold( + appBar: AppBar(title: const Text('Fehler')), + body: const Center(child: Text('f0ck nicht gefunden.')), + ); + } + return Obx( () => PageView.builder( - controller: _pageController, + controller: _pageController!, itemCount: mediaController.items.length, onPageChanged: _onPageChanged, itemBuilder: (context, index) { final MediaItem item = mediaController.items[index]; - final bool isActive = index == _currentIndex; - final bool isFavorite = - item.favorites?.any( - (f) => f.userId == authController.userId.value, - ) ?? - false; + final bool isReady = _readyItemIds.contains(item.id); return Scaffold( endDrawer: EndDrawer(), endDrawerEnableOpenDragGesture: mediaController.drawerSwipeEnabled.value, - body: CustomScrollView( - slivers: [ - SliverAppBar( - floating: false, - pinned: true, - title: Text('f0ck #${item.id}'), - actions: [ - IconButton( - icon: const Icon(Icons.fullscreen), - onPressed: () { - Get.to( - FullScreenMediaView(item: item), - fullscreenDialog: true, - ); - }, - ), - IconButton( - icon: const Icon(Icons.download), - onPressed: () async { - await _downloadMedia(item); - }, - ), - PopupMenuButton( - onSelected: (value) async { - 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(); - }, - ), - ), - ], + appBar: AppBar( + title: Text('f0ck #${item.id}'), + actions: [ + IconButton( + icon: const Icon(Icons.fullscreen), + onPressed: () { + Get.to( + FullScreenMediaView(item: item), + fullscreenDialog: true, + ); + }, ), - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AnimatedBuilder( - animation: _pageController, - builder: (context, child) { - return buildAnimatedTransition( - context: context, - pageController: _pageController, - index: index, - controller: mediaController, - child: child!, - ); - }, - child: _buildMedia(item, isActive), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ...item.tags?.map( - (tag) => Padding( - padding: const EdgeInsets.only(right: 6), - child: ActionTag(tag, (onTagTap) { - if (tag.tag == 'sfw' || tag.tag == 'nsfw') { - return; - } - mediaController.setTag(onTagTap); - Get.offAllNamed('/'); - }), - ), - ) ?? [], - ], - ), - if (authController.isLoggedIn) ...[ - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: FavoriteAvatars( - favorites: item.favorites ?? [], - brightness: Theme.of( - context, - ).brightness, - ), - ), - ), - IconButton( - icon: isFavorite - ? const Icon(Icons.favorite) - : const Icon(Icons.favorite_outline), - color: Colors.red, - onPressed: () async { - final List? newFavorites = - await mediaController.toggleFavorite( - item, - isFavorite, - ); - if (newFavorites != null) { - mediaController.items[index] = item - .copyWith(favorites: newFavorites); - mediaController.items.refresh(); - } - setState(() {}); - }, - ), - ], - ), - ], - ], - ), - ), - ], + IconButton( + icon: const Icon(Icons.download), + onPressed: () async { + await _downloadMedia(item); + }, + ), + PopupMenuButton( + onSelected: (value) => _handleShareAction(value, item), + itemBuilder: (context) => _shareMenuItems, + icon: const Icon(Icons.share), + ), + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + Scaffold.of(context).openEndDrawer(); + }, ), ), ], ), - persistentFooterButtons: [ - Obx(() { - if (mediaController.tag.value != null) { - return Center( - child: InputChip( - label: Text(mediaController.tag.value!), - onDeleted: () { - mediaController.setTag(null); - Get.offAllNamed('/'); - }, + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedBuilder( + animation: _pageController!, + builder: (context, child) { + return buildAnimatedTransition( + context: context, + pageController: _pageController!, + index: index, + controller: mediaController, + child: child!, + ); + }, + child: Obx( + () => _buildMedia(item, index == _currentIndex.value), ), - ); - } else { - return SizedBox.shrink(); - } - }), - ], + ), + const SizedBox(height: 16), + if (isReady) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Wrap( + spacing: 6.0, + runSpacing: 4.0, + alignment: WrapAlignment.center, + children: [ + ...item.tags?.map( + (tag) => ActionTag( + tag, + (tag.tag == 'sfw' || tag.tag == 'nsfw') + ? (onTagTap) => {} + : (onTagTap) { + mediaController.setTag(onTagTap); + Get.offAllNamed('/'); + }, + ), + ) ?? + [], + ], + ), + Obx( + () => Visibility( + visible: authController.isLoggedIn, + child: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: FavoriteSection( + item: item, + index: index, + ), + ), + ), + ), + ], + ), + ) + else + const SizedBox.shrink(), + ], + ), + ), + persistentFooterButtons: mediaController.tag.value != null + ? [TagFooter()] + : null, ); }, ), diff --git a/lib/screens/mediagrid.dart b/lib/screens/mediagrid.dart index 75d6b9b..6874e13 100644 --- a/lib/screens/mediagrid.dart +++ b/lib/screens/mediagrid.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pullex/pullex.dart'; +import 'package:f0ckapp/widgets/tagfooter.dart'; import 'package:f0ckapp/utils/customsearchdelegate.dart'; import 'package:f0ckapp/widgets/end_drawer.dart'; import 'package:f0ckapp/widgets/filter_bar.dart'; -import 'package:f0ckapp/screens/mediadetail.dart'; import 'package:f0ckapp/widgets/media_tile.dart'; import 'package:f0ckapp/controller/mediacontroller.dart'; @@ -24,151 +24,162 @@ class _MediaGrid extends State { initialRefresh: false, ); + late final _MediaGridAppBar _appBar; + late final _MediaGridBody _body; + @override void initState() { super.initState(); _mediaController.fetchInitial(); + _appBar = _MediaGridAppBar(mediaController: _mediaController); + _body = _MediaGridBody( + refreshController: _refreshController, + mediaController: _mediaController, + scrollController: _scrollController, + ); + } + + @override + void dispose() { + _scrollController.dispose(); + _refreshController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( - endDrawer: EndDrawer(), - endDrawerEnableOpenDragGesture: _mediaController.drawerSwipeEnabled.value, - bottomNavigationBar: FilterBar(scrollController: _scrollController), - body: PullexRefresh( - controller: _refreshController, - enablePullDown: true, - enablePullUp: true, - header: MaterialHeader(offset: 140), - onRefresh: () async { - try { - if (_mediaController.loading.value) return; - if (!_mediaController.atStart.value) { - await _mediaController.fetchNewer(); - } else { - await _mediaController.fetchInitial(); - } - } finally { - _refreshController.refreshCompleted(); - } - }, - onLoading: () async { - try { - if (!_mediaController.loading.value && - !_mediaController.atEnd.value) { - await _mediaController.fetchMore(); - } - } finally { - _refreshController.loadComplete(); - } - }, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - pinned: false, - snap: true, - floating: true, - title: GestureDetector( - child: Row( - children: [ - Image.asset( - 'assets/images/f0ck_small.webp', - fit: BoxFit.fitHeight, - ), - const SizedBox(width: 10), - const Text('fApp', style: TextStyle(fontSize: 24)), - ], - ), - onTap: () { - _mediaController.setTag(null); - }, - ), - actions: [ - IconButton( - icon: const Icon(Icons.search), - onPressed: () async { - await showSearch( - context: context, - delegate: CustomSearchDelegate(), - ); - }, - ), - Obx( - () => IconButton( - icon: Icon( - _mediaController.random.value == 1 - ? Icons.shuffle_on_outlined - : Icons.shuffle, - ), - onPressed: () { - _mediaController.toggleRandom(); - }, - ), - ), - Builder( - builder: (context) { - return IconButton( - icon: const Icon(Icons.menu), - onPressed: () { - Scaffold.of(context).openEndDrawer(); - }, - ); - }, - ), - ], - ), - Obx( - () => SliverPadding( - padding: const EdgeInsets.all(4), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate((context, index) { - final item = _mediaController.filteredItems[index]; - return GestureDetector( - onTap: () { - final item = _mediaController.filteredItems[index]; - Get.to(() => MediaDetailScreen(initialId: item.id)); - }, - child: MediaTile(item: item), - ); - }, childCount: _mediaController.filteredItems.length), - gridDelegate: _mediaController.crossAxisCount.value == 0 - ? const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150, - crossAxisSpacing: 5, - mainAxisSpacing: 5, - childAspectRatio: 1, - ) - : SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _mediaController.crossAxisCount.value, - crossAxisSpacing: 5, - mainAxisSpacing: 5, - childAspectRatio: 1, - ), - ), - ), - ), - ], - ), + return Obx( + () => Scaffold( + endDrawer: const EndDrawer(), + endDrawerEnableOpenDragGesture: + _mediaController.drawerSwipeEnabled.value, + bottomNavigationBar: FilterBar(scrollController: _scrollController), + appBar: _appBar, + body: _body, + persistentFooterButtons: _mediaController.tag.value != null + ? [TagFooter()] + : null, + ), + ); + } +} + +class _MediaGridAppBar extends StatelessWidget implements PreferredSizeWidget { + const _MediaGridAppBar({required this.mediaController}); + + final MediaController mediaController; + + @override + Widget build(BuildContext context) { + return AppBar( + title: InkWell( + onTap: () { + mediaController.setTag(null); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/f0ck_small.webp', fit: BoxFit.fitHeight), + const SizedBox(width: 10), + const Text('fApp', style: TextStyle(fontSize: 24)), + ], + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () async { + await showSearch( + context: context, + delegate: CustomSearchDelegate(), + ); + }, + ), + Obx( + () => IconButton( + icon: Icon( + mediaController.isRandomEnabled + ? Icons.shuffle_on_outlined + : Icons.shuffle, + ), + onPressed: mediaController.toggleRandom, + ), + ), + IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openEndDrawer(), + ), + ], + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _MediaGridBody extends StatelessWidget { + const _MediaGridBody({ + required this.refreshController, + required this.mediaController, + required this.scrollController, + }); + + final PullexRefreshController refreshController; + final MediaController mediaController; + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + return PullexRefresh( + controller: refreshController, + enablePullDown: true, + enablePullUp: true, + header: const MaterialHeader(), + onRefresh: () async { + try { + await mediaController.handleRefresh(); + } finally { + refreshController.refreshCompleted(); + } + }, + onLoading: () async { + try { + await mediaController.handleLoading(); + } finally { + refreshController.loadComplete(); + } + }, + child: Obx( + () => GridView.builder( + addAutomaticKeepAlives: false, + controller: scrollController, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.all(4), + itemCount: mediaController.items.length, + gridDelegate: mediaController.crossAxisCount.value == 0 + ? const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 150, + crossAxisSpacing: 5, + mainAxisSpacing: 5, + childAspectRatio: 1, + ) + : SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: mediaController.crossAxisCount.value, + crossAxisSpacing: 5, + mainAxisSpacing: 5, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + final item = mediaController.items[index]; + return GestureDetector( + key: ValueKey(item.id), + onTap: () => Get.toNamed('/${item.id}'), + child: MediaTile(item: item), + ); + }, + ), ), - persistentFooterButtons: [ - Obx(() { - if (_mediaController.tag.value != null) { - return Center( - child: InputChip( - label: Text(_mediaController.tag.value!), - onDeleted: () { - _mediaController.setTag(null); - Get.offAllNamed('/'); - }, - ), - ); - } else { - return SizedBox.shrink(); - } - }), - ], ); } } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 320ef9e..d510c97 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -52,7 +52,6 @@ class _SettingsPageState extends State { onChanged: (int? newValue) async { if (newValue != null) { await controller.setCrossAxisCount(newValue); - setState(() {}); } }, ), @@ -93,7 +92,6 @@ class _SettingsPageState extends State { onChanged: (PageTransition? newValue) async { if (newValue != null) { await controller.setTransitionType(newValue); - setState(() {}); } }, ), diff --git a/lib/services/api.dart b/lib/services/api.dart index ddeb79c..2f2370d 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -42,6 +42,9 @@ class ApiService extends GetConnect { feed.items.sort((a, b) => b.id.compareTo(a.id)); return feed; } else { + if (Get.isSnackbarOpen == false) { + Get.snackbar('Fehler', 'Fehler beim Laden der Items'); + } throw Exception('Fehler beim Laden der Items'); } } diff --git a/lib/utils/smartrefreshindicator.dart b/lib/utils/smartrefreshindicator.dart deleted file mode 100644 index cb654c0..0000000 --- a/lib/utils/smartrefreshindicator.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -class SmartRefreshIndicator extends StatelessWidget { - final Future Function() onRefresh; - final Widget child; - - const SmartRefreshIndicator({ - super.key, - required this.onRefresh, - required this.child, - }); - - @override - Widget build(context) { - return LayoutBuilder( - builder: (context, constraints) => RefreshIndicator( - onRefresh: onRefresh, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: child, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/favorite_avatars.dart b/lib/widgets/favorite_avatars.dart deleted file mode 100644 index a18a7c2..0000000 --- a/lib/widgets/favorite_avatars.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; - -class FavoriteAvatars extends StatelessWidget { - final List favorites; - final Brightness brightness; - - const FavoriteAvatars({ - super.key, - required this.favorites, - required this.brightness, - }); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ...favorites.map((favorite) { - return Container( - height: 32, - width: 32, - margin: const EdgeInsets.only(right: 5.0), - decoration: BoxDecoration( - border: Border.all( - color: brightness == Brightness.dark - ? Colors.white - : Colors.black, - width: 1.0, - ), - ), - child: CachedNetworkImage( - imageUrl: favorite.avatarUrl, - fit: BoxFit.cover, - ), - ); - }), - ], - ); - } -} diff --git a/lib/widgets/favoritesection.dart b/lib/widgets/favoritesection.dart new file mode 100644 index 0000000..cf9d930 --- /dev/null +++ b/lib/widgets/favoritesection.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:get/get.dart'; + +import 'package:f0ckapp/controller/authcontroller.dart'; +import 'package:f0ckapp/controller/mediacontroller.dart'; +import 'package:f0ckapp/models/item.dart'; + +class FavoriteSection extends StatelessWidget { + final MediaItem item; + final int index; + final MediaController mediaController = Get.find(); + final AuthController authController = Get.find(); + + FavoriteSection({super.key, required this.item, required this.index}); + + @override + Widget build(BuildContext context) { + final bool isFavorite = + item.favorites?.any((f) => f.userId == authController.userId.value) ?? + false; + + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...(item.favorites ?? []).map((favorite) { + return Container( + height: 32, + width: 32, + margin: const EdgeInsets.only(right: 5.0), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black, + width: 1.0, + ), + ), + child: CachedNetworkImage( + imageUrl: favorite.avatarUrl, + fit: BoxFit.cover, + ), + ); + }), + ], + ), + ), + ), + IconButton( + icon: isFavorite + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_outline), + color: Colors.red, + onPressed: () async { + final List? newFavorites = await mediaController + .toggleFavorite(item, isFavorite); + if (newFavorites != null) { + mediaController.items[index] = item.copyWith( + favorites: newFavorites, + ); + mediaController.items.refresh(); + } + }, + ), + ], + ); + } +} diff --git a/lib/widgets/media_tile.dart b/lib/widgets/media_tile.dart index 9d84456..836df5d 100644 --- a/lib/widgets/media_tile.dart +++ b/lib/widgets/media_tile.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:get/get.dart'; import 'package:f0ckapp/models/item.dart'; @@ -13,9 +12,6 @@ class MediaTile extends StatelessWidget { Widget build(BuildContext context) { return RepaintBoundary( child: InkWell( - onTap: () { - Get.toNamed('/${item.id}'); - }, child: Stack( fit: StackFit.expand, children: [ diff --git a/lib/widgets/tagfooter.dart b/lib/widgets/tagfooter.dart new file mode 100644 index 0000000..88631af --- /dev/null +++ b/lib/widgets/tagfooter.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:get/get.dart'; + +import 'package:f0ckapp/controller/mediacontroller.dart'; + +class TagFooter extends StatelessWidget { + final MediaController mediaController = Get.find(); + + TagFooter({super.key}); + + @override + Widget build(BuildContext context) { + return Obx(() { + if (mediaController.tag.value != null) { + return Center( + child: InputChip( + label: Text(mediaController.tag.value!), + onDeleted: () { + mediaController.setTag(null); + Get.offAllNamed('/'); + }, + ), + ); + } else { + return const SizedBox.shrink(); + } + }); + } +} diff --git a/lib/widgets/video_widget.dart b/lib/widgets/video_widget.dart index 74bbc77..65b0032 100644 --- a/lib/widgets/video_widget.dart +++ b/lib/widgets/video_widget.dart @@ -14,12 +14,14 @@ class VideoWidget extends StatefulWidget { final MediaItem details; final bool isActive; final bool fullScreen; + final VoidCallback? onInitialized; const VideoWidget({ super.key, required this.details, required this.isActive, this.fullScreen = false, + this.onInitialized, }); @override @@ -29,6 +31,7 @@ class VideoWidget extends StatefulWidget { class _VideoWidgetState extends State { final MediaController controller = Get.find(); late CachedVideoPlayerPlusController _controller; + late Worker _muteWorker; bool _showControls = false; Timer? _hideControlsTimer; @@ -36,6 +39,11 @@ class _VideoWidgetState extends State { void initState() { super.initState(); _initController(); + _muteWorker = ever(controller.muted, (bool muted) { + if (_controller.value.isInitialized) { + _controller.setVolume(muted ? 0.0 : 1.0); + } + }); } Future _initController() async { @@ -43,6 +51,8 @@ class _VideoWidgetState extends State { Uri.parse(widget.details.mediaUrl), ); await _controller.initialize(); + widget.onInitialized?.call(); + if (!mounted) return; setState(() {}); _controller.addListener(() => setState(() {})); _controller.setLooping(true); @@ -67,6 +77,7 @@ class _VideoWidgetState extends State { @override void dispose() { + _muteWorker.dispose(); _controller.dispose(); _hideControlsTimer?.cancel(); super.dispose(); @@ -87,11 +98,6 @@ class _VideoWidgetState extends State { @override Widget build(BuildContext context) { final bool muted = controller.muted.value; - if (_controller.value.isInitialized && - _controller.value.volume != (muted ? 0.0 : 1.0)) { - _controller.setVolume(muted ? 0.0 : 1.0); - } - bool isAudio = widget.details.mime.startsWith('audio'); Widget mediaContent; @@ -131,7 +137,6 @@ class _VideoWidgetState extends State { muted: muted, onMuteToggle: () { controller.toggleMuted(); - setState(() {}); }, ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 1360ce3..15e95fd 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.4.0+61 +version: 1.4.1+62 environment: sdk: ^3.9.0-100.2.beta