diff --git a/README.md b/README.md index 65f0734..62f3f4c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.1.0 + Version 3.1.1 Flutter Platforms Wiki 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"