diff --git a/README.md b/README.md
index 5682597..7b99484 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -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"