diff --git a/README.md b/README.md index e99bf8c..7f6bdde 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.0.5 + Version 3.0.6 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 5854b66..4ff7ae6 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.0.5'; -const appBuildNumber = 56; +const appDisplayVersion = '3.0.6'; +const appBuildNumber = 57; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 2c8ac75..d47c6b4 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.0.6', + title: 'Улучшение недавних запросов и отображения тегов', + bullets: [ + 'Полная видимость тегов и запросов: сняты тесные ограничения ширины (140px), теги и поисковые запросы теперь отображаются целиком без усечения.', + 'Устранение дубликатов в истории: автоматическая дедупликация поисковых запросов в базе данных и интерфейсе. Устранены повторяющиеся пары запросов с пустыми счетчиками.', + 'Исправление заголовка карточки поиска: заголовок «Недавние запросы» и счетчик больше не обрезаются на экранах смартфонов.', + 'Удобное удаление: увеличенная область нажатия для кнопки удаления (крестика) предотвращает случайные нажатия на сам запрос.', + ], + ), LunarisChange( version: '3.0.5', title: 'Интерактивные ссылки на соцсети и авторов в объявлениях и описаниях', diff --git a/lib/backend/repositories/search_repository.dart b/lib/backend/repositories/search_repository.dart index b5e06ac..374a9ed 100644 --- a/lib/backend/repositories/search_repository.dart +++ b/lib/backend/repositories/search_repository.dart @@ -12,6 +12,15 @@ class SearchRepository { Future> save(SearchHistory history, {int maxItems = 500}) { return _databaseService.safeWrite((isar) async { + final norm = history.query.trim().toLowerCase(); + // Remove any existing duplicate records with same query + final all = await isar.searchHistoryEntitys.where().findAll(); + for (final item in all) { + if (item.query.trim().toLowerCase() == norm) { + await isar.searchHistoryEntitys.delete(item.isarId); + } + } + await isar.searchHistoryEntitys.put( SearchHistoryEntity() ..historyId = history.id @@ -42,13 +51,40 @@ class SearchRepository { return _databaseService.safeRead((isar) async { final items = await isar.searchHistoryEntitys.where().findAll(); items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); - final limited = limit == null ? items : items.take(limit); + + final seen = {}; + final deduplicated = []; + for (final item in items) { + final key = item.query.trim().toLowerCase(); + if (key.isEmpty) continue; + if (seen.add(key)) { + deduplicated.add(item); + } + } + + final limited = limit == null ? deduplicated : deduplicated.take(limit); return limited.map((entity) => entity.toModel()).toList(); }); } Future> delete(String historyId) { return _databaseService.safeWrite((isar) async { + final target = await isar.searchHistoryEntitys + .where() + .filter() + .historyIdEqualTo(historyId) + .findFirst(); + if (target != null) { + final norm = target.query.trim().toLowerCase(); + final all = await isar.searchHistoryEntitys.where().findAll(); + for (final item in all) { + if (item.query.trim().toLowerCase() == norm || + item.historyId == historyId) { + await isar.searchHistoryEntitys.delete(item.isarId); + } + } + return true; + } return await isar.searchHistoryEntitys.deleteByHistoryId(historyId); }); } diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index 6f81b8e..301669a 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -123,14 +123,18 @@ class FeedController extends AsyncNotifier { final settingsRes = await ref.read(settingsServiceProvider).getSettings(); final settings = settingsRes is Success ? settingsRes.data : AppSettings.defaults; - await ref - .read(searchServiceProvider) - .saveSearch(query, 0, maxHistory: settings.searchHistoryLimit); - await refresh(); - final count = state.value?.posts.length ?? 0; - await ref - .read(searchServiceProvider) - .saveSearch(query, count, maxHistory: settings.searchHistoryLimit); + try { + await refresh(); + final count = state.value?.posts.length ?? 0; + await ref + .read(searchServiceProvider) + .saveSearch(query, count, maxHistory: settings.searchHistoryLimit); + } catch (_) { + await ref + .read(searchServiceProvider) + .saveSearch(query, 0, maxHistory: settings.searchHistoryLimit); + rethrow; + } } Future updateTagSuggestions(String query) async { diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index a7f391d..9021df4 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -175,10 +175,21 @@ class _SearchScreenState extends ConsumerState { icon: Icons.history_rounded, accentColor: const Color(0xFF6366F1), title: isRu ? 'Недавние запросы' : 'Recent searches', - badgeCount: data.recent.length, + badgeCount: data.recent + .map((e) => e.query.trim().toLowerCase()) + .where((q) => q.isNotEmpty) + .toSet() + .length, trailing: data.recent.isEmpty ? null : TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), onPressed: () => ref .read(searchControllerProvider.notifier) .clearHistory(), @@ -390,9 +401,10 @@ class _SearchSectionCard extends StatelessWidget { ), child: Icon(icon, size: 18, color: accentColor), ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Row( + mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( @@ -401,6 +413,7 @@ class _SearchSectionCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, + fontSize: 15, ), ), ), @@ -408,7 +421,7 @@ class _SearchSectionCard extends StatelessWidget { const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), + horizontal: 7, vertical: 2), decoration: BoxDecoration( color: accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), diff --git a/lib/features/search/presentation/widgets/recent_searches.dart b/lib/features/search/presentation/widgets/recent_searches.dart index 01707c7..91f6302 100644 --- a/lib/features/search/presentation/widgets/recent_searches.dart +++ b/lib/features/search/presentation/widgets/recent_searches.dart @@ -47,8 +47,44 @@ class _RecentSearchesState extends State { ); } + // Deduplicate items by normalized query + final seen = {}; + final uniqueItems = []; + for (final item in widget.items) { + final key = item.query.trim().toLowerCase(); + if (key.isEmpty) continue; + if (seen.add(key)) { + uniqueItems.add(item); + } + } + + if (uniqueItems.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(20).toList(); + _expanded ? uniqueItems : uniqueItems.take(20).toList(); + final screenW = MediaQuery.sizeOf(context).width; + final maxQueryWidth = (screenW - 200).clamp(180.0, 480.0); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -66,7 +102,7 @@ class _RecentSearchesState extends State { onTap: () => widget.onTap(item.query), child: Container( padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: Border.all( @@ -79,18 +115,22 @@ class _RecentSearchesState extends State { children: [ Icon( Icons.history_rounded, - size: 14, + size: 15, 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, + constraints: BoxConstraints(maxWidth: maxQueryWidth), + child: Tooltip( + message: item.query, + waitDuration: const Duration(milliseconds: 600), + child: Text( + item.query, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), ), @@ -100,14 +140,14 @@ class _RecentSearchesState extends State { padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, + color: theme.colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Text( '${item.resultCount}', style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - fontSize: 10, + color: theme.colorScheme.primary, + fontSize: 11, fontWeight: FontWeight.bold, ), ), @@ -115,14 +155,21 @@ class _RecentSearchesState extends State { ], 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, + Material( + color: Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => widget.onDelete!(item.id), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.close_rounded, + size: 15, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.75), + ), ), ), ), @@ -134,7 +181,7 @@ class _RecentSearchesState extends State { ), ], ), - if (widget.items.length > 20) ...[ + if (uniqueItems.length > 20) ...[ const SizedBox(height: 10), Center( child: TextButton.icon( @@ -148,7 +195,7 @@ class _RecentSearchesState extends State { label: Text( _expanded ? 'Свернуть' - : 'Показать все (${widget.items.length})', + : 'Показать все (${uniqueItems.length})', ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 9dd188f..b6e510c 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.0.5+56 +version: 3.0.6+57 environment: sdk: ">=3.3.0 <4.0.0"