diff --git a/README.md b/README.md index 5682597..7b99484 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.2 + Version 2.0.3 Flutter Platforms Gitea @@ -24,7 +24,8 @@ - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами. -- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео. +- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку. +- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений. - **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др. - **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. @@ -52,9 +53,9 @@ 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** -- **Android**: `Lunaris-v2.0.2.apk` -- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz` -- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip` +- **Android**: `Lunaris-v2.0.3.apk` +- **Linux (x64)**: `Lunaris-v2.0.3-linux-x64.tar.gz` +- **Windows**: `LunarisSetup-v2.0.3.exe` / `LunarisPortable-v2.0.3.zip` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index dd09079..e4e8056 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.2'; -const appBuildNumber = 24; +const appDisplayVersion = '2.0.3'; +const appBuildNumber = 25; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 94cdc78..444fe9f 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.3', + title: 'Infinite artist pagination, favorite artists modal, and card layout polish', + bullets: [ + 'Implemented infinite scroll pagination in the artist post feed — now loads all historical works endlessly as you scroll.', + 'Moved favorite artists to a compact star action button in the top-right header that opens an interactive modal.', + 'Expanded artist card vertical height so updated dates and statistics are never cut off.', + 'Removed mapping truncation in Pawchive provider to deliver all post attachments cleanly.', + ], + ), LunarisChange( version: '2.0.2', title: 'Swipeable media pager, favorite artists, performance and Pawchive', diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index e4891cf..223a666 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -180,7 +180,7 @@ class PawchiveProvider )); } mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return mapped.take(limit).toList(growable: false); + return mapped.toList(growable: false); } catch (error) { throw StateError( '$name artist posts are unavailable right now. ${_shortError(error)}', diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 50ddd86..30fbc69 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -20,12 +20,12 @@ final artistPostsProvider = final provider = providers.data.firstWhere( (item) => (item as ContentProvider).id == query.providerId, ); - final posts = await provider.getArtistPosts(query: query, limit: 50); + final posts = await provider.getArtistPosts(query: query, page: 0, limit: 50); await ref.read(cacheServiceProvider).cachePosts(posts); return posts; }); -class ArtistPostsScreen extends ConsumerWidget { +class ArtistPostsScreen extends ConsumerStatefulWidget { const ArtistPostsScreen({ required this.providerId, required this.service, @@ -40,77 +40,172 @@ class ArtistPostsScreen extends ConsumerWidget { final String artistName; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ArtistPostsScreenState(); +} + +class _ArtistPostsScreenState extends ConsumerState { + final _scrollController = ScrollController(); + final List _posts = []; + int _page = 0; + bool _loading = false; + bool _hasMore = true; + Object? _error; + + @override + void initState() { + super.initState(); + _scrollController.addListener(() { + if (_scrollController.position.extentAfter < 600) { + _loadMore(); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _refresh() async { + setState(() { + _posts.clear(); + _page = 0; + _hasMore = true; + _error = null; + }); + await _loadMore(); + } + + Future _loadMore() async { + if (_loading || !_hasMore) return; + setState(() { + _loading = true; + _error = null; + }); + final query = ArtistWorkQuery( - providerId: providerId, - service: service, - artistId: artistId, - artistName: artistName, + providerId: widget.providerId, + service: widget.service, + artistId: widget.artistId, + artistName: widget.artistName, ); - final posts = ref.watch(artistPostsProvider(query)); + + try { + final providers = + await ref.read(providerManagerProvider).activeArtistProviders(); + if (providers is! Success>) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Could not load artist providers'; + }); + return; + } + final provider = providers.data.firstWhere( + (item) => (item as ContentProvider).id == query.providerId, + ); + final newPosts = await provider.getArtistPosts( + query: query, + page: _page, + limit: 50, + ); + await ref.read(cacheServiceProvider).cachePosts(newPosts); + + if (!mounted) return; + setState(() { + _loading = false; + if (newPosts.isEmpty) { + _hasMore = false; + } else { + final existingKeys = _posts.map((p) => p.cacheKey).toSet(); + final uniqueNew = + newPosts.where((p) => !existingKeys.contains(p.cacheKey)).toList(); + _posts.addAll(uniqueNew); + _page++; + if (newPosts.length < 10) { + _hasMore = false; + } + } + }); + } catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = error; + }); + } + } + + @override + Widget build(BuildContext context) { final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; + return AdaptiveScaffold( - title: artistName, + title: widget.artistName, actions: [ IconButton( tooltip: 'Refresh', - onPressed: () => ref.invalidate(artistPostsProvider(query)), + onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), ), ], - body: posts.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => ErrorView( - message: _friendlyArtistPostError(error), - onRetry: () => ref.invalidate(artistPostsProvider(query)), - ), - data: (items) => items.isEmpty - ? const EmptyView( - title: 'No works', - message: 'This artist has no visible media yet.', - ) - : PostMasonryGrid( - posts: items, - columns: Responsive.columnsFor( - context, - mobileColumns: settings.mobileColumns, - desktopColumns: settings.desktopColumns, - ), - blurExplicit: settings.blurExplicitContent, - showBadges: settings.showPostBadges, - nsfwEnabled: settings.nsfwEnabled, - mediaQualityMode: - MediaQualityMode.fromName(settings.mediaQualityMode), - favoriteKeys: favoriteKeys, - viewedKeys: viewedKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: items, - ), - ), - onFavorite: (post) async { - if (favoriteKeys.contains(post.cacheKey)) { - await ref - .read(favoriteServiceProvider) - .removeFavorite(post.id, post.providerId); - } else { - await ref.read(favoriteServiceProvider).addFavorite( - post, - settings: settings, - downloadManager: - ref.read(downloadManagerServiceProvider), - ); - } - ref.invalidate(favoriteKeysProvider); - ref.invalidate(favoritesControllerProvider); - }, - ), - ), + body: _posts.isEmpty && _loading + ? const Center(child: CircularProgressIndicator()) + : _posts.isEmpty && _error != null + ? ErrorView( + message: _friendlyArtistPostError(_error!), + onRetry: _refresh, + ) + : _posts.isEmpty + ? const EmptyView( + title: 'No works', + message: 'This artist has no visible media yet.', + ) + : PostMasonryGrid( + posts: _posts, + controller: _scrollController, + loading: _loading, + columns: Responsive.columnsFor( + context, + mobileColumns: settings.mobileColumns, + desktopColumns: settings.desktopColumns, + ), + blurExplicit: settings.blurExplicitContent, + showBadges: settings.showPostBadges, + nsfwEnabled: settings.nsfwEnabled, + mediaQualityMode: + MediaQualityMode.fromName(settings.mediaQualityMode), + favoriteKeys: favoriteKeys, + viewedKeys: viewedKeys, + onOpen: (post) => context.push( + '/post/${post.providerId}/${post.id}', + extra: PostNavigationContext( + currentPost: post, + posts: _posts, + ), + ), + onFavorite: (post) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite( + post, + settings: settings, + downloadManager: + ref.read(downloadManagerServiceProvider), + ); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + }, + ), ); } diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index b2de701..eee8e9f 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -107,16 +107,23 @@ class _ArtistsScreenState extends ConsumerState { .whereType<_FavoriteArtistItem>() .toList(); final favoriteKeys = favoriteItems.map((e) => e.key).toSet(); - final selectedFav = favoriteItems.firstWhere( - (e) => e.key == _selectedFavoriteArtistKey, - orElse: () => favoriteItems.isNotEmpty - ? favoriteItems.first - : const _FavoriteArtistItem.empty(), - ); return AdaptiveScaffold( title: 'Artists', actions: [ + IconButton( + tooltip: 'Любимые авторы', + icon: Badge( + isLabelVisible: favoriteItems.isNotEmpty, + label: Text('${favoriteItems.length}'), + child: const Icon(Icons.star_rounded, color: Colors.amber), + ), + onPressed: () => _showFavoriteArtistsModal( + context, + settings, + favoriteItems, + ), + ), IconButton( tooltip: 'Refresh', onPressed: () => _refresh(configs.valueOrNull ?? const []), @@ -150,32 +157,6 @@ class _ArtistsScreenState extends ConsumerState { }, onSearch: () => _refresh(items), ), - _FavoriteArtistsSection( - favorites: favoriteItems, - selectedKey: selectedFav.isEmpty ? null : selectedFav.key, - onSelect: (fav) => - setState(() => _selectedFavoriteArtistKey = fav.key), - onOpenArtist: (fav) => context.push( - '/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}', - ), - onRemoveFavorite: (fav) async { - final updated = List.from(settings.favoriteArtists) - ..removeWhere((e) { - try { - return _FavoriteArtistItem.fromJson( - jsonDecode(e) as Map) - .key == - fav.key; - } catch (_) { - return false; - } - }); - await ref - .read(settingsControllerProvider.notifier) - .saveSettings( - settings.copyWith(favoriteArtists: updated)); - }, - ), Expanded( child: _error != null ? ErrorView( @@ -206,7 +187,7 @@ class _ArtistsScreenState extends ConsumerState { crossAxisSpacing: 12, mainAxisSpacing: 12, mainAxisExtent: - Responsive.isMobile(context) ? 88 : 104, + Responsive.isMobile(context) ? 104 : 116, ), itemCount: _artists.length + (_loading ? 1 : 0), itemBuilder: (context, index) { @@ -285,6 +266,50 @@ class _ArtistsScreenState extends ConsumerState { if (mounted) setState(() => _loading = false); } } + + void _showFavoriteArtistsModal( + BuildContext context, + AppSettings settings, + List<_FavoriteArtistItem> favorites, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return _FavoriteArtistsModal( + favorites: favorites, + initialSelectedKey: _selectedFavoriteArtistKey, + onSelect: (fav) => + setState(() => _selectedFavoriteArtistKey = fav.key), + onOpenArtist: (fav) => context.push( + '/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}', + ), + onRemoveFavorite: (fav) async { + final updated = List.from(settings.favoriteArtists) + ..removeWhere((e) { + try { + return _FavoriteArtistItem.fromJson( + jsonDecode(e) as Map) + .key == + fav.key; + } catch (_) { + return false; + } + }); + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(settings.copyWith(favoriteArtists: updated)); + setState(() {}); + }, + ); + }, + ); + } } class _ArtistsHeader extends StatelessWidget { @@ -388,7 +413,7 @@ class _ArtistCard extends StatelessWidget { child: InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ _ArtistAvatar(artist: artist), @@ -492,6 +517,176 @@ class _FavoriteArtistItem { ); } +class _FavoriteArtistsModal extends StatefulWidget { + const _FavoriteArtistsModal({ + required this.favorites, + required this.initialSelectedKey, + required this.onSelect, + required this.onOpenArtist, + required this.onRemoveFavorite, + }); + + final List<_FavoriteArtistItem> favorites; + final String? initialSelectedKey; + final ValueChanged<_FavoriteArtistItem> onSelect; + final ValueChanged<_FavoriteArtistItem> onOpenArtist; + final ValueChanged<_FavoriteArtistItem> onRemoveFavorite; + + @override + State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState(); +} + +class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> { + late String? _selectedKey; + + @override + void initState() { + super.initState(); + _selectedKey = widget.initialSelectedKey; + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final selectedFav = widget.favorites.firstWhere( + (e) => e.key == _selectedKey, + orElse: () => widget.favorites.isNotEmpty + ? widget.favorites.first + : const _FavoriteArtistItem.empty(), + ); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.65, + minChildSize: 0.25, + maxChildSize: 0.92, + builder: (context, scroll) { + return SingleChildScrollView( + controller: scroll, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: scheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + const Icon(Icons.star_rounded, + color: Colors.amber, size: 26), + const SizedBox(width: 8), + Text( + 'Любимые авторы', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (widget.favorites.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${widget.favorites.length}', + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.amber.shade900, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + const Spacer(), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + if (widget.favorites.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 36, 24, 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star_border_rounded, + size: 52, + color: scheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 16), + Text( + 'Список пуст', + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.', + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ) + else ...[ + _FavoriteArtistsSection( + favorites: widget.favorites, + selectedKey: selectedFav.isEmpty ? null : selectedFav.key, + onSelect: (fav) { + setState(() => _selectedKey = fav.key); + widget.onSelect(fav); + }, + onOpenArtist: (fav) { + Navigator.of(context).pop(); + widget.onOpenArtist(fav); + }, + onRemoveFavorite: (fav) { + widget.onRemoveFavorite(fav); + if (_selectedKey == fav.key) { + setState(() { + _selectedKey = widget.favorites + .where((e) => e.key != fav.key) + .firstOrNull + ?.key; + }); + } + }, + ), + const SizedBox(height: 16), + ], + ], + ), + ); + }, + ); + } +} + class _FavoriteArtistsSection extends ConsumerWidget { const _FavoriteArtistsSection({ required this.favorites, diff --git a/pubspec.yaml b/pubspec.yaml index 046e567..698b2b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.0.2+24 +version: 2.0.3+25 environment: sdk: ">=3.3.0 <4.0.0"