Files
venera/lib/pages/search_page.dart
buste 3aca3baafc Fix ensure searchTarget is properly initialized for aggregatedSearch mode (#173)
Set searchTarget = defaultSearchTarget when aggregatedSearch is enabled, ensuring correct initialization and preventing missing suggestions on first input.

Without this fix, when opening the search page for the first time with aggregatedSearch enabled by default, entering an ID that matches a comic source does not trigger the "Open comic" suggestion. However, after toggling aggregatedSearch off and then back on, the same ID input correctly displays the suggestion.
2025-02-07 17:03:52 +08:00

643 lines
18 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'comic_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
late final SearchBarController controller;
String searchTarget = "";
bool aggregatedSearch = false;
var focusNode = FocusNode();
var options = <String>[];
void update() {
setState(() {});
}
void search([String? text]) {
if (aggregatedSearch) {
context
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
.then((_) => update());
} else {
context
.to(
() => SearchResultPage(
text: text ?? controller.text,
sourceKey: searchTarget,
options: options,
),
)
.then((_) => update());
}
}
var suggestions = <Pair<String, TranslationType>>[];
bool canHandleUrl(String text) {
if (!text.isURL) return false;
for (var source in ComicSource.all()) {
if (source.linkHandler != null) {
var uri = Uri.parse(text);
if (source.linkHandler!.domains.contains(uri.host)) {
return true;
}
}
}
return false;
}
void findSuggestions() {
var text = controller.text.split(" ").last;
var suggestions = this.suggestions;
suggestions.clear();
if (canHandleUrl(controller.text)) {
suggestions.add(Pair("**URL**", TranslationType.other));
} else {
var text = controller.text;
for (var comicSource in ComicSource.all()) {
if (comicSource.idMatcher?.hasMatch(text) ?? false) {
suggestions.add(Pair(
"**${comicSource.key}**",
TranslationType.other,
));
}
}
}
if (!ComicSource.find(searchTarget)!.enableTagsSuggestions) {
update();
return;
}
bool check(String text, String key, String value) {
if (text.removeAllBlank == "") {
return false;
}
if (key.length >= text.length && key.substring(0, text.length) == text ||
(key.contains(" ") &&
key.split(" ").last.length >= text.length &&
key.split(" ").last.substring(0, text.length) == text)) {
return true;
} else if (value.length >= text.length && value.contains(text)) {
return true;
}
return false;
}
void find(Map<String, String> map, TranslationType type) {
for (var element in map.entries) {
if (suggestions.length > 100) {
break;
}
if (check(text, element.key, element.value)) {
suggestions.add(Pair(element.key, type));
}
}
}
find(TagsTranslation.femaleTags, TranslationType.female);
find(TagsTranslation.maleTags, TranslationType.male);
find(TagsTranslation.parodyTags, TranslationType.parody);
find(TagsTranslation.characterTranslations, TranslationType.character);
find(TagsTranslation.otherTags, TranslationType.other);
find(TagsTranslation.mixedTags, TranslationType.mixed);
find(TagsTranslation.languageTranslations, TranslationType.language);
find(TagsTranslation.artistTags, TranslationType.artist);
find(TagsTranslation.groupTags, TranslationType.group);
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
update();
}
@override
void initState() {
var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget == "_aggregated_") {
aggregatedSearch = true;
searchTarget = ComicSource.all().where((e) => e.searchPageData != null)
.toList().first.key;
} else if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) {
searchTarget = defaultSearchTarget;
} else {
searchTarget = ComicSource.all().first.key;
}
controller = SearchBarController(
onSearch: search,
);
super.initState();
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: buildSlivers().toList(),
),
);
}
Iterable<Widget> buildSlivers() sync* {
yield SliverSearchBar(
controller: controller,
onChanged: (s) {
findSuggestions();
},
focusNode: focusNode,
);
if (suggestions.isNotEmpty) {
yield buildSuggestions(context);
} else {
yield buildSearchTarget();
yield SliverAnimatedPaintExtent(
duration: const Duration(milliseconds: 200),
child: buildSearchOptions(),
);
yield _SearchHistory(search);
}
}
Widget buildSearchTarget() {
var sources =
ComicSource.all().where((e) => e.searchPageData != null).toList();
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text("Search in".tl),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: sources.map((e) {
return OptionChip(
text: e.name,
isSelected: searchTarget == e.key || aggregatedSearch,
onTap: () {
if (aggregatedSearch) return;
setState(() {
searchTarget = e.key;
useDefaultOptions();
});
},
);
}).toList(),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Aggregated Search".tl),
leading: Checkbox(
value: aggregatedSearch,
onChanged: (value) {
setState(() {
aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
});
},
),
),
],
),
),
);
}
void useDefaultOptions() {
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
options = searchOptions.map((e) => e.defaultValue).toList();
}
Widget buildSearchOptions() {
if (aggregatedSearch) {
return const SliverToBoxAdapter(child: SizedBox());
}
var children = <Widget>[];
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
if (searchOptions.length != options.length) {
useDefaultOptions();
}
if (searchOptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox());
}
for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i];
children.add(SearchOptionWidget(
option: option,
value: options[i],
onChanged: (value) {
options[i] = value;
update();
},
sourceKey: searchTarget,
));
}
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
);
}
Widget buildSuggestions(BuildContext context) {
bool check(String text, String key, String value) {
if (text.removeAllBlank == "") {
return false;
}
if (key.length >= text.length && key.substring(0, text.length) == text ||
(key.contains(" ") &&
key.split(" ").last.length >= text.length &&
key.split(" ").last.substring(0, text.length) == text)) {
return true;
} else if (value.length >= text.length && value.contains(text)) {
return true;
}
return false;
}
void onSelected(String text, TranslationType? type) {
var words = controller.text.split(" ");
if (words.length >= 2 &&
check("${words[words.length - 2]} ${words[words.length - 1]}", text,
text.translateTagsToCN)) {
controller.text = controller.text.replaceLast(
"${words[words.length - 2]} ${words[words.length - 1]}", "");
} else {
controller.text =
controller.text.replaceLast(words[words.length - 1], "");
}
if (type != null) {
controller.text += "${type.name}:$text ";
} else {
controller.text += "$text ";
}
suggestions.clear();
update();
focusNode.requestFocus();
}
bool showMethod = MediaQuery.of(context).size.width < 600;
bool showTranslation = App.locale.languageCode == "zh";
Widget buildItem(Pair<String, TranslationType> value) {
if (value.left == "**URL**") {
return ListTile(
leading: const Icon(Icons.link),
title: Text("Open link".tl),
subtitle: Text(
controller.text,
maxLines: 1,
overflow: TextOverflow.fade,
),
trailing: const Icon(Icons.arrow_right),
onTap: () {
setState(() {
suggestions.clear();
});
handleAppLink(Uri.parse(controller.text));
},
);
}
if (RegExp(r"^\*\*.*\*\*$").hasMatch(value.left)) {
var key = value.left.substring(2, value.left.length - 2);
var comicSource = ComicSource.find(key);
if (comicSource == null) {
return const SizedBox();
}
return ListTile(
leading: const Icon(Icons.link),
title: Text("${"Open comic".tl}: ${comicSource.name}"),
subtitle: Text(
controller.text,
maxLines: 1,
overflow: TextOverflow.fade,
),
trailing: const Icon(Icons.arrow_right),
onTap: () {
context.to(
() => ComicPage(
sourceKey: key,
id: controller.text,
),
);
},
);
}
var subTitle = TagsTranslation.translationTagWithNamespace(
value.left, value.right.name);
return ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Text(value.left),
),
if (!showMethod)
const SizedBox(
width: 12,
),
if (!showMethod && showTranslation)
Text(
subTitle,
style: TextStyle(
fontSize: 14, color: Theme.of(context).colorScheme.outline),
)
],
),
subtitle: (showMethod && showTranslation) ? Text(subTitle) : null,
trailing: Text(
value.right.name,
style: const TextStyle(fontSize: 13),
),
onTap: () => onSelected(value.left, value.right),
);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.hub_outlined),
title: Text("Suggestions".tl),
trailing: Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
suggestions.clear();
update();
},
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return buildItem(suggestions[index]);
},
childCount: suggestions.length,
),
),
],
);
}
}
class SearchOptionWidget extends StatelessWidget {
const SearchOptionWidget({
super.key,
required this.option,
required this.value,
required this.onChanged,
required this.sourceKey,
});
final SearchOptions option;
final String value;
final void Function(String) onChanged;
final String sourceKey;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(option.label.ts(sourceKey)),
),
if (option.type == 'select')
Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(sourceKey),
isSelected: value == e.key,
onTap: () {
onChanged(e.key);
},
);
}).toList(),
),
if (option.type == 'multi-select')
Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(sourceKey),
isSelected: (jsonDecode(value) as List).contains(e.key),
onTap: () {
var list = jsonDecode(value) as List;
if (list.contains(e.key)) {
list.remove(e.key);
} else {
list.add(e.key);
}
onChanged(jsonEncode(list));
},
);
}).toList(),
),
if (option.type == 'dropdown')
Select(
current: option.options[value],
values: option.options.values.toList(),
onTap: (index) {
onChanged(option.options.keys.elementAt(index));
},
minWidth: 96,
)
],
);
}
}
class _SearchHistory extends StatefulWidget {
const _SearchHistory(this.search);
final void Function(String) search;
@override
State<_SearchHistory> createState() => _SearchHistoryState();
}
class _SearchHistoryState extends State<_SearchHistory> {
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
setState(() {});
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<FlyoutState>()!
.show();
},
),
);
},
),
),
);
}
return buildItem(index - 2);
},
childCount: 2 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
Widget buildItem(int index) {
void showMenu(Offset offset) {
showMenuX(
context,
offset,
[
MenuEntry(
icon: Icons.copy,
text: 'Copy'.tl,
onClick: () {
Clipboard.setData(
ClipboardData(text: appdata.searchHistory[index]));
},
),
MenuEntry(
icon: Icons.delete,
text: 'Delete'.tl,
onClick: () {
appdata.removeSearchHistory(appdata.searchHistory[index]);
appdata.saveData();
setState(() {});
},
),
],
);
}
return Builder(builder: (context) {
return InkWell(
onTap: () {
widget.search(appdata.searchHistory[index]);
},
onLongPress: () {
var renderBox = context.findRenderObject() as RenderBox;
var offset = renderBox.localToGlobal(Offset.zero);
showMenu(Offset(
offset.dx + renderBox.size.width / 2 - 121,
offset.dy + renderBox.size.height - 8,
));
},
onSecondaryTapUp: (details) {
showMenu(details.globalPosition);
},
child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
});
}
}