mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00

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.
643 lines
18 KiB
Dart
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);
|
|
});
|
|
}
|
|
}
|