Compare commits

...
2 Commits
9 changed files with 686 additions and 145 deletions
+9 -6
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
<img src="https://img.shields.io/badge/Version-2.0.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`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.2';
const appBuildNumber = 24;
const appDisplayVersion = '2.0.4';
const appBuildNumber = 26;
const appReleaseChannel = 'Stable';
+19
View File
@@ -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,
});
+21 -31
View File
@@ -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
+13 -4
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 2.0.2+24
version: 2.0.4+26
environment:
sdk: ">=3.3.0 <4.0.0"