feat: 4-tab major redesign: Search, Favorites, History & Collections v2.1.0
This commit is contained in:
@@ -6,10 +6,11 @@ import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'collection_form_dialog.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
class CollectionDetailsScreen extends ConsumerWidget {
|
||||
@@ -19,60 +20,181 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
||||
final postsAsync = ref.watch(collectionPostsProvider(collectionId));
|
||||
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
||||
final title = collections
|
||||
?.where((collection) => collection.id == collectionId)
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'Collection';
|
||||
final collection = collections
|
||||
?.where((c) => c.id == collectionId)
|
||||
.firstOrNull;
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: title,
|
||||
body: posts.when(
|
||||
title: collection?.name ?? (isRu ? 'Коллекция' : 'Collection'),
|
||||
actions: [
|
||||
if (collection != null) ...[
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Редактировать' : 'Edit',
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: collection,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Удалить' : 'Delete',
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||
message: isRu
|
||||
? 'Удалить подборку "${collection.name}"?'
|
||||
: 'Remove "${collection.name}"?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(collection.id);
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
body: postsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'Collection is empty')
|
||||
: PostMasonryGrid(
|
||||
posts: items,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
data: (items) {
|
||||
if (items.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.collections_bookmark_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'В коллекции пока нет постов' : 'Collection is empty',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Добавляйте посты из ленты через меню «Добавить в коллекцию» на карточке поста'
|
||||
: 'Add posts to this collection using "Add to collection" from post cards in the feed',
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (collection?.description != null &&
|
||||
collection!.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
collection.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
Expanded(
|
||||
child: 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,
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
@@ -9,56 +10,117 @@ Future<void> showCollectionFormDialog(
|
||||
WidgetRef ref, {
|
||||
Collection? collection,
|
||||
}) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final nameController = TextEditingController(text: collection?.name);
|
||||
final descriptionController =
|
||||
TextEditingController(text: collection?.description);
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(collection == null ? 'New collection' : 'Edit collection'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
builder: (context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(24, 16, 24, 20),
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
collection == null
|
||||
? Icons.create_new_folder_rounded
|
||||
: Icons.edit_note_rounded,
|
||||
color: scheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
collection == null
|
||||
? (isRu ? 'Новая коллекция' : 'New collection')
|
||||
: (isRu ? 'Редактировать' : 'Edit collection'),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: collection == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRu ? 'Название' : 'Name',
|
||||
hintText: isRu ? 'Например, Избранные обои' : 'e.g. Best Wallpapers',
|
||||
prefixIcon: const Icon(Icons.bookmark_border_rounded),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRu ? 'Описание (необязательно)' : 'Description (optional)',
|
||||
hintText: isRu ? 'О чем эта подборка' : 'What is this collection about',
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(isRu ? 'Отмена' : 'Cancel'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (collection == null) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.create(name, descriptionController.text.trim());
|
||||
} else {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.updateCollection(
|
||||
collection.id,
|
||||
name: name,
|
||||
description: descriptionController.text.trim(),
|
||||
coverUrl: collection.coverUrl,
|
||||
);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Text(isRu ? 'Сохранить' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (collection == null) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.create(name, descriptionController.text.trim());
|
||||
} else {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.updateCollection(
|
||||
collection.id,
|
||||
name: name,
|
||||
description: descriptionController.text.trim(),
|
||||
coverUrl: collection.coverUrl,
|
||||
);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../backend/models/collection.dart';
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'collection_form_dialog.dart';
|
||||
import 'collections_controller.dart';
|
||||
@@ -16,11 +17,15 @@ class CollectionsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final collections = ref.watch(collectionsControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Collections',
|
||||
title: isRu ? 'Коллекции' : 'Collections',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New collection',
|
||||
tooltip: isRu ? 'Новая коллекция' : 'New collection',
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
onPressed: () => showCollectionFormDialog(context, ref),
|
||||
),
|
||||
@@ -28,141 +33,251 @@ class CollectionsScreen extends ConsumerWidget {
|
||||
body: collections.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'No collections yet')
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.35,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _CollectionCard(
|
||||
collection: item,
|
||||
onOpen: () => context.go('/collections/${item.id}'),
|
||||
onEdit: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: item,
|
||||
data: (items) {
|
||||
if (items.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.collections_bookmark_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Delete collection',
|
||||
message: 'Remove ${item.name}?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'Нет коллекций' : 'No collections yet',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Создавайте тематические коллекции и объединяйте в них понравившиеся работы'
|
||||
: 'Create themed collections and organize your favorite posts',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => showCollectionFormDialog(context, ref),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(
|
||||
isRu ? 'Создать коллекцию' : 'Create collection',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisSpacing: 14,
|
||||
crossAxisSpacing: 14,
|
||||
childAspectRatio: 0.88,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _CollectionCard(
|
||||
collection: item,
|
||||
isRu: isRu,
|
||||
onOpen: () => context.go('/collections/${item.id}'),
|
||||
onEdit: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: item,
|
||||
),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||
message: isRu
|
||||
? 'Удалить подборку "${item.name}"?'
|
||||
: 'Remove "${item.name}"?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollectionCard extends StatelessWidget {
|
||||
class _CollectionCard extends ConsumerWidget {
|
||||
const _CollectionCard({
|
||||
required this.collection,
|
||||
required this.isRu,
|
||||
required this.onOpen,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Collection collection;
|
||||
final bool isRu;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final postsAsync = ref.watch(collectionPostsProvider(collection.id));
|
||||
final posts = postsAsync.value ?? const <Post>[];
|
||||
final previewUrls = posts
|
||||
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||||
.whereType<String>()
|
||||
.take(4)
|
||||
.toList();
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerLow,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
child: Stack(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 54,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
scheme.primaryContainer.withValues(alpha: 0.56),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// Cover Collage (2x2 or Hero)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCoverMosaic(previewUrls, scheme),
|
||||
// Glass count badge
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 28,
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${posts.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
collection.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
collection.description ?? 'No description',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: PopupMenuButton<String>(
|
||||
tooltip: 'Collection actions',
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.edit_rounded),
|
||||
title: Text('Edit'),
|
||||
// Info Row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
collection.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (collection.description != null &&
|
||||
collection.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
collection.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.delete_rounded),
|
||||
title: Text('Delete'),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: isRu ? 'Действия' : 'Actions',
|
||||
icon: Icon(
|
||||
Icons.more_vert_rounded,
|
||||
size: 20,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.edit_rounded, size: 20),
|
||||
title: Text(isRu ? 'Редактировать' : 'Edit'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.delete_rounded, size: 20),
|
||||
title: Text(isRu ? 'Удалить' : 'Delete'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -172,4 +287,63 @@ class _CollectionCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverMosaic(List<String> urls, ColorScheme scheme) {
|
||||
if (urls.isEmpty) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.primaryContainer.withValues(alpha: 0.6),
|
||||
scheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 40,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (urls.length == 1) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: urls[0],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mosaic grid (up to 4)
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (i < urls.length)
|
||||
CachedNetworkImage(
|
||||
imageUrl: urls[i],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) =>
|
||||
Container(color: scheme.surfaceContainerHighest),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.image_not_supported_rounded, size: 16),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(color: scheme.surfaceContainerHighest),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -11,6 +12,8 @@ import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import 'favorites_controller.dart';
|
||||
|
||||
enum FavoriteMediaType { all, video, image }
|
||||
|
||||
class FavoritesScreen extends ConsumerStatefulWidget {
|
||||
const FavoritesScreen({super.key});
|
||||
|
||||
@@ -21,85 +24,528 @@ class FavoritesScreen extends ConsumerStatefulWidget {
|
||||
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
int _tabIndex = 0;
|
||||
String? _selectedArtist;
|
||||
FavoriteMediaType _mediaFilter = FavoriteMediaType.all;
|
||||
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 posts = state.value?.posts ?? const <Post>[];
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final allPosts = state.value?.posts ?? const <Post>[];
|
||||
final downloaded = ref
|
||||
.watch(downloadedMediaByKeysProvider(
|
||||
posts.map((post) => post.cacheKey).toList(growable: false),
|
||||
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);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: settings.languageCode == 'ru' ? 'Избранное' : 'Favorites',
|
||||
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) => data.posts.isEmpty
|
||||
? const EmptyView(title: 'No favorites yet')
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: Icon(Icons.favorite_rounded),
|
||||
label: Text('All'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: Icon(Icons.offline_pin_rounded),
|
||||
label: Text('Offline'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
icon: Icon(Icons.groups_rounded),
|
||||
label: Text('Artists'),
|
||||
),
|
||||
],
|
||||
selected: {_tabIndex},
|
||||
onSelectionChanged: (value) {
|
||||
setState(() {
|
||||
_tabIndex = value.first;
|
||||
_selectedArtist = null;
|
||||
});
|
||||
},
|
||||
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, 8, 16, 4),
|
||||
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: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (_tabIndex) {
|
||||
1 => _postsGrid(
|
||||
data.posts
|
||||
.where((post) =>
|
||||
downloaded.containsKey(post.cacheKey))
|
||||
.toList(),
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
|
||||
// Navigation Tabs
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 6),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<int>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: const Icon(Icons.favorite_rounded, size: 18),
|
||||
label: Text(
|
||||
'${isRu ? 'Все' : 'All'} (${allPosts.length})',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
2 => _artistAlbums(
|
||||
data.posts,
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: const Icon(Icons.offline_pin_rounded, size: 18),
|
||||
label: Text(
|
||||
'${isRu ? 'Офлайн' : 'Offline'} (${offlinePosts.length})',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_ => _postsGrid(
|
||||
data.posts,
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
icon: const Icon(Icons.groups_rounded, size: 18),
|
||||
label: Text(
|
||||
'${isRu ? 'Авторы' : 'Artists'} (${artistGroups.length})',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_tabIndex},
|
||||
onSelectionChanged: (value) {
|
||||
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: (_) => 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: (_) => 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: (_) => setState(
|
||||
() => _mediaFilter = FavoriteMediaType.image,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Main Content
|
||||
Expanded(
|
||||
child: switch (_tabIndex) {
|
||||
1 => _buildOfflineView(
|
||||
offlinePosts,
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
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(
|
||||
List<Post> posts,
|
||||
AppSettings settings,
|
||||
Set<String> downloadedKeys,
|
||||
bool isRu,
|
||||
) {
|
||||
if (posts.isEmpty) {
|
||||
return 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
|
||||
? 'Загружайте понравившиеся работы для просмотра без подключения к сети'
|
||||
: 'Download posts to view them anytime without an internet connection',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final filtered = _applyFilters(posts);
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Text(isRu ? 'Ничего не найдено' : 'No matching offline posts'),
|
||||
);
|
||||
}
|
||||
return _postsGrid(filtered, settings, downloadedKeys);
|
||||
}
|
||||
|
||||
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
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _selectedArtist = null),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
tooltip: isRu ? 'Все авторы' : 'All artists',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
selected.isNotEmpty ? selected[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selected,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
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: () {
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
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,
|
||||
@@ -130,49 +576,146 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _artistAlbums(
|
||||
List<Post> posts,
|
||||
AppSettings settings,
|
||||
Set<String> downloadedKeys,
|
||||
) {
|
||||
final groups = favoriteArtistAlbums(posts);
|
||||
final selected = _selectedArtist;
|
||||
if (selected != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _selectedArtist = null),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
label: Text(selected),
|
||||
),
|
||||
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;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Artist Avatar
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.primaryContainer,
|
||||
scheme.tertiaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
artistName.isNotEmpty ? artistName[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.secondaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$postsCount ${isRu ? 'работ' : 'posts'}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Thumbnails preview
|
||||
if (previewUrls.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (final url in previewUrls)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
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: 6),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: _postsGrid(
|
||||
groups[selected] ?? const [],
|
||||
settings,
|
||||
downloadedKeys,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
children: [
|
||||
for (final entry in groups.entries)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_rounded),
|
||||
title: Text(entry.key),
|
||||
subtitle: Text('${entry.value.length} posts'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => setState(() => _selectedArtist = entry.key),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ class SearchController extends AsyncNotifier<SearchState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteHistory(String historyId) async {
|
||||
await ref.read(searchServiceProvider).deleteHistoryItem(historyId);
|
||||
final recent = await _recent();
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(recent: recent),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
await ref.read(searchServiceProvider).clearHistory();
|
||||
state = const AsyncData(SearchState());
|
||||
|
||||
@@ -2,58 +2,601 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/app_search_bar.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'search_controller.dart';
|
||||
import 'widgets/recent_searches.dart';
|
||||
|
||||
class SearchScreen extends ConsumerWidget {
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
bool _showTips = false;
|
||||
|
||||
void _onTagTap(String tag, String currentQuery) {
|
||||
final trimmed = currentQuery.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||
} else {
|
||||
final newQuery = '$trimmed $tag';
|
||||
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(newQuery)}');
|
||||
}
|
||||
}
|
||||
|
||||
void _appendToken(String token, String currentQuery) {
|
||||
final trimmed = currentQuery.trim();
|
||||
final newQuery = trimmed.isEmpty ? token : '$trimmed $token';
|
||||
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(searchControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = AppStrings(settings.languageCode);
|
||||
final isRu = strings.ru;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Search',
|
||||
title: strings.search,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear history',
|
||||
onPressed: () =>
|
||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
||||
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||
onPressed: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Очистить историю поиска?' : 'Clear search history?',
|
||||
message: isRu
|
||||
? 'Все недавние поисковые запросы будут удалены.'
|
||||
: 'All recent search queries will be removed.',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.clearHistory();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: data.query,
|
||||
suggestions: data.suggestions,
|
||||
onSubmitted: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onSuggestionApplied: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
data: (data) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 860),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
children: [
|
||||
// 1. Search Bar with Quick Operators
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: data.query,
|
||||
suggestions: data.suggestions,
|
||||
onSubmitted: (query) => context
|
||||
.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onSuggestionApplied: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_QuickOperatorChip(
|
||||
label: 'and',
|
||||
icon: Icons.alt_route_rounded,
|
||||
color: const Color(0xFFF59E0B),
|
||||
onTap: () => _appendToken('and', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'type:video',
|
||||
icon: Icons.videocam_rounded,
|
||||
color: const Color(0xFF3B82F6),
|
||||
onTap: () =>
|
||||
_appendToken('type:video', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'type:gif',
|
||||
icon: Icons.gif_rounded,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
onTap: () => _appendToken('type:gif', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'rating:safe',
|
||||
icon: Icons.verified_user_rounded,
|
||||
color: const Color(0xFF10B981),
|
||||
onTap: () =>
|
||||
_appendToken('rating:safe', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'rating:explicit',
|
||||
icon: Icons.explicit_rounded,
|
||||
color: const Color(0xFFEF4444),
|
||||
onTap: () =>
|
||||
_appendToken('rating:explicit', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'score:>100',
|
||||
icon: Icons.star_rounded,
|
||||
color: const Color(0xFFF97316),
|
||||
onTap: () =>
|
||||
_appendToken('score:>100', data.query),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. Recent Searches Card
|
||||
_SearchSectionCard(
|
||||
icon: Icons.history_rounded,
|
||||
accentColor: const Color(0xFF6366F1),
|
||||
title: isRu ? 'Недавние запросы' : 'Recent searches',
|
||||
badgeCount: data.recent.length,
|
||||
trailing: data.recent.isEmpty
|
||||
? null
|
||||
: TextButton(
|
||||
onPressed: () => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.clearHistory(),
|
||||
child: Text(
|
||||
isRu ? 'Очистить' : 'Clear',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onDelete: (id) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.deleteHistory(id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Explore Popular Categories & Tags
|
||||
_SearchSectionCard(
|
||||
icon: Icons.explore_rounded,
|
||||
accentColor: const Color(0xFF10B981),
|
||||
title: isRu ? 'Популярные категории' : 'Explore categories',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CategoryTagRow(
|
||||
categoryName: isRu ? 'Тематика' : 'Theme & Ambience',
|
||||
color: const Color(0xFF0EA5E9),
|
||||
tags: const [
|
||||
'scenery',
|
||||
'cyberpunk',
|
||||
'monochrome',
|
||||
'night',
|
||||
'fantasy',
|
||||
'original',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CategoryTagRow(
|
||||
categoryName:
|
||||
isRu ? 'Персонажи и детали' : 'Characters & Attire',
|
||||
color: const Color(0xFFEC4899),
|
||||
tags: const [
|
||||
'1girl',
|
||||
'solo',
|
||||
'cat_ears',
|
||||
'short_hair',
|
||||
'long_hair',
|
||||
'maid',
|
||||
'uniform',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CategoryTagRow(
|
||||
categoryName: isRu ? 'Провайдеры' : 'Providers',
|
||||
color: const Color(0xFF8B5CF6),
|
||||
tags: const [
|
||||
'provider:gelbooru',
|
||||
'provider:danbooru',
|
||||
'provider:e621',
|
||||
'provider:rule34',
|
||||
'provider:pawchive',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 4. Search Tips / Cheat Sheet Card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF59E0B).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.lightbulb_outline_rounded,
|
||||
size: 18,
|
||||
color: Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
isRu ? 'Шпаргалка по поиску' : 'Search tips & operators',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
initiallyExpanded: _showTips,
|
||||
onExpansionChanged: (exp) => setState(() => _showTips = exp),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_TipRow(
|
||||
code: 'tag_a and tag_b',
|
||||
description: isRu
|
||||
? 'Независимый опрос обоих тегов с чередованием постов в ленте.'
|
||||
: 'Interleaves results from both queries independently.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'type:video / type:gif',
|
||||
description: isRu
|
||||
? 'Показывает только видео или анимированные GIF.'
|
||||
: 'Filters results to only videos or animated GIFs.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'rating:safe / rating:explicit',
|
||||
description: isRu
|
||||
? 'Фильтр по возрастному рейтингу медиа.'
|
||||
: 'Filter by age rating classification.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'score:>50 / score:>100',
|
||||
description: isRu
|
||||
? 'Посты с оценкой пользователей выше указанной.'
|
||||
: 'Posts with community score greater than threshold.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'provider:gelbooru',
|
||||
description: isRu
|
||||
? 'Поиск исключительно в указанном источнике.'
|
||||
: 'Search specifically on the given booru provider.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchSectionCard extends StatelessWidget {
|
||||
const _SearchSectionCard({
|
||||
required this.icon,
|
||||
required this.accentColor,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.badgeCount,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color accentColor;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final int? badgeCount;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: accentColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (badgeCount != null && badgeCount! > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$badgeCount',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickOperatorChip extends StatelessWidget {
|
||||
const _QuickOperatorChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryTagRow extends StatelessWidget {
|
||||
const _CategoryTagRow({
|
||||
required this.categoryName,
|
||||
required this.color,
|
||||
required this.tags,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String categoryName;
|
||||
final Color color;
|
||||
final List<String> tags;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
categoryName,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => onTap(tag),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
tag,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TipRow extends StatelessWidget {
|
||||
const _TipRow({required this.code, required this.description});
|
||||
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class RecentSearches extends StatefulWidget {
|
||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
||||
const RecentSearches({
|
||||
required this.items,
|
||||
required this.onTap,
|
||||
this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<SearchHistory> items;
|
||||
final ValueChanged<String> onTap;
|
||||
final ValueChanged<String>? onDelete;
|
||||
|
||||
@override
|
||||
State<RecentSearches> createState() => _RecentSearchesState();
|
||||
@@ -17,31 +23,136 @@ class _RecentSearchesState extends State<RecentSearches> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) return const Text('No recent searches');
|
||||
final theme = Theme.of(context);
|
||||
if (widget.items.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_toggle_off_rounded,
|
||||
size: 40,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'История поиска пуста',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final visibleItems =
|
||||
_expanded ? widget.items : widget.items.take(30).toList();
|
||||
_expanded ? widget.items : widget.items.take(20).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final item in visibleItems)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(item.query),
|
||||
subtitle:
|
||||
Text('${item.tags.join(' ')} - ${item.resultCount} results'),
|
||||
onTap: () => widget.onTap(item.query),
|
||||
),
|
||||
if (widget.items.length > 30)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(
|
||||
_expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded,
|
||||
),
|
||||
label: Text(
|
||||
_expanded
|
||||
? 'Show latest 30'
|
||||
: 'Show all ${widget.items.length} searches',
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final item in visibleItems)
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => widget.onTap(item.query),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
item.query,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.resultCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${item.resultCount}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.onDelete != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => widget.onDelete!(item.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.items.length > 20) ...[
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(
|
||||
_expanded
|
||||
? Icons.unfold_less_rounded
|
||||
: Icons.unfold_more_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
_expanded
|
||||
? 'Свернуть'
|
||||
: 'Показать все (${widget.items.length})',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> deleteItem(String providerId, String postId) async {
|
||||
await ref
|
||||
.read(viewedHistoryServiceProvider)
|
||||
.deleteItem(providerId, postId);
|
||||
ref.invalidate(viewedKeysProvider);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
||||
ref.invalidate(viewedKeysProvider);
|
||||
|
||||
@@ -1,85 +1,251 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'viewed_controller.dart';
|
||||
|
||||
class ViewedScreen extends ConsumerWidget {
|
||||
class ViewedScreen extends ConsumerStatefulWidget {
|
||||
const ViewedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ViewedScreen> createState() => _ViewedScreenState();
|
||||
}
|
||||
|
||||
class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||
bool _isGridView = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(viewedControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||
title: isRu ? 'История' : 'History',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear viewed history',
|
||||
onPressed: () => ref.read(viewedControllerProvider.notifier).clear(),
|
||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||
tooltip: _isGridView
|
||||
? (isRu ? 'Список' : 'List view')
|
||||
: (isRu ? 'Сетка' : 'Grid view'),
|
||||
icon: Icon(_isGridView
|
||||
? Icons.view_agenda_rounded
|
||||
: Icons.grid_view_rounded),
|
||||
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||
onPressed: () async {
|
||||
final confirm = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Очистить историю' : 'Clear history',
|
||||
message: isRu
|
||||
? 'Вы действительно хотите очистить всю историю просмотров?'
|
||||
: 'Are you sure you want to clear your entire viewing history?',
|
||||
);
|
||||
if (confirm) {
|
||||
await ref.read(viewedControllerProvider.notifier).clear();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (groups) => groups.isEmpty
|
||||
? const EmptyView(title: 'No viewed posts yet')
|
||||
: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
data: (groups) {
|
||||
if (groups.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||
child: Text(
|
||||
group.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
for (final item in group.items)
|
||||
_ViewedPostTile(
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
onOpen: () {
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
child: Icon(
|
||||
Icons.history_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'История просмотров пуста' : 'No viewed posts yet',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Посты, которые вы открываете в ленте, будут сохраняться здесь'
|
||||
: 'Posts you open from the feed will appear 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
|
||||
if (_isGridView) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: PostMasonryGrid(
|
||||
posts: allPosts,
|
||||
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,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: allPosts,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) => _toggleFavorite(
|
||||
ref,
|
||||
post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_localizedGroupLabel(group.label, isRu),
|
||||
style:
|
||||
Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${group.items.length}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
_ViewedPostTile(
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
isRu: isRu,
|
||||
onOpen: () {
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
onDelete: () => ref
|
||||
.read(viewedControllerProvider.notifier)
|
||||
.deleteItem(item.post.providerId, item.post.id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _localizedGroupLabel(String label, bool isRu) {
|
||||
if (!isRu) return label;
|
||||
if (label == 'Today') return 'Сегодня';
|
||||
if (label == 'Yesterday') return 'Вчера';
|
||||
return label;
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
@@ -98,6 +264,7 @@ class ViewedScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,44 +273,212 @@ class _ViewedPostTile extends StatelessWidget {
|
||||
required this.post,
|
||||
required this.viewedAt,
|
||||
required this.isFavorite,
|
||||
required this.isRu,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final DateTime viewedAt;
|
||||
final bool isFavorite;
|
||||
final bool isRu;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
bool get _isVideo {
|
||||
final type = post.fileType.toLowerCase();
|
||||
final url = post.fileUrl.toLowerCase();
|
||||
return type == 'mp4' ||
|
||||
type == 'webm' ||
|
||||
url.endsWith('.mp4') ||
|
||||
url.endsWith('.webm');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final image = MediaUrlSelector.preview(post).firstOrNull;
|
||||
final timeStr =
|
||||
'${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerLow,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: image == null
|
||||
? const Icon(Icons.image_not_supported_rounded)
|
||||
: Image.network(image, fit: BoxFit.cover),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Squircle Thumbnail
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
height: 68,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image == null
|
||||
? Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.image_not_supported_rounded),
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: image,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(
|
||||
Icons.broken_image_rounded,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isVideo)
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Post details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
scheme.primaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
post.providerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
timeStr,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (post.score != 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.thumb_up_rounded,
|
||||
size: 12,
|
||||
color: scheme.outline,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${post.score}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: scheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Action buttons
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
tooltip: isFavorite
|
||||
? (isRu ? 'В избранном' : 'In favorites')
|
||||
: (isRu ? 'В избранное' : 'Add to favorites'),
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: isFavorite ? Colors.redAccent : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
tooltip: isRu ? 'Удалить из истории' : 'Delete from history',
|
||||
onPressed: onDelete,
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 20,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text(post.tags.take(4).join(' '), maxLines: 1),
|
||||
subtitle: Text(
|
||||
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user