diff --git a/README.md b/README.md
index e99bf8c..7f6bdde 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 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"