fix(ui): harden settings focus and semantics
This commit is contained in:
@@ -4,8 +4,7 @@ import 'package:flutter/material.dart';
|
||||
String initialOf(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
final first = trimmed.runes.first;
|
||||
return String.fromCharCode(first).toUpperCase();
|
||||
return trimmed.characters.first.toUpperCase();
|
||||
}
|
||||
|
||||
/// Deterministic colour for [name] from a curated palette. The palette is
|
||||
|
||||
@@ -1,85 +1,98 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:string_similarity/string_similarity.dart';
|
||||
import 'package:unorm_dart/unorm_dart.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
|
||||
const int defaultMediaSearchLimit = 100;
|
||||
|
||||
final RegExp _searchSeparatorPattern = RegExp(r'[^\p{L}\p{N}\p{M}]+', unicode: true);
|
||||
|
||||
List<MediaItem> rankMediaSearchResults(List<MediaItem> items, String query, {int? limit}) {
|
||||
final normalizedQuery = normalizeSearchText(query);
|
||||
if (normalizedQuery.isEmpty) {
|
||||
if (limit != null) {
|
||||
RangeError.checkNotNegative(limit, 'limit');
|
||||
if (limit == 0) return const [];
|
||||
}
|
||||
if (items.isEmpty) return const [];
|
||||
|
||||
final searchQuery = _NormalizedSearchQuery(query);
|
||||
if (searchQuery.text.isEmpty) {
|
||||
return limit == null ? List<MediaItem>.of(items) : items.take(limit).toList();
|
||||
}
|
||||
|
||||
final ranked = <_RankedMediaItem>[
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_RankedMediaItem(item: items[i], score: mediaSearchRelevanceScore(items[i], normalizedQuery), originalIndex: i),
|
||||
];
|
||||
if (limit == null || limit >= items.length) {
|
||||
final ranked = <_RankedMediaItem>[
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_RankedMediaItem(
|
||||
item: items[i],
|
||||
score: _mediaSearchRelevanceScoreNormalized(items[i], searchQuery),
|
||||
originalIndex: i,
|
||||
),
|
||||
]..sort(_compareRankedBestFirst);
|
||||
return [for (final entry in ranked) entry.item];
|
||||
}
|
||||
|
||||
ranked.sort((a, b) {
|
||||
final scoreComparison = b.score.compareTo(a.score);
|
||||
if (scoreComparison != 0) return scoreComparison;
|
||||
return a.originalIndex.compareTo(b.originalIndex);
|
||||
});
|
||||
final retained = HeapPriorityQueue<_RankedMediaItem>(_compareRankedWorstFirst);
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
final score = _mediaSearchRelevanceScoreNormalized(item, searchQuery);
|
||||
if (retained.length < limit) {
|
||||
retained.add(_RankedMediaItem(item: item, score: score, originalIndex: i));
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = ranked.map((entry) => entry.item);
|
||||
return limit == null ? result.toList() : result.take(limit).toList();
|
||||
final worst = retained.first;
|
||||
if (score > worst.score || (score == worst.score && i < worst.originalIndex)) {
|
||||
retained
|
||||
..removeFirst()
|
||||
..add(_RankedMediaItem(item: item, score: score, originalIndex: i));
|
||||
}
|
||||
}
|
||||
|
||||
final ranked = retained.toList()..sort(_compareRankedBestFirst);
|
||||
return [for (final entry in ranked) entry.item];
|
||||
}
|
||||
|
||||
double mediaSearchRelevanceScore(MediaItem item, String query) {
|
||||
final normalizedQuery = normalizeSearchText(query);
|
||||
if (normalizedQuery.isEmpty) return 0;
|
||||
final fields = <({String? value, double weight})>[
|
||||
(value: item.title, weight: 1.0),
|
||||
(value: item.titleSort, weight: 0.98),
|
||||
(value: item.originalTitle, weight: 0.96),
|
||||
(value: item.grandparentTitle, weight: 0.9),
|
||||
(value: item.parentTitle, weight: 0.8),
|
||||
];
|
||||
|
||||
var best = 0.0;
|
||||
for (final field in fields) {
|
||||
final candidate = normalizeSearchText(field.value);
|
||||
if (candidate.isEmpty) continue;
|
||||
best = math.max(best, _scoreNormalizedField(normalizedQuery, candidate) * field.weight);
|
||||
}
|
||||
double _mediaSearchRelevanceScoreNormalized(MediaItem item, _NormalizedSearchQuery query) {
|
||||
var best = _scoreWeightedField(item.title, query, 1.0);
|
||||
best = math.max(best, _scoreWeightedField(item.titleSort, query, 0.98));
|
||||
best = math.max(best, _scoreWeightedField(item.originalTitle, query, 0.96));
|
||||
best = math.max(best, _scoreWeightedField(item.grandparentTitle, query, 0.9));
|
||||
best = math.max(best, _scoreWeightedField(item.parentTitle, query, 0.8));
|
||||
return best;
|
||||
}
|
||||
|
||||
String normalizeSearchText(String? value) {
|
||||
if (value == null) return '';
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[\u0000-\u002f\u003a-\u0040\u005b-\u0060\u007b-\u007f]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
double _scoreWeightedField(String? value, _NormalizedSearchQuery query, double weight) {
|
||||
final candidate = normalizeSearchText(value);
|
||||
if (candidate.isEmpty) return 0;
|
||||
return _scoreNormalizedField(query, candidate) * weight;
|
||||
}
|
||||
|
||||
double _scoreNormalizedField(String query, String candidate) {
|
||||
if (candidate == query) return 1000;
|
||||
/// Produces an accent-sensitive search key where canonical/compatibility
|
||||
/// equivalents and Unicode typography compare alike.
|
||||
String normalizeSearchText(String? value) {
|
||||
if (value == null) return '';
|
||||
return nfkc(value).toLowerCase().replaceAll(_searchSeparatorPattern, ' ').trim();
|
||||
}
|
||||
|
||||
final queryWithoutArticle = _withoutLeadingArticle(query);
|
||||
final candidateWithoutArticle = _withoutLeadingArticle(candidate);
|
||||
if (queryWithoutArticle.isNotEmpty && candidateWithoutArticle == queryWithoutArticle) return 980;
|
||||
double _scoreNormalizedField(_NormalizedSearchQuery query, String candidate) {
|
||||
if (candidate == query.text) return 1000;
|
||||
|
||||
if (candidate.startsWith(query)) return 900 + _lengthCloseness(query, candidate, 50);
|
||||
if (queryWithoutArticle.isNotEmpty && candidateWithoutArticle.startsWith(queryWithoutArticle)) {
|
||||
return 880 + _lengthCloseness(queryWithoutArticle, candidateWithoutArticle, 50);
|
||||
}
|
||||
if (candidate.startsWith(query.text)) return 900 + _lengthCloseness(query.text, candidate, 50);
|
||||
|
||||
if (candidate.contains(query)) return 800 + _lengthCloseness(query, candidate, 50);
|
||||
if (candidate.contains(query.text)) return 800 + _lengthCloseness(query.text, candidate, 50);
|
||||
|
||||
final queryTokens = _tokens(query);
|
||||
final queryTokens = query.tokens;
|
||||
final candidateTokens = _tokens(candidate);
|
||||
if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0;
|
||||
|
||||
final candidateTokenSet = candidateTokens.toSet();
|
||||
final matchingTokens = queryTokens.where(candidateTokenSet.contains).length;
|
||||
final sortedQuery = _sortedTokens(queryTokens);
|
||||
final sortedCandidate = _sortedTokens(candidateTokens);
|
||||
final tokenSimilarity = StringSimilarity.compareTwoStrings(sortedQuery, sortedCandidate);
|
||||
final rawSimilarity = StringSimilarity.compareTwoStrings(query, candidate);
|
||||
final tokenSimilarity = StringSimilarity.compareTwoStrings(query.sortedTokens, sortedCandidate);
|
||||
final rawSimilarity = StringSimilarity.compareTwoStrings(query.text, candidate);
|
||||
final fuzzyScore = math.max(rawSimilarity, tokenSimilarity) * 650;
|
||||
|
||||
if (matchingTokens == queryTokens.length) return math.max(700 + tokenSimilarity * 100, fuzzyScore);
|
||||
@@ -95,13 +108,6 @@ String _sortedTokens(List<String> tokens) {
|
||||
return sorted.join(' ');
|
||||
}
|
||||
|
||||
String _withoutLeadingArticle(String value) {
|
||||
for (final article in const ['the ', 'a ', 'an ']) {
|
||||
if (value.startsWith(article)) return value.substring(article.length);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double _lengthCloseness(String query, String candidate, double maxBonus) {
|
||||
final longest = math.max(query.length, candidate.length);
|
||||
if (longest == 0) return 0;
|
||||
@@ -110,6 +116,26 @@ double _lengthCloseness(String query, String candidate, double maxBonus) {
|
||||
return maxBonus * closeness;
|
||||
}
|
||||
|
||||
int _compareRankedBestFirst(_RankedMediaItem a, _RankedMediaItem b) {
|
||||
final scoreComparison = b.score.compareTo(a.score);
|
||||
if (scoreComparison != 0) return scoreComparison;
|
||||
return a.originalIndex.compareTo(b.originalIndex);
|
||||
}
|
||||
|
||||
int _compareRankedWorstFirst(_RankedMediaItem a, _RankedMediaItem b) {
|
||||
final scoreComparison = a.score.compareTo(b.score);
|
||||
if (scoreComparison != 0) return scoreComparison;
|
||||
return b.originalIndex.compareTo(a.originalIndex);
|
||||
}
|
||||
|
||||
class _NormalizedSearchQuery {
|
||||
_NormalizedSearchQuery(String value) : text = normalizeSearchText(value);
|
||||
|
||||
final String text;
|
||||
late final List<String> tokens = _tokens(text);
|
||||
late final String sortedTokens = _sortedTokens(tokens);
|
||||
}
|
||||
|
||||
class _RankedMediaItem {
|
||||
const _RankedMediaItem({required this.item, required this.score, required this.originalIndex});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user