Compare commits

...
1 Commits
7 changed files with 404 additions and 103 deletions
+6 -5
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" /> <img src="https://img.shields.io/badge/Version-2.0.3-blueviolet?style=for-the-badge" alt="Version 2.0.3" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" /> <img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" /> <img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
@@ -24,7 +24,8 @@
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами. - **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео. - **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др. - **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. - **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
@@ -52,9 +53,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.2.apk` - **Android**: `Lunaris-v2.0.3.apk`
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz` - **Linux (x64)**: `Lunaris-v2.0.3-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip` - **Windows**: `LunarisSetup-v2.0.3.exe` / `LunarisPortable-v2.0.3.zip`
--- ---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.2'; const appDisplayVersion = '2.0.3';
const appBuildNumber = 24; const appBuildNumber = 25;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+10
View File
@@ -1,4 +1,14 @@
const lunarisChangelog = [ 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( LunarisChange(
version: '2.0.2', version: '2.0.2',
title: 'Swipeable media pager, favorite artists, performance and Pawchive', title: 'Swipeable media pager, favorite artists, performance and Pawchive',
+1 -1
View File
@@ -180,7 +180,7 @@ class PawchiveProvider
)); ));
} }
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt)); mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false); return mapped.toList(growable: false);
} catch (error) { } catch (error) {
throw StateError( throw StateError(
'$name artist posts are unavailable right now. ${_shortError(error)}', '$name artist posts are unavailable right now. ${_shortError(error)}',
@@ -20,12 +20,12 @@ final artistPostsProvider =
final provider = providers.data.firstWhere( final provider = providers.data.firstWhere(
(item) => (item as ContentProvider).id == query.providerId, (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); await ref.read(cacheServiceProvider).cachePosts(posts);
return posts; return posts;
}); });
class ArtistPostsScreen extends ConsumerWidget { class ArtistPostsScreen extends ConsumerStatefulWidget {
const ArtistPostsScreen({ const ArtistPostsScreen({
required this.providerId, required this.providerId,
required this.service, required this.service,
@@ -40,40 +40,136 @@ class ArtistPostsScreen extends ConsumerWidget {
final String artistName; final String artistName;
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ArtistPostsScreen> createState() => _ArtistPostsScreenState();
}
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController();
final List<Post> _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<void> _refresh() async {
setState(() {
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
await _loadMore();
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
setState(() {
_loading = true;
_error = null;
});
final query = ArtistWorkQuery( final query = ArtistWorkQuery(
providerId: providerId, providerId: widget.providerId,
service: service, service: widget.service,
artistId: artistId, artistId: widget.artistId,
artistName: artistName, artistName: widget.artistName,
); );
final posts = ref.watch(artistPostsProvider(query));
try {
final providers =
await ref.read(providerManagerProvider).activeArtistProviders();
if (providers is! Success<List<ArtistProvider>>) {
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 = final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults; ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{}; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
return AdaptiveScaffold( return AdaptiveScaffold(
title: artistName, title: widget.artistName,
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: () => ref.invalidate(artistPostsProvider(query)), onPressed: _refresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
], ],
body: posts.when( body: _posts.isEmpty && _loading
loading: () => const Center(child: CircularProgressIndicator()), ? const Center(child: CircularProgressIndicator())
error: (error, _) => ErrorView( : _posts.isEmpty && _error != null
message: _friendlyArtistPostError(error), ? ErrorView(
onRetry: () => ref.invalidate(artistPostsProvider(query)), message: _friendlyArtistPostError(_error!),
), onRetry: _refresh,
data: (items) => items.isEmpty )
: _posts.isEmpty
? const EmptyView( ? const EmptyView(
title: 'No works', title: 'No works',
message: 'This artist has no visible media yet.', message: 'This artist has no visible media yet.',
) )
: PostMasonryGrid( : PostMasonryGrid(
posts: items, posts: _posts,
controller: _scrollController,
loading: _loading,
columns: Responsive.columnsFor( columns: Responsive.columnsFor(
context, context,
mobileColumns: settings.mobileColumns, mobileColumns: settings.mobileColumns,
@@ -90,7 +186,7 @@ class ArtistPostsScreen extends ConsumerWidget {
'/post/${post.providerId}/${post.id}', '/post/${post.providerId}/${post.id}',
extra: PostNavigationContext( extra: PostNavigationContext(
currentPost: post, currentPost: post,
posts: items, posts: _posts,
), ),
), ),
onFavorite: (post) async { onFavorite: (post) async {
@@ -110,7 +206,6 @@ class ArtistPostsScreen extends ConsumerWidget {
ref.invalidate(favoritesControllerProvider); ref.invalidate(favoritesControllerProvider);
}, },
), ),
),
); );
} }
@@ -107,16 +107,23 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
.whereType<_FavoriteArtistItem>() .whereType<_FavoriteArtistItem>()
.toList(); .toList();
final favoriteKeys = favoriteItems.map((e) => e.key).toSet(); 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( return AdaptiveScaffold(
title: 'Artists', title: 'Artists',
actions: [ 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( IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: () => _refresh(configs.valueOrNull ?? const []), onPressed: () => _refresh(configs.valueOrNull ?? const []),
@@ -150,32 +157,6 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
}, },
onSearch: () => _refresh(items), 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<String>.from(settings.favoriteArtists)
..removeWhere((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
fav.key;
} catch (_) {
return false;
}
});
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(
settings.copyWith(favoriteArtists: updated));
},
),
Expanded( Expanded(
child: _error != null child: _error != null
? ErrorView( ? ErrorView(
@@ -206,7 +187,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
mainAxisExtent: mainAxisExtent:
Responsive.isMobile(context) ? 88 : 104, Responsive.isMobile(context) ? 104 : 116,
), ),
itemCount: _artists.length + (_loading ? 1 : 0), itemCount: _artists.length + (_loading ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -285,6 +266,50 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
} }
void _showFavoriteArtistsModal(
BuildContext context,
AppSettings settings,
List<_FavoriteArtistItem> favorites,
) {
showModalBottomSheet<void>(
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<String>.from(settings.favoriteArtists)
..removeWhere((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
fav.key;
} catch (_) {
return false;
}
});
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(settings.copyWith(favoriteArtists: updated));
setState(() {});
},
);
},
);
}
} }
class _ArtistsHeader extends StatelessWidget { class _ArtistsHeader extends StatelessWidget {
@@ -388,7 +413,7 @@ class _ArtistCard extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
children: [ children: [
_ArtistAvatar(artist: artist), _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 { class _FavoriteArtistsSection extends ConsumerWidget {
const _FavoriteArtistsSection({ const _FavoriteArtistsSection({
required this.favorites, required this.favorites,
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.0.2+24 version: 2.0.3+25
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"