Compare commits

...
2 Commits
9 changed files with 686 additions and 145 deletions
+9 -6
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.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/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,8 +24,11 @@
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами. - **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео. - **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др. - **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. - **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор. - **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
@@ -52,9 +55,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.4.apk`
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz` - **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip` - **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
--- ---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.2'; const appDisplayVersion = '2.0.4';
const appBuildNumber = 24; const appBuildNumber = 26;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+19
View File
@@ -1,4 +1,23 @@
const lunarisChangelog = [ 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( 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',
@@ -47,6 +47,7 @@ abstract class TagMetadataProvider {
abstract class ArtistProvider { abstract class ArtistProvider {
Future<List<ArtistProfile>> listArtists({ Future<List<ArtistProfile>> listArtists({
String? service, String? service,
List<String>? services,
String? query, String? query,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
@@ -55,6 +56,7 @@ abstract class ArtistProvider {
Future<List<ArtistProfile>> searchArtists( Future<List<ArtistProfile>> searchArtists(
String query, { String query, {
String? service, String? service,
List<String>? services,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
}); });
+21 -31
View File
@@ -91,66 +91,56 @@ class KemonoProvider
@override @override
Future<List<ArtistProfile>> listArtists({ Future<List<ArtistProfile>> listArtists({
String? service, String? service,
List<String>? services,
String? query, String? query,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
}) async { }) async {
final searchText = query?.trim() ?? ''; final effectiveService =
if (searchText.isNotEmpty) { services != null && services.length == 1 ? services.first : service;
List<ArtistProfile> list;
try { try {
final response = await _creatorDio.get<dynamic>( final response = await _creatorDio.get<dynamic>(
_creatorPath(), effectiveService == null || effectiveService.isEmpty
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.
}
}
try {
final response = await _creatorDio.get<dynamic>(
service == null || service.isEmpty
? _creatorPath() ? _creatorPath()
: '${_creatorPath()}/$service', : '${_creatorPath()}/$effectiveService',
queryParameters: { queryParameters: {
'page': page, 'page': page,
'itemsPerPage': limit.clamp(1, 100), 'itemsPerPage': limit.clamp(1, 100),
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(), if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
}, },
); );
return _rankArtists(_artistsFromResponse(response.data), query ?? ''); list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) { } catch (_) {
return _listArtistsFromPublicApi( list = await _listArtistsFromPublicApi(
service: service, service: effectiveService,
query: query, query: query,
page: page, page: page,
limit: limit, 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 @override
Future<List<ArtistProfile>> searchArtists( Future<List<ArtistProfile>> searchArtists(
String query, { String query, {
String? service, String? service,
List<String>? services,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
}) { }) {
return listArtists( return listArtists(
service: service, query: query, page: page, limit: limit); service: service,
services: services,
query: query,
page: page,
limit: limit,
);
} }
@override @override
+12 -3
View File
@@ -122,16 +122,23 @@ class PawchiveProvider
@override @override
Future<List<ArtistProfile>> listArtists({ Future<List<ArtistProfile>> listArtists({
String? service, String? service,
List<String>? services,
String? query, String? query,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
}) async { }) async {
final allArtists = await _ensureArtistsLoaded(); final allArtists = await _ensureArtistsLoaded();
final queryText = (query ?? '').trim().toLowerCase(); 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 filtered = allArtists.where((artist) {
final matchService = service == null || final artistService = artist.service.toLowerCase();
final matchService = activeServices != null
? activeServices.contains(artistService)
: service == null ||
service.isEmpty || service.isEmpty ||
artist.service.toLowerCase() == service.toLowerCase(); artistService == service.toLowerCase();
if (!matchService) return false; if (!matchService) return false;
if (queryText.isEmpty) return true; if (queryText.isEmpty) return true;
return artist.displayName.toLowerCase().contains(queryText) || return artist.displayName.toLowerCase().contains(queryText) ||
@@ -147,11 +154,13 @@ class PawchiveProvider
Future<List<ArtistProfile>> searchArtists( Future<List<ArtistProfile>> searchArtists(
String query, { String query, {
String? service, String? service,
List<String>? services,
int page = 1, int page = 1,
int limit = 30, int limit = 30,
}) { }) {
return listArtists( return listArtists(
service: service, service: service,
services: services,
query: query, query: query,
page: page, page: page,
limit: limit, limit: limit,
@@ -180,7 +189,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,262 @@ 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 = [];
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( 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;
});
}
}
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 = 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>{};
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
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.',
) )
: 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( : PostMasonryGrid(
posts: items, posts: displayedPosts,
controller: _scrollController,
loading: _loading,
columns: Responsive.columnsFor( columns: Responsive.columnsFor(
context, context,
mobileColumns: settings.mobileColumns, mobileColumns: settings.mobileColumns,
@@ -82,28 +304,31 @@ class ArtistPostsScreen extends ConsumerWidget {
blurExplicit: settings.blurExplicitContent, blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges, showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled, nsfwEnabled: settings.nsfwEnabled,
mediaQualityMode: mediaQualityMode: MediaQualityMode.fromName(
MediaQualityMode.fromName(settings.mediaQualityMode), settings.mediaQualityMode),
favoriteKeys: favoriteKeys, favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys, viewedKeys: viewedKeys,
onOpen: (post) => context.push( onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}', '/post/${post.providerId}/${post.id}',
extra: PostNavigationContext( extra: PostNavigationContext(
currentPost: post, currentPost: post,
posts: items, posts: displayedPosts,
), ),
), ),
onFavorite: (post) async { onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) { if (favoriteKeys.contains(post.cacheKey)) {
await ref await ref
.read(favoriteServiceProvider) .read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId); .removeFavorite(
post.id, post.providerId);
} else { } else {
await ref.read(favoriteServiceProvider).addFavorite( await ref
.read(favoriteServiceProvider)
.addFavorite(
post, post,
settings: settings, settings: settings,
downloadManager: downloadManager: ref.read(
ref.read(downloadManagerServiceProvider), downloadManagerServiceProvider),
); );
} }
ref.invalidate(favoriteKeysProvider); ref.invalidate(favoriteKeysProvider);
@@ -111,6 +336,8 @@ class ArtistPostsScreen extends ConsumerWidget {
}, },
), ),
), ),
],
),
); );
} }
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> { class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final _search = TextEditingController(); final _search = TextEditingController();
final _scroll = ScrollController(); final _scroll = ScrollController();
final Set<String> _selectedServices = <String>{};
String? _providerId; String? _providerId;
var _page = 1; var _page = 1;
var _loading = false; var _loading = false;
@@ -107,16 +108,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 []),
@@ -138,43 +146,39 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
WidgetsBinding.instance WidgetsBinding.instance
.addPostFrameCallback((_) => _refresh(items)); .addPostFrameCallback((_) => _refresh(items));
} }
final displayedArtists = _selectedServices.isEmpty
? _artists
: _artists
.where((a) =>
_selectedServices.contains(a.service.toLowerCase()))
.toList(growable: false);
return Column( return Column(
children: [ children: [
_ArtistsHeader( _ArtistsHeader(
searchController: _search, searchController: _search,
providers: items, providers: items,
selectedProviderId: _providerId, selectedProviderId: _providerId,
selectedServices: _selectedServices,
onProviderChanged: (value) { onProviderChanged: (value) {
setState(() => _providerId = value); setState(() => _providerId = value);
_refresh(items); _refresh(items);
}, },
onSearch: () => _refresh(items), onToggleService: (service) {
), setState(() {
_FavoriteArtistsSection( if (_selectedServices.contains(service)) {
favorites: favoriteItems, _selectedServices.remove(service);
selectedKey: selectedFav.isEmpty ? null : selectedFav.key, } else {
onSelect: (fav) => _selectedServices.add(service);
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 _refresh(items);
.read(settingsControllerProvider.notifier)
.saveSettings(
settings.copyWith(favoriteArtists: updated));
}, },
onClearServices: () {
setState(() => _selectedServices.clear());
_refresh(items);
},
onSearch: () => _refresh(items),
), ),
Expanded( Expanded(
child: _error != null child: _error != null
@@ -182,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
message: _friendlyArtistError(_error), message: _friendlyArtistError(_error),
onRetry: () => _refresh(items), onRetry: () => _refresh(items),
) )
: _artists.isEmpty && !_loading : displayedArtists.isEmpty && !_loading
? const EmptyView( ? Center(
title: 'No artists', child: Padding(
message: 'Try another name or provider.', 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( : LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@@ -206,14 +233,15 @@ 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:
displayedArtists.length + (_loading ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _artists.length) { if (index >= displayedArtists.length) {
return const _ArtistSkeletonCard(); return const _ArtistSkeletonCard();
} }
final artist = _artists[index]; final artist = displayedArtists[index];
final isFav = favoriteKeys.contains( final isFav = favoriteKeys.contains(
'${artist.providerId}:${artist.service}:${artist.id}'); '${artist.providerId}:${artist.service}:${artist.id}');
return _ArtistCard( return _ArtistCard(
@@ -269,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
); );
final next = await provider.searchArtists( final next = await provider.searchArtists(
_search.text.trim(), _search.text.trim(),
services: _selectedServices.isEmpty ? null : _selectedServices.toList(),
page: _page, page: _page,
limit: 30, limit: 30,
); );
@@ -285,6 +314,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 {
@@ -292,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget {
required this.searchController, required this.searchController,
required this.providers, required this.providers,
required this.selectedProviderId, required this.selectedProviderId,
required this.selectedServices,
required this.onProviderChanged, required this.onProviderChanged,
required this.onToggleService,
required this.onClearServices,
required this.onSearch, required this.onSearch,
}); });
final TextEditingController searchController; final TextEditingController searchController;
final List<ContentProviderConfig> providers; final List<ContentProviderConfig> providers;
final String? selectedProviderId; final String? selectedProviderId;
final Set<String> selectedServices;
final ValueChanged<String?> onProviderChanged; final ValueChanged<String?> onProviderChanged;
final ValueChanged<String> onToggleService;
final VoidCallback onClearServices;
final VoidCallback onSearch; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; 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( 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 +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 { 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.4+26
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"