Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f624fc020 | ||
|
|
5ae802f89c |
@@ -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.4-blueviolet?style=for-the-badge" alt="Version 2.0.4" />
|
||||
<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,8 +24,11 @@
|
||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
|
||||
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
||||
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||
@@ -52,9 +55,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.4.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.2';
|
||||
const appBuildNumber = 24;
|
||||
const appDisplayVersion = '2.0.4';
|
||||
const appBuildNumber = 26;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.4',
|
||||
title: 'Platform service selection & media type filtering in artist posts',
|
||||
bullets: [
|
||||
'Added multi-select platform filters (Patreon, Pixiv Fanbox, Fantia, Boosty, Discord) in the Artists tab.',
|
||||
'Added multi-select media type filter chips (Photos, Videos, GIFs) in artist posts with counts.',
|
||||
'Full-screen post pager respects active media type filters during swiping.',
|
||||
],
|
||||
),
|
||||
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',
|
||||
|
||||
@@ -47,6 +47,7 @@ abstract class TagMetadataProvider {
|
||||
abstract class ArtistProvider {
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
@@ -55,6 +56,7 @@ abstract class ArtistProvider {
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
});
|
||||
|
||||
@@ -91,66 +91,56 @@ class KemonoProvider
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final searchText = query?.trim() ?? '';
|
||||
if (searchText.isNotEmpty) {
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
_creatorPath(),
|
||||
queryParameters: {
|
||||
'keyword': searchText,
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _rankArtists(
|
||||
_artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service == service)
|
||||
.toList(growable: false),
|
||||
searchText,
|
||||
);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
}
|
||||
}
|
||||
final effectiveService =
|
||||
services != null && services.length == 1 ? services.first : service;
|
||||
List<ArtistProfile> list;
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
service == null || service.isEmpty
|
||||
effectiveService == null || effectiveService.isEmpty
|
||||
? _creatorPath()
|
||||
: '${_creatorPath()}/$service',
|
||||
: '${_creatorPath()}/$effectiveService',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
list = await _listArtistsFromPublicApi(
|
||||
service: effectiveService,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
if (services != null && services.isNotEmpty) {
|
||||
final sSet = services.map((s) => s.toLowerCase()).toSet();
|
||||
list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service, query: query, page: page, limit: limit);
|
||||
service: service,
|
||||
services: services,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -122,16 +122,23 @@ class PawchiveProvider
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final allArtists = await _ensureArtistsLoaded();
|
||||
final queryText = (query ?? '').trim().toLowerCase();
|
||||
final activeServices = services != null && services.isNotEmpty
|
||||
? services.map((s) => s.trim().toLowerCase()).toSet()
|
||||
: null;
|
||||
final filtered = allArtists.where((artist) {
|
||||
final matchService = service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service.toLowerCase() == service.toLowerCase();
|
||||
final artistService = artist.service.toLowerCase();
|
||||
final matchService = activeServices != null
|
||||
? activeServices.contains(artistService)
|
||||
: service == null ||
|
||||
service.isEmpty ||
|
||||
artistService == service.toLowerCase();
|
||||
if (!matchService) return false;
|
||||
if (queryText.isEmpty) return true;
|
||||
return artist.displayName.toLowerCase().contains(queryText) ||
|
||||
@@ -147,11 +154,13 @@ class PawchiveProvider
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service,
|
||||
services: services,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
@@ -180,7 +189,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,304 @@ 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 = [];
|
||||
final Set<String> _selectedTypes = <String>{};
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Post post) {
|
||||
if (_selectedTypes.isEmpty) return true;
|
||||
final isVid = MediaUrlSelector.isVideo(post);
|
||||
final isGif = MediaUrlSelector.isGif(post);
|
||||
final isPhoto = !isVid && !isGif;
|
||||
|
||||
if (_selectedTypes.contains('video') && isVid) return true;
|
||||
if (_selectedTypes.contains('gif') && isGif) return true;
|
||||
if (_selectedTypes.contains('photo') && isPhoto) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _buildMediaTypeFilters() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final photoCount = _posts
|
||||
.where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p))
|
||||
.length;
|
||||
final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
|
||||
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
selected: _selectedTypes.isEmpty,
|
||||
label: Text('Все (${_posts.length})'),
|
||||
onSelected: (_) => setState(() => _selectedTypes.clear()),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('photo'),
|
||||
avatar: Icon(Icons.photo_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('photo')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Фото ($photoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('photo');
|
||||
} else {
|
||||
_selectedTypes.remove('photo');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('video'),
|
||||
avatar: Icon(Icons.videocam_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('video')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Видео ($videoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('video');
|
||||
} else {
|
||||
_selectedTypes.remove('video');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('gif'),
|
||||
avatar: Icon(Icons.gif_rounded,
|
||||
size: 20,
|
||||
color: _selectedTypes.contains('gif')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('GIF ($gifCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('gif');
|
||||
} else {
|
||||
_selectedTypes.remove('gif');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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>{};
|
||||
|
||||
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
|
||||
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.',
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildMediaTypeFilters(),
|
||||
Expanded(
|
||||
child: displayedPosts.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.filter_alt_off_rounded,
|
||||
size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Нет медиа по выбранным фильтрам',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => setState(
|
||||
() => _selectedTypes.clear()),
|
||||
child: const Text('Показать все'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: PostMasonryGrid(
|
||||
posts: displayedPosts,
|
||||
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: displayedPosts,
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
|
||||
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
final _search = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
final Set<String> _selectedServices = <String>{};
|
||||
String? _providerId;
|
||||
var _page = 1;
|
||||
var _loading = false;
|
||||
@@ -107,16 +108,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 []),
|
||||
@@ -138,43 +146,39 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _refresh(items));
|
||||
}
|
||||
final displayedArtists = _selectedServices.isEmpty
|
||||
? _artists
|
||||
: _artists
|
||||
.where((a) =>
|
||||
_selectedServices.contains(a.service.toLowerCase()))
|
||||
.toList(growable: false);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_ArtistsHeader(
|
||||
searchController: _search,
|
||||
providers: items,
|
||||
selectedProviderId: _providerId,
|
||||
selectedServices: _selectedServices,
|
||||
onProviderChanged: (value) {
|
||||
setState(() => _providerId = value);
|
||||
_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));
|
||||
onToggleService: (service) {
|
||||
setState(() {
|
||||
if (_selectedServices.contains(service)) {
|
||||
_selectedServices.remove(service);
|
||||
} else {
|
||||
_selectedServices.add(service);
|
||||
}
|
||||
});
|
||||
_refresh(items);
|
||||
},
|
||||
onClearServices: () {
|
||||
setState(() => _selectedServices.clear());
|
||||
_refresh(items);
|
||||
},
|
||||
onSearch: () => _refresh(items),
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
@@ -182,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
message: _friendlyArtistError(_error),
|
||||
onRetry: () => _refresh(items),
|
||||
)
|
||||
: _artists.isEmpty && !_loading
|
||||
? const EmptyView(
|
||||
title: 'No artists',
|
||||
message: 'Try another name or provider.',
|
||||
: displayedArtists.isEmpty && !_loading
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.person_search_outlined,
|
||||
size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Нет авторов по выбранным фильтрам',
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
setState(
|
||||
() => _selectedServices.clear());
|
||||
_refresh(items);
|
||||
},
|
||||
child: const Text('Показать все сервисы'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -206,14 +233,15 @@ 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),
|
||||
itemCount:
|
||||
displayedArtists.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _artists.length) {
|
||||
if (index >= displayedArtists.length) {
|
||||
return const _ArtistSkeletonCard();
|
||||
}
|
||||
final artist = _artists[index];
|
||||
final artist = displayedArtists[index];
|
||||
final isFav = favoriteKeys.contains(
|
||||
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||
return _ArtistCard(
|
||||
@@ -269,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
);
|
||||
final next = await provider.searchArtists(
|
||||
_search.text.trim(),
|
||||
services: _selectedServices.isEmpty ? null : _selectedServices.toList(),
|
||||
page: _page,
|
||||
limit: 30,
|
||||
);
|
||||
@@ -285,6 +314,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 {
|
||||
@@ -292,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget {
|
||||
required this.searchController,
|
||||
required this.providers,
|
||||
required this.selectedProviderId,
|
||||
required this.selectedServices,
|
||||
required this.onProviderChanged,
|
||||
required this.onToggleService,
|
||||
required this.onClearServices,
|
||||
required this.onSearch,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final String? selectedProviderId;
|
||||
final Set<String> selectedServices;
|
||||
final ValueChanged<String?> onProviderChanged;
|
||||
final ValueChanged<String> onToggleService;
|
||||
final VoidCallback onClearServices;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord'];
|
||||
|
||||
String _formatServiceName(String s) {
|
||||
switch (s.toLowerCase()) {
|
||||
case 'patreon':
|
||||
return 'Patreon';
|
||||
case 'fanbox':
|
||||
return 'Pixiv Fanbox';
|
||||
case 'fantia':
|
||||
return 'Fantia';
|
||||
case 'boosty':
|
||||
return 'Boosty';
|
||||
case 'discord':
|
||||
return 'Discord';
|
||||
default:
|
||||
return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -357,6 +455,29 @@ class _ArtistsHeader extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
FilterChip(
|
||||
selected: selectedServices.isEmpty,
|
||||
label: const Text('Все платформы'),
|
||||
onSelected: (_) => onClearServices(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (final service in _availableServices) ...[
|
||||
FilterChip(
|
||||
selected: selectedServices.contains(service),
|
||||
label: Text(_formatServiceName(service)),
|
||||
onSelected: (_) => onToggleService(service),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -388,7 +509,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 +613,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
@@ -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.4+26
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user