diff --git a/README.md b/README.md
index 65f0734..62f3f4c 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 174819c..8a189b7 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '3.1.0';
-const appBuildNumber = 61;
+const appDisplayVersion = '3.1.1';
+const appBuildNumber = 62;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index d199127..de0d70d 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,16 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '3.1.1',
+ title: 'Деление тегов по стилю e621/Gelbooru/Rule34 и исправление поиска тегов со скобками',
+ bullets: [
+ 'Цветное деление тегов по категориям (e621, Rule34, Gelbooru): теги авторов (Artist) подсвечиваются кораллово-красным, персонажи (Character) — мятно-зелёным, серии/копирайты (Copyright) — пурпурным, виды (Species) — янтарно-оранжевым, мета (Meta) — ледяным голубым, а общие теги (General) — нейтральным стилем.',
+ 'Liquid Glass чипы тегов: компоненты TagChip получили полупрозрачный фон с BackdropFilter размытием, цветной светодиодный индикатор-точку со свечением и зеркальный контур.',
+ 'Индикаторы в заголовках категорий: заголовки блоков тегов дополнены цветными точками категорий со свечением и стильными капсулами счётчиков.',
+ 'Исправление поиска тегов со скобками: исправлена проблема, из-за которой теги с круглыми скобками (например, tyson_(password), touhou_(series)) очищались от скобок при поиске и не находили постов в ленте.',
+ 'Поддержка скобок в подсказках и локальном кэше: автодополнение теперь корректно выдает теги со скобками, фильтруя только внешние скобки группировки and-веток.',
+ 'Liquid Glass оформление блока тегов в детальном просмотре поста на мобильных и десктоп устройствах.',
+ ],
+ ),
LunarisChange(
version: '3.1.0',
title: 'iPhone Liquid Glass — Поиск, Избранное, История и исправление доступности',
diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart
index b2893a6..875f895 100644
--- a/lib/backend/providers/provider_manager.dart
+++ b/lib/backend/providers/provider_manager.dart
@@ -169,7 +169,16 @@ class ProviderManager {
for (final raw in rawTags) {
var token = raw.trim();
- token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
+ if (token.isEmpty) continue;
+
+ // Only strip parentheses if they are grouping delimiters (unbalanced in the token, e.g. '(cat' or 'dog)')
+ // but preserve tags that have internal paired parentheses like 'tyson_(password)'.
+ if (token.startsWith('(') && !token.contains(')')) {
+ token = token.substring(1).trim();
+ }
+ if (token.endsWith(')') && !token.contains('(')) {
+ token = token.substring(0, token.length - 1).trim();
+ }
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
diff --git a/lib/backend/services/search_service.dart b/lib/backend/services/search_service.dart
index f073b70..14b2793 100644
--- a/lib/backend/services/search_service.dart
+++ b/lib/backend/services/search_service.dart
@@ -21,10 +21,7 @@ class SearchService {
TagCacheService? get tagCacheService => _tagCacheService;
static String sanitizeToken(String raw) {
- return raw
- .replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
- .trim()
- .toLowerCase();
+ return raw.trim().toLowerCase();
}
List parseTags(String query) {
diff --git a/lib/backend/services/tag_cache_service.dart b/lib/backend/services/tag_cache_service.dart
index f419e7f..6022639 100644
--- a/lib/backend/services/tag_cache_service.dart
+++ b/lib/backend/services/tag_cache_service.dart
@@ -66,10 +66,11 @@ class TagCacheService {
int limit = 16,
Iterable? priorityTags,
}) {
- final cleanPrefix = prefix
- .replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
- .trim()
- .toLowerCase();
+ var cleanPrefix = prefix.trim().toLowerCase();
+ // If the prefix has an unbalanced opening parenthesis at the start (e.g. '(cat'), strip it
+ if (cleanPrefix.startsWith('(') && !cleanPrefix.contains(')')) {
+ cleanPrefix = cleanPrefix.substring(1).trim();
+ }
if (cleanPrefix.isEmpty) return const [];
final prioritySet = priorityTags
diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart
index a664b84..99da8fa 100644
--- a/lib/features/post/presentation/post_details_screen.dart
+++ b/lib/features/post/presentation/post_details_screen.dart
@@ -314,42 +314,49 @@ class PostDetailsScreen extends ConsumerWidget {
),
],
const SizedBox(height: 16),
- Container(
- decoration: BoxDecoration(
- color:
- Theme.of(context).colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(18),
- border: Border.all(
- color: Theme.of(context)
- .colorScheme
- .outlineVariant
- .withValues(alpha: 0.35),
- ),
- ),
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
+ ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.55)
+ : Colors.white.withValues(alpha: 0.85),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.12)
+ : Colors.white.withValues(alpha: 0.8),
+ width: 1.1,
+ ),
+ ),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Icon(
- Icons.tag_rounded,
- size: 20,
- color: Theme.of(context).colorScheme.primary,
- ),
- const SizedBox(width: 8),
- Text(
- '${strings.tags} (${post.cleanTags.length})',
- style: Theme.of(context)
- .textTheme
- .titleMedium
- ?.copyWith(fontWeight: FontWeight.bold),
+ Row(
+ children: [
+ Icon(
+ Icons.tag_rounded,
+ size: 20,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '${strings.tags} (${post.cleanTags.length})',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ ],
),
+ const SizedBox(height: 12),
+ PostTagsPanel(post: post),
],
),
- const SizedBox(height: 12),
- PostTagsPanel(post: post),
- ],
+ ),
),
),
const SizedBox(height: 16),
@@ -491,41 +498,48 @@ class PostDetailsScreen extends ConsumerWidget {
),
],
const SizedBox(height: 12),
- Container(
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(18),
- border: Border.all(
- color: Theme.of(context)
- .colorScheme
- .outlineVariant
- .withValues(alpha: 0.35),
- ),
- ),
- clipBehavior: Clip.antiAlias,
- child: ExpansionTile(
- tilePadding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
- childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
- initiallyExpanded: false,
- shape: const Border(),
- collapsedShape: const Border(),
- leading: Icon(
- Icons.tag_rounded,
- color: Theme.of(context).colorScheme.primary,
- ),
- title: Text(
- '${strings.tags} (${post.cleanTags.length})',
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- children: [
- Align(
- alignment: Alignment.centerLeft,
- child: PostTagsPanel(post: post),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.55)
+ : Colors.white.withValues(alpha: 0.85),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.12)
+ : Colors.white.withValues(alpha: 0.8),
+ width: 1.1,
+ ),
),
- ],
+ child: ExpansionTile(
+ tilePadding:
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
+ initiallyExpanded: false,
+ shape: const Border(),
+ collapsedShape: const Border(),
+ leading: Icon(
+ Icons.tag_rounded,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ title: Text(
+ '${strings.tags} (${post.cleanTags.length})',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: PostTagsPanel(post: post),
+ ),
+ ],
+ ),
+ ),
),
),
const SizedBox(height: 12),
diff --git a/lib/features/post/presentation/widgets/post_tags_panel.dart b/lib/features/post/presentation/widgets/post_tags_panel.dart
index eb9d656..65b74a5 100644
--- a/lib/features/post/presentation/widgets/post_tags_panel.dart
+++ b/lib/features/post/presentation/widgets/post_tags_panel.dart
@@ -320,13 +320,51 @@ class _TagGroupBlock extends StatelessWidget {
children: [
Row(
children: [
- Text(label, style: Theme.of(context).textTheme.labelLarge),
- const SizedBox(width: 8),
- Text(
- '${tags.length}',
- style: Theme.of(context).textTheme.labelMedium?.copyWith(
- color: Theme.of(context).colorScheme.onSurfaceVariant,
+ Container(
+ width: 8,
+ height: 8,
+ margin: const EdgeInsets.only(right: 8),
+ decoration: BoxDecoration(
+ color: _headerDotColor(group, Theme.of(context)),
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: _headerDotColor(group, Theme.of(context))
+ .withValues(alpha: 0.55),
+ blurRadius: 5,
+ spreadRadius: 1,
),
+ ],
+ ),
+ ),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.1,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1.5),
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.08)
+ : Colors.black.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.12)
+ : Colors.black.withValues(alpha: 0.08),
+ ),
+ ),
+ child: Text(
+ '${tags.length}',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
),
],
),
@@ -338,6 +376,7 @@ class _TagGroupBlock extends StatelessWidget {
for (final tag in visibleTags)
TagChip(
tag: tag,
+ group: group,
onTap: () => onTap(tag),
onLongPress: () => onLongPress(tag),
),
@@ -360,4 +399,17 @@ class _TagGroupBlock extends StatelessWidget {
),
);
}
+
+ static Color _headerDotColor(String group, ThemeData theme) {
+ return switch (group.toLowerCase()) {
+ 'artist' => const Color(0xFFFF4757),
+ 'character' => const Color(0xFF2ED573),
+ 'copyright' => const Color(0xFFA55EEA),
+ 'species' => const Color(0xFFFFA502),
+ 'meta' => const Color(0xFF1E90FF),
+ _ => theme.colorScheme.primary,
+ };
+ }
}
+
+
diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart
index 2f373aa..1625e00 100644
--- a/lib/shared/widgets/app_search_bar.dart
+++ b/lib/shared/widgets/app_search_bar.dart
@@ -567,7 +567,7 @@ class _TagInputSearchBarState extends State {
final draft = _controller.text.trim().toLowerCase();
if (draft.isEmpty) return '';
final raw = draft.split(RegExp(r'\s+')).last;
- return raw.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
+ return raw.trim();
}
bool get _isEditing => _focusNode.hasFocus || _localDirty;
diff --git a/lib/shared/widgets/tag_chip.dart b/lib/shared/widgets/tag_chip.dart
index fbb74ec..ab0a4c9 100644
--- a/lib/shared/widgets/tag_chip.dart
+++ b/lib/shared/widgets/tag_chip.dart
@@ -1,36 +1,142 @@
+import 'dart:ui';
import 'package:flutter/material.dart';
+import '../../backend/models/tag_suggestion.dart';
+
class TagChip extends StatelessWidget {
const TagChip({
required this.tag,
+ this.category,
+ this.group,
this.onTap,
this.onLongPress,
super.key,
});
final String tag;
+ final TagCategory? category;
+ final String? group;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
+ TagCategory get _resolvedCategory {
+ if (category != null) return category!;
+ final g = (group ?? '').toLowerCase();
+ return switch (g) {
+ 'artist' => TagCategory.artist,
+ 'character' => TagCategory.character,
+ 'copyright' => TagCategory.copyright,
+ 'species' => TagCategory.species,
+ 'meta' => TagCategory.meta,
+ 'general' => TagCategory.general,
+ _ => tagCategoryFromString(g),
+ };
+ }
+
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
- final chip = ActionChip(
- onPressed: onTap,
- label: Text(tag),
- labelStyle: TextStyle(
- color: scheme.onSecondaryContainer,
- fontWeight: FontWeight.w700,
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final cat = _resolvedCategory;
+
+ final (Color baseColor, Color textColor) = _colorsForCategory(cat, scheme, isDark);
+
+ final chipWidget = ClipRRect(
+ borderRadius: BorderRadius.circular(10),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
+ child: Container(
+ decoration: BoxDecoration(
+ color: baseColor.withValues(alpha: isDark ? 0.18 : 0.12),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: baseColor.withValues(alpha: isDark ? 0.45 : 0.32),
+ width: 1.0,
+ ),
+ ),
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(10),
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 6,
+ height: 6,
+ margin: const EdgeInsets.only(right: 6),
+ decoration: BoxDecoration(
+ color: baseColor,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: baseColor.withValues(alpha: 0.6),
+ blurRadius: 4,
+ spreadRadius: 1,
+ ),
+ ],
+ ),
+ ),
+ Text(
+ tag,
+ style: TextStyle(
+ color: textColor,
+ fontWeight: FontWeight.w700,
+ fontSize: 13,
+ letterSpacing: -0.1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
),
- backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.62),
- side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)),
- visualDensity: VisualDensity.compact,
);
- if (onLongPress == null) return chip;
+
+ if (onLongPress == null) return chipWidget;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: onLongPress,
- child: chip,
+ child: chipWidget,
);
}
+
+ static (Color, Color) _colorsForCategory(
+ TagCategory cat,
+ ColorScheme scheme,
+ bool isDark,
+ ) {
+ return switch (cat) {
+ TagCategory.artist => (
+ const Color(0xFFFF4757), // e621 / Gelbooru red/coral
+ isDark ? const Color(0xFFFF6B81) : const Color(0xFFD63031),
+ ),
+ TagCategory.character => (
+ const Color(0xFF2ED573), // Vibrant character green
+ isDark ? const Color(0xFF7BED9F) : const Color(0xFF10AC84),
+ ),
+ TagCategory.copyright => (
+ const Color(0xFFA55EEA), // Classic anime series purple
+ isDark ? const Color(0xFFD980FA) : const Color(0xFF8854D0),
+ ),
+ TagCategory.species => (
+ const Color(0xFFFFA502), // e621 species amber/orange
+ isDark ? const Color(0xFFFFC048) : const Color(0xFFE67E22),
+ ),
+ TagCategory.meta => (
+ const Color(0xFF1E90FF), // Meta / format cyan / ice blue
+ isDark ? const Color(0xFF70A1FF) : const Color(0xFF0984E3),
+ ),
+ TagCategory.general || TagCategory.unknown => (
+ isDark ? scheme.primary : scheme.primary,
+ isDark ? scheme.onSurface : scheme.onSurface,
+ ),
+ };
+ }
}
+
diff --git a/pubspec.yaml b/pubspec.yaml
index 23be272..acf9bfb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 3.1.0+61
+version: 3.1.1+62
environment:
sdk: ">=3.3.0 <4.0.0"