1282 lines
49 KiB
Dart
1282 lines
49 KiB
Dart
import 'dart:ui';
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../../app/app.dart';
|
||
import '../../../app/app_navigator.dart';
|
||
import '../../../app/responsive.dart';
|
||
import '../../../backend/backend.dart';
|
||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||
import '../../../shared/widgets/empty_view.dart';
|
||
import '../../../shared/widgets/error_view.dart';
|
||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||
import 'favorites_controller.dart';
|
||
|
||
enum FavoriteMediaType { all, video, image }
|
||
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
|
||
|
||
class FavoritesScreen extends ConsumerStatefulWidget {
|
||
const FavoritesScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<FavoritesScreen> createState() => _FavoritesScreenState();
|
||
}
|
||
|
||
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||
int _tabIndex = 0;
|
||
String? _selectedArtist;
|
||
FavoriteMediaType _mediaFilter = FavoriteMediaType.all;
|
||
OfflineSortOption _offlineSort = OfflineSortOption.dateAddedDesc;
|
||
bool _isSearching = false;
|
||
final TextEditingController _searchController = TextEditingController();
|
||
String _searchQuery = '';
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
bool _isVideoPost(Post post) {
|
||
final type = post.fileType.toLowerCase();
|
||
final url = post.fileUrl.toLowerCase();
|
||
return type == 'mp4' ||
|
||
type == 'webm' ||
|
||
url.endsWith('.mp4') ||
|
||
url.endsWith('.webm');
|
||
}
|
||
|
||
List<Post> _applyFilters(List<Post> posts) {
|
||
var result = posts;
|
||
if (_mediaFilter == FavoriteMediaType.video) {
|
||
result = result.where(_isVideoPost).toList();
|
||
} else if (_mediaFilter == FavoriteMediaType.image) {
|
||
result = result.where((p) => !_isVideoPost(p)).toList();
|
||
}
|
||
|
||
if (_searchQuery.isNotEmpty) {
|
||
final query = _searchQuery.toLowerCase();
|
||
result = result.where((post) {
|
||
return post.tags.any((t) => t.toLowerCase().contains(query)) ||
|
||
post.providerName.toLowerCase().contains(query);
|
||
}).toList();
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(favoritesControllerProvider);
|
||
final settings =
|
||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||
final isRu = settings.languageCode == 'ru';
|
||
final allPosts = state.value?.posts ?? const <Post>[];
|
||
final downloaded = ref
|
||
.watch(downloadedMediaByKeysProvider(
|
||
allPosts.map((post) => post.cacheKey).toList(growable: false),
|
||
))
|
||
.value ??
|
||
const <String, DownloadedMedia>{};
|
||
|
||
final offlinePosts = allPosts
|
||
.where((post) => downloaded.containsKey(post.cacheKey))
|
||
.toList();
|
||
final artistGroups = favoriteArtistAlbums(allPosts);
|
||
final diskSizeBytes = _tabIndex == 1
|
||
? (ref.watch(offlineDiskSizeProvider(downloaded.values)).value ?? 0)
|
||
: 0;
|
||
final tasks = _tabIndex == 1
|
||
? (ref.watch(downloadTasksProvider).value ?? const <DownloadTask>[])
|
||
: const <DownloadTask>[];
|
||
final activeTasks = tasks
|
||
.where((t) =>
|
||
t.status == DownloadTaskStatus.running ||
|
||
t.status == DownloadTaskStatus.queued)
|
||
.toList();
|
||
|
||
return AdaptiveScaffold(
|
||
title: isRu ? 'Избранное' : 'Favorites',
|
||
actions: [
|
||
IconButton(
|
||
tooltip: _isSearching
|
||
? (isRu ? 'Закрыть поиск' : 'Close search')
|
||
: (isRu ? 'Поиск в избранном' : 'Search favorites'),
|
||
icon: Icon(_isSearching ? Icons.close_rounded : Icons.search_rounded),
|
||
onPressed: () {
|
||
setState(() {
|
||
_isSearching = !_isSearching;
|
||
if (!_isSearching) {
|
||
_searchController.clear();
|
||
_searchQuery = '';
|
||
}
|
||
});
|
||
},
|
||
),
|
||
],
|
||
body: state.when(
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (error, _) => ErrorView(message: error.toString()),
|
||
data: (data) {
|
||
if (data.posts.isEmpty) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.primaryContainer
|
||
.withValues(alpha: 0.3),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Icons.favorite_border_rounded,
|
||
size: 56,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
isRu ? 'В избранном пока пусто' : 'No favorites yet',
|
||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
isRu
|
||
? 'Нажимайте ❤️ на постах в ленте, чтобы сохранить их'
|
||
: 'Tap ❤️ on posts in the feed to save them here',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton.icon(
|
||
onPressed: () => context.go('/feed'),
|
||
icon: const Icon(Icons.explore_rounded),
|
||
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
children: [
|
||
// Search input bar if active
|
||
if (_isSearching)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(18),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).brightness == Brightness.dark
|
||
? Theme.of(context).colorScheme.surfaceContainerHigh
|
||
.withValues(alpha: 0.55)
|
||
: Colors.white.withValues(alpha: 0.80),
|
||
borderRadius: BorderRadius.circular(18),
|
||
border: Border.all(
|
||
color: Theme.of(context).brightness == Brightness.dark
|
||
? Colors.white.withValues(alpha: 0.14)
|
||
: Colors.white.withValues(alpha: 0.85),
|
||
width: 1.1,
|
||
),
|
||
),
|
||
child: TextField(
|
||
controller: _searchController,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
hintText: isRu
|
||
? 'Поиск по тегам и авторам...'
|
||
: 'Search by tags and artists...',
|
||
prefixIcon: const Icon(Icons.search_rounded),
|
||
suffixIcon: _searchQuery.isNotEmpty
|
||
? IconButton(
|
||
icon: const Icon(Icons.clear_rounded),
|
||
onPressed: () {
|
||
HapticFeedback.lightImpact();
|
||
_searchController.clear();
|
||
setState(() => _searchQuery = '');
|
||
},
|
||
)
|
||
: null,
|
||
isDense: true,
|
||
filled: false,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 10,
|
||
),
|
||
border: InputBorder.none,
|
||
),
|
||
onChanged: (val) =>
|
||
setState(() => _searchQuery = val.trim()),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Navigation Tabs with Liquid Glass container
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(
|
||
alpha: Theme.of(context).brightness == Brightness.dark
|
||
? 0.20
|
||
: 0.04,
|
||
),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(22),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
color: Theme.of(context).brightness == Brightness.dark
|
||
? Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHigh
|
||
.withValues(alpha: 0.50)
|
||
: Colors.white.withValues(alpha: 0.72),
|
||
border: Border.all(
|
||
color: Theme.of(context).brightness == Brightness.dark
|
||
? Colors.white.withValues(alpha: 0.12)
|
||
: Colors.white.withValues(alpha: 0.85),
|
||
width: 1.1,
|
||
),
|
||
),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: SegmentedButton<int>(
|
||
showSelectedIcon: false,
|
||
style: ButtonStyle(
|
||
visualDensity: VisualDensity.compact,
|
||
shape: WidgetStatePropertyAll(
|
||
RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
),
|
||
segments: [
|
||
ButtonSegment(
|
||
value: 0,
|
||
icon: const Icon(Icons.favorite_rounded, size: 18),
|
||
label: Text(
|
||
'${isRu ? 'Все' : 'All'} (${allPosts.length})',
|
||
style: const TextStyle(
|
||
fontSize: 12, fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
ButtonSegment(
|
||
value: 1,
|
||
icon: const Icon(Icons.offline_pin_rounded,
|
||
size: 18),
|
||
label: Text(
|
||
'${isRu ? 'Офлайн' : 'Offline'} (${offlinePosts.length})',
|
||
style: const TextStyle(
|
||
fontSize: 12, fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
ButtonSegment(
|
||
value: 2,
|
||
icon: const Icon(Icons.groups_rounded, size: 18),
|
||
label: Text(
|
||
'${isRu ? 'Авторы' : 'Artists'} (${artistGroups.length})',
|
||
style: const TextStyle(
|
||
fontSize: 12, fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
selected: {_tabIndex},
|
||
onSelectionChanged: (value) {
|
||
HapticFeedback.selectionClick();
|
||
setState(() {
|
||
_tabIndex = value.first;
|
||
_selectedArtist = null;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Media Type Filter Chips (for Tab 0 and Tab 1)
|
||
if (_tabIndex != 2)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
child: Row(
|
||
children: [
|
||
FilterChip(
|
||
label: Text(isRu ? 'Все' : 'All'),
|
||
selected: _mediaFilter == FavoriteMediaType.all,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _mediaFilter = FavoriteMediaType.all,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: 8),
|
||
FilterChip(
|
||
avatar: const Icon(Icons.videocam_rounded, size: 16),
|
||
label: Text(isRu ? 'Видео' : 'Videos'),
|
||
selected: _mediaFilter == FavoriteMediaType.video,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _mediaFilter = FavoriteMediaType.video,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: 8),
|
||
FilterChip(
|
||
avatar: const Icon(Icons.image_rounded, size: 16),
|
||
label: Text(isRu ? 'Фото' : 'Images'),
|
||
selected: _mediaFilter == FavoriteMediaType.image,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _mediaFilter = FavoriteMediaType.image,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 4),
|
||
|
||
// Main Content
|
||
Expanded(
|
||
child: switch (_tabIndex) {
|
||
1 => _buildOfflineView(
|
||
offlinePosts: offlinePosts,
|
||
allPosts: allPosts,
|
||
downloaded: downloaded,
|
||
diskSizeBytes: diskSizeBytes,
|
||
activeTasks: activeTasks,
|
||
settings: settings,
|
||
isRu: isRu,
|
||
),
|
||
2 => _buildArtistsView(
|
||
allPosts,
|
||
settings,
|
||
downloaded.keys.toSet(),
|
||
isRu,
|
||
),
|
||
_ => _buildAllView(
|
||
allPosts,
|
||
settings,
|
||
downloaded.keys.toSet(),
|
||
isRu,
|
||
),
|
||
},
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAllView(
|
||
List<Post> posts,
|
||
AppSettings settings,
|
||
Set<String> downloadedKeys,
|
||
bool isRu,
|
||
) {
|
||
final filtered = _applyFilters(posts);
|
||
if (filtered.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.filter_list_off_rounded,
|
||
size: 48,
|
||
color: Theme.of(context).colorScheme.outline,
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
isRu ? 'Ничего не найдено' : 'No matching posts',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
_mediaFilter = FavoriteMediaType.all;
|
||
_searchController.clear();
|
||
_searchQuery = '';
|
||
});
|
||
},
|
||
child: Text(isRu ? 'Сбросить фильтры' : 'Reset filters'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return _postsGrid(filtered, settings, downloadedKeys);
|
||
}
|
||
|
||
Widget _buildOfflineView({
|
||
required List<Post> offlinePosts,
|
||
required List<Post> allPosts,
|
||
required Map<String, DownloadedMedia> downloaded,
|
||
required int diskSizeBytes,
|
||
required List<DownloadTask> activeTasks,
|
||
required AppSettings settings,
|
||
required bool isRu,
|
||
}) {
|
||
final pendingPosts =
|
||
allPosts.where((p) => !downloaded.containsKey(p.cacheKey)).toList();
|
||
|
||
final sorted = List<Post>.from(offlinePosts);
|
||
if (_offlineSort == OfflineSortOption.sizeDesc) {
|
||
sorted.sort((a, b) {
|
||
final mA = downloaded[a.cacheKey];
|
||
final mB = downloaded[b.cacheKey];
|
||
final sA = mA != null ? DownloadedMediaService.getFileSizeSync(mA) : 0;
|
||
final sB = mB != null ? DownloadedMediaService.getFileSizeSync(mB) : 0;
|
||
return sB.compareTo(sA);
|
||
});
|
||
} else if (_offlineSort == OfflineSortOption.artist) {
|
||
sorted.sort((a, b) {
|
||
final artA = (a.tagGroups['artist'] ?? const []).firstOrNull ?? '';
|
||
final artB = (b.tagGroups['artist'] ?? const []).firstOrNull ?? '';
|
||
return artA.toLowerCase().compareTo(artB.toLowerCase());
|
||
});
|
||
} else {
|
||
sorted.sort((a, b) {
|
||
final dA = downloaded[a.cacheKey]?.downloadedAt ??
|
||
DateTime.fromMillisecondsSinceEpoch(0);
|
||
final dB = downloaded[b.cacheKey]?.downloadedAt ??
|
||
DateTime.fromMillisecondsSinceEpoch(0);
|
||
return dB.compareTo(dA);
|
||
});
|
||
}
|
||
|
||
final filtered = _applyFilters(sorted);
|
||
|
||
return Column(
|
||
children: [
|
||
_buildOfflineSyncHeader(
|
||
downloadedCount: offlinePosts.length,
|
||
totalCount: allPosts.length,
|
||
pendingCount: pendingPosts.length,
|
||
diskSizeBytes: diskSizeBytes,
|
||
activeTasks: activeTasks,
|
||
pendingPosts: pendingPosts,
|
||
isRu: isRu,
|
||
),
|
||
Expanded(
|
||
child: offlinePosts.isEmpty
|
||
? Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(18),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.secondaryContainer
|
||
.withValues(alpha: 0.35),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Icons.cloud_off_rounded,
|
||
size: 48,
|
||
color: Theme.of(context).colorScheme.secondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
isRu ? 'Нет офлайн постов' : 'No offline posts',
|
||
style:
|
||
Theme.of(context).textTheme.titleLarge?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
isRu
|
||
? 'Нажмите «Скачать всё», чтобы загрузить избранные посты на устройство'
|
||
: 'Tap "Download all" above to save favorites for offline viewing',
|
||
textAlign: TextAlign.center,
|
||
style:
|
||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
: filtered.isEmpty
|
||
? Center(
|
||
child: Text(isRu
|
||
? 'Ничего не найдено по фильтрам'
|
||
: 'No matching offline posts'),
|
||
)
|
||
: _postsGrid(filtered, settings, downloaded.keys.toSet()),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildOfflineSyncHeader({
|
||
required int downloadedCount,
|
||
required int totalCount,
|
||
required int pendingCount,
|
||
required int diskSizeBytes,
|
||
required List<DownloadTask> activeTasks,
|
||
required List<Post> pendingPosts,
|
||
required bool isRu,
|
||
}) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
|
||
return _FavoritesLiquidCard(
|
||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||
padding: const EdgeInsets.all(14),
|
||
glowColor: scheme.primary,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
scheme.primary,
|
||
scheme.primary.withValues(alpha: 0.85),
|
||
],
|
||
),
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: scheme.primary.withValues(alpha: 0.35),
|
||
blurRadius: 7,
|
||
offset: const Offset(0, 2.5),
|
||
),
|
||
],
|
||
),
|
||
child: const Center(
|
||
child: Icon(
|
||
Icons.offline_pin_rounded,
|
||
color: Colors.white,
|
||
size: 22,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'$downloadedCount ${isRu ? 'из' : 'of'} $totalCount ${isRu ? 'офлайн' : 'saved'}',
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: -0.2,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.sd_storage_rounded,
|
||
size: 14,
|
||
color: scheme.primary,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'${DownloadedMediaService.formatBytes(diskSizeBytes)} ${isRu ? 'на устройстве' : 'on disk'}',
|
||
style:
|
||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: scheme.onSurfaceVariant,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (pendingCount > 0)
|
||
FilledButton.tonalIcon(
|
||
onPressed: () async {
|
||
HapticFeedback.mediumImpact();
|
||
final dm = ref.read(downloadManagerServiceProvider);
|
||
for (final p in pendingPosts) {
|
||
await dm.start(p);
|
||
}
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
isRu
|
||
? 'В очередь загрузки добавлено: $pendingCount'
|
||
: 'Queued $pendingCount downloads',
|
||
),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
icon: const Icon(Icons.download_rounded, size: 16),
|
||
label: Text(
|
||
isRu
|
||
? 'Скачать всё ($pendingCount)'
|
||
: 'Download ($pendingCount)',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
style: FilledButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 6,
|
||
),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: scheme.primary.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.check_circle_rounded,
|
||
size: 16,
|
||
color: scheme.primary,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
isRu ? 'Всё готово' : 'Synced',
|
||
style:
|
||
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
color: scheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (activeTasks.isNotEmpty) ...[
|
||
const SizedBox(height: 10),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: const LinearProgressIndicator(minHeight: 3),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'${isRu ? 'Загрузка...' : 'Downloading...'} (${activeTasks.length})',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: scheme.primary,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
Flexible(
|
||
child: Text(
|
||
activeTasks.first.fileName,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: scheme.onSurfaceVariant,
|
||
fontSize: 11,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(height: 10),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
'${isRu ? 'Сортировка' : 'Sort'}:',
|
||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ChoiceChip(
|
||
label: Text(isRu ? 'По дате' : 'Date'),
|
||
selected: _offlineSort == OfflineSortOption.dateAddedDesc,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _offlineSort = OfflineSortOption.dateAddedDesc,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: 6),
|
||
ChoiceChip(
|
||
label: Text(isRu ? 'По размеру' : 'Size'),
|
||
selected: _offlineSort == OfflineSortOption.sizeDesc,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _offlineSort = OfflineSortOption.sizeDesc,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: 6),
|
||
ChoiceChip(
|
||
label: Text(isRu ? 'По автору' : 'Artist'),
|
||
selected: _offlineSort == OfflineSortOption.artist,
|
||
onSelected: (_) {
|
||
HapticFeedback.selectionClick();
|
||
setState(
|
||
() => _offlineSort = OfflineSortOption.artist,
|
||
);
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildArtistsView(
|
||
List<Post> posts,
|
||
AppSettings settings,
|
||
Set<String> downloadedKeys,
|
||
bool isRu,
|
||
) {
|
||
final groups = favoriteArtistAlbums(posts);
|
||
final selected = _selectedArtist;
|
||
|
||
if (selected != null) {
|
||
final artistPosts = groups[selected] ?? const [];
|
||
final filtered = _applyFilters(artistPosts);
|
||
|
||
return Column(
|
||
children: [
|
||
// Artist Header Banner (Liquid Glass)
|
||
_FavoritesLiquidCard(
|
||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
onPressed: () {
|
||
HapticFeedback.selectionClick();
|
||
setState(() => _selectedArtist = null);
|
||
},
|
||
icon: const Icon(Icons.arrow_back_rounded),
|
||
tooltip: isRu ? 'Все авторы' : 'All artists',
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
Theme.of(context).colorScheme.primary,
|
||
Theme.of(context).colorScheme.tertiary,
|
||
],
|
||
),
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.primary
|
||
.withValues(alpha: 0.35),
|
||
blurRadius: 6,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
selected.isNotEmpty ? selected[0].toUpperCase() : '?',
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
selected,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: -0.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
'${artistPosts.length} ${isRu ? 'работ' : 'posts'}',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
FilledButton.tonalIcon(
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
onPressed: () {
|
||
HapticFeedback.selectionClick();
|
||
context.push('/search?q=${Uri.encodeComponent('artist:$selected')}');
|
||
},
|
||
icon: const Icon(Icons.search_rounded, size: 16),
|
||
label: Text(isRu ? 'В сети' : 'Search'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: filtered.isEmpty
|
||
? Center(
|
||
child: Text(isRu ? 'Ничего не найдено' : 'No posts found'),
|
||
)
|
||
: _postsGrid(filtered, settings, downloadedKeys),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
final query = _searchQuery.toLowerCase();
|
||
final entries = groups.entries.where((e) {
|
||
if (query.isEmpty) return true;
|
||
return e.key.toLowerCase().contains(query);
|
||
}).toList();
|
||
|
||
if (entries.isEmpty) {
|
||
return Center(
|
||
child: Text(isRu ? 'Авторы не найдены' : 'No artists found'),
|
||
);
|
||
}
|
||
|
||
return ListView.separated(
|
||
// Extended bottom padding so the last artist cards scroll comfortably above the floating dock
|
||
padding: EdgeInsets.fromLTRB(
|
||
16, 8, 16, 140 + MediaQuery.paddingOf(context).bottom),
|
||
itemCount: entries.length,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||
itemBuilder: (context, index) {
|
||
final entry = entries[index];
|
||
final artistPosts = entry.value;
|
||
final previewUrls = artistPosts
|
||
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||
.whereType<String>()
|
||
.take(3)
|
||
.toList();
|
||
|
||
return _ArtistCard(
|
||
artistName: entry.key,
|
||
postsCount: artistPosts.length,
|
||
previewUrls: previewUrls,
|
||
isRu: isRu,
|
||
onTap: () => setState(() => _selectedArtist = entry.key),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _postsGrid(
|
||
List<Post> posts,
|
||
AppSettings settings,
|
||
Set<String> downloadedKeys,
|
||
) {
|
||
if (posts.isEmpty) return const EmptyView(title: 'Nothing here yet');
|
||
return PostMasonryGrid(
|
||
posts: posts,
|
||
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: posts.map((post) => post.cacheKey).toSet(),
|
||
downloadedKeys: downloadedKeys,
|
||
onOpen: (post) => AppNavigator.openPost(
|
||
context,
|
||
post: post,
|
||
postsList: posts,
|
||
),
|
||
onFavorite: (post) =>
|
||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FavoritesLiquidCard extends StatelessWidget {
|
||
const _FavoritesLiquidCard({
|
||
required this.child,
|
||
this.padding,
|
||
this.glowColor,
|
||
this.margin,
|
||
});
|
||
|
||
final Widget child;
|
||
final EdgeInsetsGeometry? padding;
|
||
final EdgeInsetsGeometry? margin;
|
||
final Color? glowColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
final accent = glowColor ?? theme.colorScheme.primary;
|
||
|
||
return Container(
|
||
margin: margin,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isDark
|
||
? Colors.black.withValues(alpha: 0.30)
|
||
: accent.withValues(alpha: 0.07),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 5),
|
||
spreadRadius: -2,
|
||
),
|
||
BoxShadow(
|
||
color: accent.withValues(alpha: isDark ? 0.05 : 0.02),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(22),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||
child: Container(
|
||
padding: padding ?? const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: isDark
|
||
? [
|
||
theme.colorScheme.surfaceContainerHigh
|
||
.withValues(alpha: 0.60),
|
||
theme.colorScheme.surfaceContainerLow
|
||
.withValues(alpha: 0.38),
|
||
]
|
||
: [
|
||
Colors.white.withValues(alpha: 0.88),
|
||
Colors.white.withValues(alpha: 0.72),
|
||
],
|
||
),
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.14)
|
||
: Colors.white.withValues(alpha: 0.85),
|
||
width: 1.2,
|
||
),
|
||
),
|
||
child: child,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ArtistCard extends StatelessWidget {
|
||
const _ArtistCard({
|
||
required this.artistName,
|
||
required this.postsCount,
|
||
required this.previewUrls,
|
||
required this.isRu,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String artistName;
|
||
final int postsCount;
|
||
final List<String> previewUrls;
|
||
final bool isRu;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isDark
|
||
? Colors.black.withValues(alpha: 0.28)
|
||
: scheme.primary.withValues(alpha: 0.06),
|
||
blurRadius: 14,
|
||
offset: const Offset(0, 4),
|
||
spreadRadius: -2,
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(22),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(22),
|
||
onTap: () {
|
||
HapticFeedback.selectionClick();
|
||
onTap();
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(22),
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: isDark
|
||
? [
|
||
scheme.surfaceContainerHigh
|
||
.withValues(alpha: 0.58),
|
||
scheme.surfaceContainerLow
|
||
.withValues(alpha: 0.36),
|
||
]
|
||
: [
|
||
Colors.white.withValues(alpha: 0.88),
|
||
Colors.white.withValues(alpha: 0.72),
|
||
],
|
||
),
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.13)
|
||
: Colors.white.withValues(alpha: 0.85),
|
||
width: 1.1,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// Artist Squircle Avatar
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
scheme.primary,
|
||
scheme.tertiary,
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: BorderRadius.circular(15),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: scheme.primary.withValues(alpha: 0.35),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2.5),
|
||
),
|
||
],
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
artistName.isNotEmpty ? artistName[0].toUpperCase() : '?',
|
||
style: const TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// Artist Name & Count
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
artistName,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: -0.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 3),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 2,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: scheme.primary.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
'$postsCount ${isRu ? 'работ' : 'posts'}',
|
||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
color: scheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Thumbnails preview
|
||
if (previewUrls.isNotEmpty) ...[
|
||
const SizedBox(width: 8),
|
||
Row(
|
||
children: [
|
||
for (final url in previewUrls)
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 5),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.10)
|
||
: scheme.outlineVariant.withValues(alpha: 0.25),
|
||
),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: CachedNetworkImage(
|
||
imageUrl: url,
|
||
memCacheWidth: 128,
|
||
memCacheHeight: 128,
|
||
fit: BoxFit.cover,
|
||
placeholder: (_, __) => Container(
|
||
color: scheme.surfaceContainerHighest,
|
||
),
|
||
errorWidget: (_, __, ___) => Container(
|
||
color: scheme.surfaceContainerHighest,
|
||
child: const Icon(
|
||
Icons.image_not_supported_rounded,
|
||
size: 16,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(width: 4),
|
||
Icon(
|
||
Icons.chevron_right_rounded,
|
||
size: 20,
|
||
color: scheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Map<String, List<Post>> favoriteArtistAlbums(List<Post> posts) {
|
||
final groups = <String, List<Post>>{};
|
||
for (final post in posts) {
|
||
final artists = post.tagGroups['artist'] ??
|
||
post.tags.where((tag) => tag.startsWith('artist:')).toList();
|
||
final names = artists.isEmpty
|
||
? const ['Unknown artist']
|
||
: artists.map((tag) => tag.replaceFirst('artist:', '')).toList();
|
||
for (final name in names) {
|
||
groups.putIfAbsent(name, () => []).add(post);
|
||
}
|
||
}
|
||
return Map.fromEntries(
|
||
groups.entries.toList()
|
||
..sort((a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase())),
|
||
);
|
||
}
|