Compare commits

...
1 Commits
7 changed files with 404 additions and 103 deletions
+6 -5
View File
@@ -10,7 +10,7 @@
</p>
<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/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" />
@@ -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`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.2';
const appBuildNumber = 24;
const appDisplayVersion = '2.0.3';
const appBuildNumber = 25;
const appReleaseChannel = 'Stable';
+10
View File
@@ -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',
+1 -1
View File
@@ -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)}',
@@ -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<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(
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<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 =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
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);
},
),
);
}
@@ -107,16 +107,23 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
.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<ArtistsScreen> {
},
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(
child: _error != null
? ErrorView(
@@ -206,7 +187,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
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<ArtistsScreen> {
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 {
@@ -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,
+1 -1
View File
@@ -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"