Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33248ef9d | ||
|
|
1b08194666 | ||
|
|
37f2dbf47d | ||
|
|
4bbe04dfd8 | ||
|
|
e8448eb748 | ||
|
|
7e384b1be7 |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" />
|
<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/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<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/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" />
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
|
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
|
||||||
|
|
||||||
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
||||||
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
||||||
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
||||||
@@ -69,9 +71,9 @@
|
|||||||
|
|
||||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||||
|
|
||||||
- **Android**: `Lunaris-v2.0.9.apk`
|
- **Android**: `Lunaris-v2.0.10.apk`
|
||||||
- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz`
|
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
|
||||||
- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip`
|
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.9';
|
const appDisplayVersion = '2.0.15';
|
||||||
const appBuildNumber = 31;
|
const appBuildNumber = 37;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,74 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.15',
|
||||||
|
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||||
|
bullets: [
|
||||||
|
'Fixed vertical text wrapping on mobile: separated title and full-width SegmentedButtons for Theme, Language, and Media Quality so text never collapses.',
|
||||||
|
'Pinned Category Quick Navigation: bar now stays at the top of the screen and reliably scrolls to all 7 sections.',
|
||||||
|
'SingleChildScrollView Migration: ensures all section keys remain mounted and scrollable on demand.',
|
||||||
|
'Full-width Folder Structure Tile: added clean monospace path card with dedicated change button.',
|
||||||
|
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.14',
|
||||||
|
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
|
||||||
|
bullets: [
|
||||||
|
'Settings 2.0 Layout: reimagined settings experience with modern squircle card groups, smooth borders, and curated accent badges.',
|
||||||
|
'Category Quick Navigation: horizontal capsule strip at the top to smoothly jump between General, Appearance, Feed, Filters, Storage, Diagnostics, and About.',
|
||||||
|
'Instant Segmented Controls: replaced tedious dropdowns with fast 1-tap SegmentedButtons for Theme (Dark/Light/Auto), Language (RU/EN), and Media Quality (Auto/Data saver/HQ).',
|
||||||
|
'Folder Structure Bottom Sheet: intuitive download directory template picker with preset chips and custom pattern editor.',
|
||||||
|
'Interactive Stepper & Limit Controls: modernized desktop & mobile column steppers and disk cache limits with accent pill badges.',
|
||||||
|
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and Gitea repository link.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.13',
|
||||||
|
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||||
|
bullets: [
|
||||||
|
'Toolbar Section Dividers: added subtle vertical dividers between Actions (Reset/Refresh), Rating, Providers, and Top Periods to prevent visual clutter.',
|
||||||
|
'Post Card Badge Deconfliction: moved Rating badge (Explicit/Questionable/Safe) to the bottom-left beneath the Seen/Downloaded indicators.',
|
||||||
|
'Separated Provider & Media Badges: booru source (e.g. Gelbooru) remains top-left and Video/GIF badge remains top-right, preventing them from overlapping on narrow screens.',
|
||||||
|
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.12',
|
||||||
|
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
|
||||||
|
bullets: [
|
||||||
|
'Feed 2.0 Design: modern squircle post cards with 14dp border radius, subtle micro-borders, and depth shadows.',
|
||||||
|
'Unified Media Badges: sleek glassmorphism capsules for booru providers, ratings, video/GIF indicators, and status badges.',
|
||||||
|
'Cinematic Bottom Gradient: gentle shading on post cards for clean presentation without obscuring art.',
|
||||||
|
'Tactile Floating Favorite Button: circular glassmorphism heart button with neon glow and instant feedback.',
|
||||||
|
'Compact 2-Tier Feed Toolbar: 22dp search capsule, filter count badge, and single unified horizontal scroll for sources and time periods.',
|
||||||
|
'Quick Filter & Actions: direct access to random post, selection mode, rating picker, and active filter resets.',
|
||||||
|
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.11',
|
||||||
|
title: 'Mobile Video Player Redesign & Format Recognition Fix',
|
||||||
|
bullets: [
|
||||||
|
'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.',
|
||||||
|
'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.',
|
||||||
|
'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.',
|
||||||
|
'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.',
|
||||||
|
'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.',
|
||||||
|
'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.',
|
||||||
|
'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.',
|
||||||
|
'Sleek Neon Seekbar with live network buffer progress indicator.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.10',
|
||||||
|
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
|
||||||
|
bullets: [
|
||||||
|
'Fixed "and" operator disappearing when submitting search or applying autocompletions.',
|
||||||
|
'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").',
|
||||||
|
'Supported multiple "and" separators without accidental deduplication.',
|
||||||
|
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.9',
|
version: '2.0.9',
|
||||||
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
|
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> parseTags(String query) {
|
List<String> parseTags(String query) {
|
||||||
return query
|
final seen = <String>{};
|
||||||
.trim()
|
final result = <String>[];
|
||||||
.split(RegExp(r'\s+'))
|
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||||
.map(sanitizeToken)
|
final tag = raw.trim();
|
||||||
.where((tag) => tag.isNotEmpty && tag != 'and')
|
if (tag.isEmpty) continue;
|
||||||
.toSet()
|
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||||
.toList();
|
result.add(tag);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> saveSearch(
|
Future<Result<void>> saveSearch(
|
||||||
|
|||||||
@@ -802,15 +802,22 @@ class _SearchPresetsBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
|
avatar: Icon(
|
||||||
|
active ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||||
|
size: 15,
|
||||||
|
color: active ? scheme.primary : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
label: Text(name),
|
label: Text(name),
|
||||||
selected: active,
|
selected: active,
|
||||||
onSelected: (_) => onApply(raw),
|
onSelected: (_) => onApply(raw),
|
||||||
selectedColor: scheme.primaryContainer,
|
selectedColor: scheme.primaryContainer.withValues(alpha: 0.8),
|
||||||
checkmarkColor: scheme.onPrimaryContainer,
|
checkmarkColor: scheme.onPrimaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||||
fontWeight:
|
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||||
active ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktop(BuildContext context) {
|
Widget _buildDesktop(BuildContext context) {
|
||||||
|
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -100,34 +102,42 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||||
onPressed: widget.onToggleSelectionMode,
|
onPressed: widget.onToggleSelectionMode,
|
||||||
icon: Icon(widget.selectionMode
|
icon: Icon(widget.selectionMode
|
||||||
? Icons.check_box_rounded
|
? Icons.check_box_rounded
|
||||||
: Icons.check_box_outline_blank_rounded),
|
: Icons.check_box_outline_blank_rounded),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
const SizedBox(width: 4),
|
||||||
tooltip: 'Providers',
|
Badge(
|
||||||
onPressed: widget.onProviderFilter,
|
isLabelVisible: hasActiveFilters,
|
||||||
icon: const Icon(Icons.hub_rounded),
|
child: IconButton.filledTonal(
|
||||||
|
tooltip: 'Провайдеры',
|
||||||
|
onPressed: widget.onProviderFilter,
|
||||||
|
icon: const Icon(Icons.hub_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Rating',
|
tooltip: 'Рейтинг',
|
||||||
onPressed: widget.onRatingFilter,
|
onPressed: widget.onRatingFilter,
|
||||||
icon: const Icon(Icons.tune_rounded),
|
icon: const Icon(Icons.tune_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear filters',
|
tooltip: 'Очистить фильтры',
|
||||||
onPressed: widget.onClearFilters,
|
onPressed: widget.onClearFilters,
|
||||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Обновить',
|
||||||
onPressed: widget.onRefresh,
|
onPressed: widget.onRefresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Random post',
|
tooltip: 'Случайный пост',
|
||||||
onPressed: widget.onRandom,
|
onPressed: widget.onRandom,
|
||||||
icon: const Icon(Icons.casino_rounded),
|
icon: const Icon(Icons.casino_rounded),
|
||||||
),
|
),
|
||||||
@@ -154,7 +164,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
if (widget.providers.isNotEmpty) ...[
|
if (widget.providers.isNotEmpty) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected: widget.selectedProviderIds.isEmpty,
|
selected: widget.selectedProviderIds.isEmpty,
|
||||||
label: const Text('All providers'),
|
label: const Text('Все источники'),
|
||||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -162,14 +172,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
selected:
|
selected:
|
||||||
widget.selectedProviderIds.contains(provider.id),
|
widget.selectedProviderIds.contains(provider.id),
|
||||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
avatar: const Icon(Icons.hub_rounded, size: 15),
|
||||||
label: Text(provider.name),
|
label: Text(provider.name),
|
||||||
onSelected: (_) =>
|
onSelected: (_) =>
|
||||||
widget.onQuickProviderToggle(provider.id),
|
widget.onQuickProviderToggle(provider.id),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
for (final period in TopPeriodFilter.values) ...[
|
for (final period in TopPeriodFilter.values) ...[
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
@@ -188,25 +200,62 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobile(BuildContext context) {
|
Widget _buildMobile(BuildContext context) {
|
||||||
|
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TagInputSearchBar(
|
Row(
|
||||||
initialValue: _query,
|
children: [
|
||||||
suggestions: widget.tagSuggestions,
|
Expanded(
|
||||||
onChanged: (value) {
|
child: TagInputSearchBar(
|
||||||
_query = value;
|
initialValue: _query,
|
||||||
widget.onSearchChanged(value);
|
suggestions: widget.tagSuggestions,
|
||||||
},
|
onChanged: (value) {
|
||||||
onSubmitted: widget.onSearch,
|
_query = value;
|
||||||
onSuggestionApplied: (value) {
|
widget.onSearchChanged(value);
|
||||||
_query = value;
|
},
|
||||||
widget.onSuggestionTap(value);
|
onSubmitted: widget.onSearch,
|
||||||
},
|
onSuggestionApplied: (value) {
|
||||||
|
_query = value;
|
||||||
|
widget.onSuggestionTap(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Badge(
|
||||||
|
isLabelVisible: hasActiveFilters,
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Фильтры',
|
||||||
|
onPressed: widget.onProviderFilter,
|
||||||
|
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Случайный пост',
|
||||||
|
onPressed: widget.onRandom,
|
||||||
|
icon: const Icon(Icons.casino_rounded, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||||
|
onPressed: widget.onToggleSelectionMode,
|
||||||
|
icon: Icon(
|
||||||
|
widget.selectionMode
|
||||||
|
? Icons.check_box_rounded
|
||||||
|
: Icons.check_box_outline_blank_rounded,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (widget.providerStatusMessage != null) ...[
|
if (widget.providerStatusMessage != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: InputChip(
|
child: InputChip(
|
||||||
@@ -214,55 +263,54 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
label: Text(widget.providerStatusMessage!),
|
label: Text(widget.providerStatusMessage!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 7),
|
||||||
_HorizontalWheelScroller(
|
_HorizontalWheelScroller(
|
||||||
height: 40,
|
height: 38,
|
||||||
builder: (controller) => ListView(
|
builder: (controller) => ListView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
primary: false,
|
primary: false,
|
||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
|
||||||
tooltip:
|
ActionChip(
|
||||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: widget.onToggleSelectionMode,
|
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
|
||||||
icon: Icon(widget.selectionMode
|
label: const Text('Сбросить'),
|
||||||
? Icons.check_box_rounded
|
onPressed: widget.onClearFilters,
|
||||||
: Icons.check_box_outline_blank_rounded),
|
),
|
||||||
),
|
const SizedBox(width: 6),
|
||||||
const SizedBox(width: 6),
|
],
|
||||||
IconButton.filledTonal(
|
ActionChip(
|
||||||
tooltip: 'Rating',
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: widget.onRatingFilter,
|
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||||
icon: const Icon(Icons.tune_rounded),
|
label: const Text('Обновить'),
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Clear filters',
|
|
||||||
onPressed: widget.onClearFilters,
|
|
||||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Refresh',
|
|
||||||
onPressed: widget.onRefresh,
|
onPressed: widget.onRefresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
const SizedBox(width: 6),
|
||||||
tooltip: 'Random post',
|
const _ToolbarDivider(),
|
||||||
onPressed: widget.onRandom,
|
const SizedBox(width: 6),
|
||||||
icon: const Icon(Icons.casino_rounded),
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
selected: widget.rating != null,
|
||||||
|
avatar: const Icon(Icons.shield_outlined, size: 15),
|
||||||
|
label: Text(widget.rating ?? 'Рейтинг'),
|
||||||
|
onSelected: (_) => widget.onRatingFilter(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 6),
|
||||||
if (widget.providers.isNotEmpty) ...[
|
if (widget.providers.isNotEmpty) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected: widget.selectedProviderIds.isEmpty,
|
selected: widget.selectedProviderIds.isEmpty,
|
||||||
label: const Text('All'),
|
label: const Text('Все'),
|
||||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
for (final provider in widget.providers) ...[
|
for (final provider in widget.providers) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected:
|
selected:
|
||||||
widget.selectedProviderIds.contains(provider.id),
|
widget.selectedProviderIds.contains(provider.id),
|
||||||
label: Text(provider.name),
|
label: Text(provider.name),
|
||||||
@@ -271,20 +319,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_HorizontalWheelScroller(
|
|
||||||
height: 36,
|
|
||||||
builder: (controller) => ListView(
|
|
||||||
controller: controller,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
primary: false,
|
|
||||||
children: [
|
|
||||||
for (final period in TopPeriodFilter.values) ...[
|
for (final period in TopPeriodFilter.values) ...[
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected: widget.topPeriodFilter == period,
|
selected: widget.topPeriodFilter == period,
|
||||||
label: Text(period.label),
|
label: Text(period.label),
|
||||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||||
@@ -300,6 +341,20 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ToolbarDivider extends StatelessWidget {
|
||||||
|
const _ToolbarDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HorizontalWheelScroller extends StatefulWidget {
|
class _HorizontalWheelScroller extends StatefulWidget {
|
||||||
const _HorizontalWheelScroller({
|
const _HorizontalWheelScroller({
|
||||||
required this.builder,
|
required this.builder,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -121,15 +121,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _focusNode.hasFocus
|
color: _focusNode.hasFocus
|
||||||
? scheme.primary.withValues(alpha: 0.45)
|
? scheme.primary.withValues(alpha: 0.6)
|
||||||
: Colors.transparent,
|
: Colors.white.withValues(alpha: 0.08),
|
||||||
|
width: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(22),
|
||||||
onTap: _focusNode.requestFocus,
|
onTap: _focusNode.requestFocus,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
@@ -242,18 +243,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
Widget _buildTagChip(BuildContext context, String tag) {
|
Widget _buildTagChip(BuildContext context, String tag) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isAnd = tag.toLowerCase() == 'and';
|
||||||
return InputChip(
|
return InputChip(
|
||||||
label: Text(tag),
|
label: Text(
|
||||||
avatar: const Icon(Icons.tag_rounded, size: 16),
|
isAnd ? 'AND' : tag,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isAnd
|
||||||
|
? scheme.onSecondaryContainer
|
||||||
|
: scheme.onPrimaryContainer,
|
||||||
|
fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700,
|
||||||
|
letterSpacing: isAnd ? 0.8 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
avatar: Icon(
|
||||||
|
isAnd ? Icons.alt_route_rounded : Icons.tag_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: isAnd ? scheme.secondary : scheme.primary,
|
||||||
|
),
|
||||||
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
||||||
onPressed: () => _editTag(tag),
|
onPressed: () => _editTag(tag),
|
||||||
onDeleted: () => _removeTag(tag),
|
onDeleted: () => _removeTag(tag),
|
||||||
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54),
|
backgroundColor: isAnd
|
||||||
labelStyle: TextStyle(
|
? scheme.secondaryContainer.withValues(alpha: 0.7)
|
||||||
color: scheme.onPrimaryContainer,
|
: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||||
fontWeight: FontWeight.w700,
|
side: BorderSide(
|
||||||
|
color: isAnd
|
||||||
|
? scheme.secondary.withValues(alpha: 0.4)
|
||||||
|
: scheme.primary.withValues(alpha: 0.18),
|
||||||
),
|
),
|
||||||
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
|
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
);
|
);
|
||||||
@@ -306,7 +323,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
for (final tag in additions) {
|
for (final tag in additions) {
|
||||||
if (!_tags.contains(tag)) _tags.add(tag);
|
if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
|
||||||
|
_tags.add(tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
@@ -359,10 +378,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (draft.isNotEmpty) {
|
if (draft.isNotEmpty) {
|
||||||
final draftTags = _parseTags(draft);
|
final tokens = _parseTags(draft);
|
||||||
_tags.removeWhere(draftTags.contains);
|
if (tokens.isNotEmpty) {
|
||||||
|
final prefixTokens = tokens.sublist(0, tokens.length - 1);
|
||||||
|
for (final token in prefixTokens) {
|
||||||
|
if (token.toLowerCase() == 'and' || !_tags.contains(token)) {
|
||||||
|
_tags.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_tags.contains(suggestion)) {
|
||||||
|
_tags.add(suggestion);
|
||||||
}
|
}
|
||||||
if (!_tags.contains(suggestion)) _tags.add(suggestion);
|
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
@@ -387,7 +415,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
final result = <String>[];
|
final result = <String>[];
|
||||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||||
final tag = raw.trim();
|
final tag = raw.trim();
|
||||||
if (tag.isEmpty || !seen.add(tag)) continue;
|
if (tag.isEmpty) continue;
|
||||||
|
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||||
result.add(tag);
|
result.add(tag);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+251
-153
@@ -177,114 +177,161 @@ class _PostCardState extends ConsumerState<PostCard>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: ClipRRect(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
child: Stack(
|
borderRadius: BorderRadius.circular(14),
|
||||||
children: [
|
border: Border.all(
|
||||||
AspectRatio(
|
color: Colors.white.withValues(alpha: 0.09),
|
||||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
width: 0.8,
|
||||||
child: BlurContent(
|
),
|
||||||
enabled: widget.blurExplicit && sensitive,
|
boxShadow: [
|
||||||
child: LayoutBuilder(
|
BoxShadow(
|
||||||
builder: (context, constraints) {
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
blurRadius: 8,
|
||||||
final cacheWidth =
|
offset: const Offset(0, 2),
|
||||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
),
|
||||||
final headers = _headersFor(post);
|
],
|
||||||
final videoUrls = MediaUrlSelector.video(post);
|
),
|
||||||
if (!mobile &&
|
child: ClipRRect(
|
||||||
_hovered &&
|
borderRadius: BorderRadius.circular(13.2),
|
||||||
_resolvedPost != null &&
|
child: Stack(
|
||||||
MediaUrlSelector.isVideo(post) &&
|
children: [
|
||||||
videoUrls.isNotEmpty) {
|
AspectRatio(
|
||||||
return _FeedVideoPreview(
|
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||||
videoUrl: videoUrls.first,
|
child: BlurContent(
|
||||||
headers: headers,
|
enabled: widget.blurExplicit && sensitive,
|
||||||
fallbackImageUrl: imageUrl,
|
child: LayoutBuilder(
|
||||||
cacheWidth: cacheWidth,
|
builder: (context, constraints) {
|
||||||
);
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
}
|
final cacheWidth =
|
||||||
return CachedNetworkImage(
|
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||||
imageUrl: imageUrl,
|
final headers = _headersFor(post);
|
||||||
httpHeaders: headers,
|
final videoUrls = MediaUrlSelector.video(post);
|
||||||
memCacheWidth: cacheWidth,
|
if (!mobile &&
|
||||||
maxWidthDiskCache: cacheWidth,
|
_hovered &&
|
||||||
fit: BoxFit.cover,
|
_resolvedPost != null &&
|
||||||
placeholder: (context, url) => const LoadingSkeleton(),
|
MediaUrlSelector.isVideo(post) &&
|
||||||
errorWidget: (context, url, error) => ColoredBox(
|
videoUrls.isNotEmpty) {
|
||||||
color: Theme.of(context)
|
return _FeedVideoPreview(
|
||||||
.colorScheme
|
videoUrl: videoUrls.first,
|
||||||
.surfaceContainerHighest,
|
headers: headers,
|
||||||
child: const Center(
|
fallbackImageUrl: imageUrl,
|
||||||
child: Icon(Icons.broken_image_rounded),
|
cacheWidth: cacheWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
httpHeaders: headers,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
maxWidthDiskCache: cacheWidth,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => const LoadingSkeleton(),
|
||||||
|
errorWidget: (context, url, error) => ColoredBox(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.broken_image_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Bottom cinematic gradient overlay for badges and favorite
|
||||||
if (widget.showBadges) ...[
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 0,
|
||||||
top: 8,
|
right: 0,
|
||||||
child: _ProviderBadge(name: post.providerName),
|
bottom: 0,
|
||||||
|
height: 64,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withValues(alpha: 0.72),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (widget.showBadges) ...[
|
||||||
right: 8,
|
Positioned(
|
||||||
top: 8,
|
left: 8,
|
||||||
child: RatingBadge(rating: post.rating),
|
top: 8,
|
||||||
),
|
child: _ProviderBadge(name: post.providerName),
|
||||||
Positioned(
|
),
|
||||||
left: 8,
|
if (post.fileType.toLowerCase().contains('video') ||
|
||||||
bottom: 8,
|
post.fileType.toLowerCase().contains('webm') ||
|
||||||
child: _MediaBadge(fileType: post.fileType),
|
post.fileType.toLowerCase().contains('mp4') ||
|
||||||
),
|
post.fileType.toLowerCase().contains('gif'))
|
||||||
if (widget.isViewed)
|
Positioned(
|
||||||
const Positioned(
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
child: _MediaBadge(fileType: post.fileType),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.isViewed || widget.isDownloaded) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.isViewed) const _SeenBadge(),
|
||||||
|
if (widget.isViewed && widget.isDownloaded)
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
if (widget.isDownloaded)
|
||||||
|
const _DownloadedBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
RatingBadge(rating: post.rating),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.selected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.24),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(13),
|
||||||
|
),
|
||||||
|
child: const Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Icon(Icons.check_circle_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mobile)
|
||||||
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
child: _SeenBadge(),
|
child: _FavoriteButton(
|
||||||
),
|
isFavorite: widget.isFavorite,
|
||||||
if (widget.isDownloaded)
|
onPressed: widget.onFavorite,
|
||||||
Positioned(
|
|
||||||
right: widget.isViewed ? 72 : 8,
|
|
||||||
bottom: 8,
|
|
||||||
child: const _DownloadedBadge(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (widget.selected)
|
|
||||||
Positioned.fill(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary
|
|
||||||
.withValues(alpha: 0.24),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
width: 3,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Align(
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(8),
|
|
||||||
child: Icon(Icons.check_circle_rounded),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (mobile)
|
|
||||||
Positioned(
|
|
||||||
right: 8,
|
|
||||||
bottom: 8,
|
|
||||||
child: _FavoriteButton(
|
|
||||||
isFavorite: widget.isFavorite,
|
|
||||||
onPressed: widget.onFavorite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Double-tap heart animation overlay
|
// Double-tap heart animation overlay
|
||||||
if (_showHeart)
|
if (_showHeart)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -369,7 +416,8 @@ class _PostCardState extends ConsumerState<PostCard>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -635,13 +683,37 @@ class _FavoriteButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton.filledTonal(
|
return InkResponse(
|
||||||
visualDensity: VisualDensity.compact,
|
onTap: onPressed,
|
||||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
radius: 20,
|
||||||
onPressed: onPressed,
|
child: AnimatedContainer(
|
||||||
icon: Icon(
|
duration: AppMotion.duration(context, 150),
|
||||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
width: 34,
|
||||||
size: 18,
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.58),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isFavorite
|
||||||
|
? Colors.redAccent.withValues(alpha: 0.6)
|
||||||
|
: Colors.white.withValues(alpha: 0.16),
|
||||||
|
width: 0.9,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
if (isFavorite)
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.redAccent.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
|
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,16 +726,23 @@ class _ProviderBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
name,
|
||||||
child: Text(
|
style: const TextStyle(
|
||||||
name,
|
fontSize: 10.5,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -678,25 +757,32 @@ class _MediaBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final type = _type(fileType);
|
final type = _type(fileType);
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
border: Border.all(
|
||||||
child: Padding(
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
width: 0.7,
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(_icon(type), size: 12, color: Colors.white),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
type,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(_icon(type), size: 13, color: Colors.white),
|
||||||
|
const SizedBox(width: 3.5),
|
||||||
|
Text(
|
||||||
|
type,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,25 +809,31 @@ class _SeenBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
border: Border.all(
|
||||||
child: const Padding(
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
width: 0.7,
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'seen',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
|
||||||
|
SizedBox(width: 3.5),
|
||||||
|
Text(
|
||||||
|
'seen',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,14 +843,20 @@ class _DownloadedBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: const Icon(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
Icons.download_done_rounded,
|
||||||
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
size: 13,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index >= posts.length) return const LoadingSkeleton();
|
if (index >= posts.length) return const LoadingSkeleton();
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: SizedBox.expand(
|
child: SizedBox.expand(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
|||||||
@@ -14,16 +14,23 @@ class RatingBadge extends StatelessWidget {
|
|||||||
'explicit' || 'e' => Colors.red,
|
'explicit' || 'e' => Colors.red,
|
||||||
_ => Colors.blueGrey,
|
_ => Colors.blueGrey,
|
||||||
};
|
};
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6.5, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.85),
|
color: color.withValues(alpha: 0.88),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.18),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
rating.isEmpty ? 'unknown' : rating,
|
||||||
child: Text(
|
style: const TextStyle(
|
||||||
rating.isEmpty ? 'unknown' : rating,
|
fontSize: 10.5,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.9+31
|
version: 2.0.15+37
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ void main() {
|
|||||||
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses query preserving and operators and grouping', () {
|
||||||
|
expect(
|
||||||
|
service.parseTags('cat and dog and fox'),
|
||||||
|
['cat', 'and', 'dog', 'and', 'fox'],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
service.parseTags('(cat and dog) anthro'),
|
||||||
|
['(cat', 'and', 'dog)', 'anthro'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('recent searches are ordered by searchedAt', () async {
|
test('recent searches are ordered by searchedAt', () async {
|
||||||
await service.saveSearch('old', 1);
|
await service.saveSearch('old', 1);
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/backend.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('SettingsScreen renders category quick nav and sections without overflow', (tester) async {
|
||||||
|
// Test on typical mobile viewport: 390x844
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final settings = AppSettings.defaults.copyWith(
|
||||||
|
languageCode: 'ru',
|
||||||
|
themeMode: 'dark',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
settingsControllerProvider.overrideWith(
|
||||||
|
() => _FakeSettingsController(settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: SettingsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify pinned Category Quick Nav renders
|
||||||
|
expect(find.text('Основное'), findsWidgets);
|
||||||
|
expect(find.text('Внешний вид'), findsWidgets);
|
||||||
|
|
||||||
|
// Verify Theme mode segment buttons
|
||||||
|
expect(find.text('Темная'), findsOneWidget);
|
||||||
|
expect(find.text('Светлая'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap a quick nav chip to test scrolling
|
||||||
|
await tester.tap(find.text('Внешний вид').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify language segment is visible and tappable
|
||||||
|
expect(find.text('🇷🇺 Русский'), findsOneWidget);
|
||||||
|
expect(find.text('🇬🇧 English'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap English to test selection
|
||||||
|
await tester.tap(find.text('🇬🇧 English'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSettingsController extends SettingsController {
|
||||||
|
_FakeSettingsController(this._initial);
|
||||||
|
|
||||||
|
final AppSettings _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppSettings> build() async => _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSettings(AppSettings settings) async {
|
||||||
|
state = AsyncData(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,6 +288,57 @@ void main() {
|
|||||||
expect(size.height, lessThan(96));
|
expect(size.height, lessThan(96));
|
||||||
expect(find.text('touhou'), findsOneWidget);
|
expect(find.text('touhou'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('preserves AND operator chips when submitted', (tester) async {
|
||||||
|
String submitted = '';
|
||||||
|
await tester.pumpWidget(_Harness(
|
||||||
|
child: TagInputSearchBar(
|
||||||
|
initialValue: 'cat and dog',
|
||||||
|
onSubmitted: (query) => submitted = query,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('cat'), findsOneWidget);
|
||||||
|
expect(find.text('AND'), findsOneWidget);
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(submitted, 'cat and dog');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('applying suggestion preserves preceding tokens and AND operator',
|
||||||
|
(tester) async {
|
||||||
|
String applied = '';
|
||||||
|
await tester.pumpWidget(_Harness(
|
||||||
|
child: TagInputSearchBar(
|
||||||
|
suggestions: const [
|
||||||
|
TagSuggestion(
|
||||||
|
name: 'dog',
|
||||||
|
category: TagCategory.general,
|
||||||
|
postCount: 50,
|
||||||
|
providerId: 'test',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSuggestionApplied: (query) => applied = query,
|
||||||
|
onSubmitted: (_) {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'cat and d');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('dog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(applied, 'cat and dog');
|
||||||
|
expect(find.text('cat'), findsOneWidget);
|
||||||
|
expect(find.text('AND'), findsOneWidget);
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Harness extends StatelessWidget {
|
class _Harness extends StatelessWidget {
|
||||||
|
|||||||
Reference in New Issue
Block a user