feat: 4-tab major redesign: Search, Favorites, History & Collections v2.1.0
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.15-blueviolet?style=for-the-badge" alt="Version 2.0.15" />
|
||||
<img src="https://img.shields.io/badge/Version-2.1.0-blueviolet?style=for-the-badge" alt="Version 2.1.0" />
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
## ✨ Основные возможности
|
||||
|
||||
- **🔍 Поиск 2.0**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов.
|
||||
- **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью.
|
||||
- **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки.
|
||||
- **📁 Коллекции 2.0**: Мозаичные обложки 2x2 с превью постов, счетчики работ со стеклянным бейджем, современный экран деталей коллекции и обновленный диалог создания.
|
||||
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
|
||||
|
||||
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.15';
|
||||
const appBuildNumber = 37;
|
||||
const appDisplayVersion = '2.1.0';
|
||||
const appBuildNumber = 38;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.1.0',
|
||||
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
|
||||
bullets: [
|
||||
'Search Tab Redesign: hero search container, quick search operator chips (and, type:video, rating:safe, etc.), interactive recent search cloud with 1-tap delete (x), popular exploratory category cards, and search tips cheat sheet.',
|
||||
'Favorites Tab Redesign: live counters on segment tabs (All, Offline, Artists), quick media filter chips (All, Videos, Images), in-favorites search bar, and Spotify/Apple Music-style artist album cards with 3-thumbnail preview collages.',
|
||||
'History Tab Redesign: timeline grouped by time (Today, Yesterday, Date), view mode switcher (rich list with 68x68 rounded thumbnails, provider chips, timestamps, and delete from history vs full visual Masonry gallery grid), and confirmation dialog before clearing.',
|
||||
'Collections Tab Redesign: Pinterest-style collection cards with 2x2 thumbnail mosaic covers and glassmorphism post counters, modernized collection details screen with description banner, and elegant squircle creation dialog.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.15',
|
||||
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||
|
||||
@@ -47,6 +47,12 @@ class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<bool>> delete(String historyId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
return await isar.searchHistoryEntitys.deleteByHistoryId(historyId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.clear();
|
||||
|
||||
@@ -51,6 +51,14 @@ class ViewedPostRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.viewedPostEntitys.deleteByViewedKey(
|
||||
ViewedPost.keyFor(providerId, postId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.viewedPostEntitys.clear();
|
||||
|
||||
@@ -161,6 +161,9 @@ class SearchService {
|
||||
return Success(sorted.take(limit).toList(growable: false));
|
||||
}
|
||||
|
||||
Future<Result<bool>> deleteHistoryItem(String historyId) =>
|
||||
_repository.delete(historyId);
|
||||
|
||||
Future<Result<void>> clearHistory() => _repository.clear();
|
||||
|
||||
Future<void> clearTagCache() async {
|
||||
|
||||
@@ -59,6 +59,10 @@ class ViewedHistoryService {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||
return _repository.deleteItem(providerId, postId);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() {
|
||||
return _repository.clear();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.15+37
|
||||
version: 2.1.0+38
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart' hide SearchController;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/app/app.dart';
|
||||
import 'package:gel_rule_app/backend/backend.dart';
|
||||
import 'package:gel_rule_app/features/collections/presentation/collections_controller.dart';
|
||||
import 'package:gel_rule_app/features/collections/presentation/collections_screen.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_controller.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_screen.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_state.dart';
|
||||
import 'package:gel_rule_app/features/search/presentation/search_controller.dart'
|
||||
as search_feature;
|
||||
import 'package:gel_rule_app/features/search/presentation/search_screen.dart';
|
||||
import 'package:gel_rule_app/features/search/presentation/search_state.dart';
|
||||
import 'package:gel_rule_app/features/viewed/presentation/viewed_controller.dart';
|
||||
import 'package:gel_rule_app/features/viewed/presentation/viewed_screen.dart';
|
||||
|
||||
Post _samplePost({
|
||||
required String id,
|
||||
String providerId = 'danbooru',
|
||||
String providerName = 'Danbooru',
|
||||
List<String> tags = const ['tag1', 'artist:test_artist'],
|
||||
String fileType = 'jpg',
|
||||
}) {
|
||||
return Post(
|
||||
id: id,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: 'https://example.com/$id.jpg',
|
||||
sampleUrl: 'https://example.com/$id.jpg',
|
||||
fileUrl: 'https://example.com/$id.jpg',
|
||||
tags: tags,
|
||||
rating: 'safe',
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: fileType,
|
||||
score: 42,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
testWidgets('FavoritesScreen renders segments, counters and filter chips',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final post = _samplePost(id: '1');
|
||||
final post2 = _samplePost(
|
||||
id: '2', fileType: 'mp4', tags: ['artist:animator', 'video']);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
favoritesControllerProvider.overrideWith(
|
||||
() => _FakeFavoritesController([post, post2]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
downloadedMediaByKeysProvider([post.cacheKey, post2.cacheKey])
|
||||
.overrideWith((ref) => <String, DownloadedMedia>{}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: FavoritesScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify segmented button labels with live counts
|
||||
expect(find.text('Все (2)'), findsOneWidget);
|
||||
expect(find.text('Офлайн (0)'), findsOneWidget);
|
||||
expect(find.text('Авторы (2)'), findsOneWidget);
|
||||
|
||||
// Verify media filter chips
|
||||
expect(find.text('Все'), findsWidgets);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
expect(find.text('Фото'), findsOneWidget);
|
||||
|
||||
// Switch to Artists tab
|
||||
await tester.tap(find.text('Авторы (2)'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('test_artist'), findsOneWidget);
|
||||
expect(find.text('animator'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ViewedScreen renders timeline and switches view mode',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final post = _samplePost(id: '10', tags: ['landscape', 'sunset']);
|
||||
final group = ViewedTimelineGroup(
|
||||
label: 'Today',
|
||||
items: [
|
||||
ViewedPostEntry(post: post, viewedAt: DateTime.now()),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
viewedControllerProvider.overrideWith(
|
||||
() => _FakeViewedController([group]),
|
||||
),
|
||||
favoriteKeysProvider.overrideWith(
|
||||
(ref) => <String>{},
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: ViewedScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify timeline group
|
||||
expect(find.text('Сегодня'), findsOneWidget);
|
||||
expect(find.text('landscape sunset'), findsOneWidget);
|
||||
|
||||
// Switch to Grid mode
|
||||
final gridBtn = find.byIcon(Icons.grid_view_rounded);
|
||||
expect(gridBtn, findsOneWidget);
|
||||
await tester.tap(gridBtn);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Now icon should switch to list mode
|
||||
expect(find.byIcon(Icons.view_agenda_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CollectionsScreen renders collection cards and new collection button',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final collection = Collection(
|
||||
id: 'c1',
|
||||
name: 'Cyberpunk Art',
|
||||
description: 'Futuristic neon cities',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
collectionsControllerProvider.overrideWith(
|
||||
() => _FakeCollectionsController([collection]),
|
||||
),
|
||||
collectionPostsProvider('c1').overrideWith(
|
||||
(ref) => Future.value([_samplePost(id: '1')]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: CollectionsScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Cyberpunk Art'), findsOneWidget);
|
||||
expect(find.text('Futuristic neon cities'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'SearchScreen renders hero search bar, quick chips, and categories',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
search_feature.searchControllerProvider.overrideWith(
|
||||
() => _FakeSearchController([
|
||||
SearchHistory(
|
||||
id: 'h1',
|
||||
query: 'genshin_impact',
|
||||
tags: const ['genshin_impact'],
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: 1500,
|
||||
),
|
||||
]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SearchScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify Search Bar and Quick Operator Chips
|
||||
expect(find.text('type:video'), findsOneWidget);
|
||||
expect(find.text('rating:safe'), findsOneWidget);
|
||||
|
||||
// Verify Recent Searches chip
|
||||
expect(find.text('genshin_impact'), findsOneWidget);
|
||||
|
||||
// Verify Popular Categories
|
||||
expect(find.text('Тематика'), findsOneWidget);
|
||||
expect(find.text('Персонажи и детали'), findsOneWidget);
|
||||
expect(find.text('Провайдеры'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFavoritesController extends FavoritesController {
|
||||
_FakeFavoritesController(this._posts);
|
||||
|
||||
final List<Post> _posts;
|
||||
|
||||
@override
|
||||
Future<FavoritesState> build() async {
|
||||
return FavoritesState(posts: _posts);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeViewedController extends ViewedController {
|
||||
_FakeViewedController(this._groups);
|
||||
|
||||
final List<ViewedTimelineGroup> _groups;
|
||||
|
||||
@override
|
||||
Future<List<ViewedTimelineGroup>> build() async => _groups;
|
||||
}
|
||||
|
||||
class _FakeCollectionsController extends CollectionsController {
|
||||
_FakeCollectionsController(this._collections);
|
||||
|
||||
final List<Collection> _collections;
|
||||
|
||||
@override
|
||||
Future<List<Collection>> build() async => _collections;
|
||||
}
|
||||
|
||||
class _FakeSearchController extends search_feature.SearchController {
|
||||
_FakeSearchController(this._history);
|
||||
|
||||
final List<SearchHistory> _history;
|
||||
|
||||
@override
|
||||
Future<SearchState> build() async {
|
||||
return SearchState(
|
||||
recent: _history,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user