Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f624fc020 | ||
|
|
5ae802f89c |
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user