feat: platform service selection and media type filters for artist posts (v2.0.4)
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.3-blueviolet?style=for-the-badge" alt="Version 2.0.3" />
|
||||
<img src="https://img.shields.io/badge/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" />
|
||||
@@ -25,8 +25,10 @@
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
||||
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||
@@ -53,9 +55,9 @@
|
||||
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
- **Android**: `Lunaris-v2.0.3.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.3-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.3.exe` / `LunarisPortable-v2.0.3.zip`
|
||||
- **Android**: `Lunaris-v2.0.4.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.3';
|
||||
const appBuildNumber = 25;
|
||||
const appDisplayVersion = '2.0.4';
|
||||
const appBuildNumber = 26;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
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',
|
||||
|
||||
@@ -47,6 +47,7 @@ abstract class TagMetadataProvider {
|
||||
abstract class ArtistProvider {
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
@@ -55,6 +56,7 @@ abstract class ArtistProvider {
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
});
|
||||
|
||||
@@ -91,66 +91,56 @@ class KemonoProvider
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final searchText = query?.trim() ?? '';
|
||||
if (searchText.isNotEmpty) {
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
_creatorPath(),
|
||||
queryParameters: {
|
||||
'keyword': searchText,
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _rankArtists(
|
||||
_artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service == service)
|
||||
.toList(growable: false),
|
||||
searchText,
|
||||
);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
}
|
||||
}
|
||||
final effectiveService =
|
||||
services != null && services.length == 1 ? services.first : service;
|
||||
List<ArtistProfile> list;
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
service == null || service.isEmpty
|
||||
effectiveService == null || effectiveService.isEmpty
|
||||
? _creatorPath()
|
||||
: '${_creatorPath()}/$service',
|
||||
: '${_creatorPath()}/$effectiveService',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
list = await _listArtistsFromPublicApi(
|
||||
service: effectiveService,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
if (services != null && services.isNotEmpty) {
|
||||
final sSet = services.map((s) => s.toLowerCase()).toSet();
|
||||
list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service, query: query, page: page, limit: limit);
|
||||
service: service,
|
||||
services: services,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -122,16 +122,23 @@ class PawchiveProvider
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final allArtists = await _ensureArtistsLoaded();
|
||||
final queryText = (query ?? '').trim().toLowerCase();
|
||||
final activeServices = services != null && services.isNotEmpty
|
||||
? services.map((s) => s.trim().toLowerCase()).toSet()
|
||||
: null;
|
||||
final filtered = allArtists.where((artist) {
|
||||
final matchService = service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service.toLowerCase() == service.toLowerCase();
|
||||
final artistService = artist.service.toLowerCase();
|
||||
final matchService = activeServices != null
|
||||
? activeServices.contains(artistService)
|
||||
: service == null ||
|
||||
service.isEmpty ||
|
||||
artistService == service.toLowerCase();
|
||||
if (!matchService) return false;
|
||||
if (queryText.isEmpty) return true;
|
||||
return artist.displayName.toLowerCase().contains(queryText) ||
|
||||
@@ -147,11 +154,13 @@ class PawchiveProvider
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service,
|
||||
services: services,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
|
||||
@@ -46,6 +46,7 @@ class ArtistPostsScreen extends ConsumerStatefulWidget {
|
||||
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;
|
||||
@@ -138,6 +139,100 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -145,6 +240,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
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: widget.artistName,
|
||||
actions: [
|
||||
@@ -166,45 +263,80 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
title: 'No works',
|
||||
message: 'This artist has no visible media yet.',
|
||||
)
|
||||
: PostMasonryGrid(
|
||||
posts: _posts,
|
||||
controller: _scrollController,
|
||||
loading: _loading,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: _posts,
|
||||
: 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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
@@ -145,16 +146,38 @@ 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);
|
||||
},
|
||||
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(
|
||||
@@ -163,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) {
|
||||
@@ -189,12 +235,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
mainAxisExtent:
|
||||
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(
|
||||
@@ -250,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,
|
||||
);
|
||||
@@ -317,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;
|
||||
@@ -382,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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.3+25
|
||||
version: 2.0.4+26
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user