aggregated search

This commit is contained in:
2024-12-09 17:56:44 +08:00
parent cab66619df
commit cf7be85f29
9 changed files with 320 additions and 22 deletions

View File

@@ -247,7 +247,8 @@
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf",
"Export as epub": "导出为epub"
"Export as epub": "导出为epub",
"Aggregated Search": "聚合搜索"
},
"zh_TW": {
"Home": "首頁",
@@ -497,6 +498,7 @@
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub"
"Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜索"
}
}

View File

@@ -1,7 +1,8 @@
part of 'components.dart';
class MouseBackDetector extends StatelessWidget {
const MouseBackDetector({super.key, required this.onTapDown, required this.child});
const MouseBackDetector(
{super.key, required this.onTapDown, required this.child});
final Widget child;
@@ -20,3 +21,45 @@ class MouseBackDetector extends StatelessWidget {
);
}
}
class AnimatedTapRegion extends StatefulWidget {
const AnimatedTapRegion({
super.key,
required this.child,
required this.onTap,
this.borderRadius = 0,
});
final Widget child;
final void Function() onTap;
final double borderRadius;
@override
State<AnimatedTapRegion> createState() => _AnimatedTapRegionState();
}
class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => isHovered = true),
onExit: (_) => setState(() => isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
clipBehavior: Clip.antiAlias,
child: AnimatedScale(
duration: _fastAnimationDuration,
scale: isHovered ? 1.1 : 1,
child: widget.child,
),
),
),
);
}
}

View File

@@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
},
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
if (pointerSignal.kind == PointerDeviceKind.mouse &&
!_isMouseScroll) {
setState(() {

View File

@@ -267,13 +267,14 @@ class OptionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return AnimatedContainer(
duration: _fastAnimationDuration,
decoration: BoxDecoration(
color: isSelected
? context.colorScheme.primaryContainer
? context.colorScheme.secondaryContainer
: context.colorScheme.surface,
border: isSelected
? Border.all(color: context.colorScheme.primaryContainer)
? Border.all(color: context.colorScheme.secondaryContainer)
: Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),

View File

@@ -0,0 +1,213 @@
import "package:flutter/material.dart";
import "package:shimmer/shimmer.dart";
import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart";
import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/foundation/image_provider/cached_image.dart";
import "package:venera/pages/search_result_page.dart";
import "comic_page.dart";
class AggregatedSearchPage extends StatefulWidget {
const AggregatedSearchPage({super.key, required this.keyword});
final String keyword;
@override
State<AggregatedSearchPage> createState() => _AggregatedSearchPageState();
}
class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
late final List<ComicSource> sources;
late final SearchBarController controller;
var _keyword = "";
@override
void initState() {
sources = ComicSource.all().where((e) => e.searchPageData != null).toList();
_keyword = widget.keyword;
controller = SearchBarController(
currentText: widget.keyword,
onSearch: (text) {
setState(() {
_keyword = text;
});
},
);
super.initState();
}
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(slivers: [
SliverSearchBar(controller: controller),
SliverList(
key: ValueKey(_keyword),
delegate: SliverChildBuilderDelegate(
(context, index) {
final source = sources[index];
return _SliverSearchResult(source: source, keyword: widget.keyword);
},
childCount: sources.length,
),
),
]);
}
}
class _SliverSearchResult extends StatefulWidget {
const _SliverSearchResult({required this.source, required this.keyword});
final ComicSource source;
final String keyword;
@override
State<_SliverSearchResult> createState() => _SliverSearchResultState();
}
class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin {
bool isLoading = true;
static const _kComicHeight = 144.0;
get _comicWidth => _kComicHeight * 0.72;
static const _kLeftPadding = 16.0;
List<Comic>? comics;
void load() async {
final data = widget.source.searchPageData!;
var options =
(data.searchOptions ?? []).map((e) => e.defaultValue).toList();
if (data.loadPage != null) {
var res = await data.loadPage!(widget.keyword, 1, options);
if (!res.error) {
setState(() {
comics = res.data;
isLoading = false;
});
}
} else if (data.loadNext != null) {
var res = await data.loadNext!(widget.keyword, null, options);
if (!res.error) {
setState(() {
comics = res.data;
isLoading = false;
});
}
}
}
@override
void initState() {
super.initState();
load();
}
Widget buildPlaceHolder() {
return Container(
height: _kComicHeight,
width: _comicWidth,
margin: const EdgeInsets.only(left: _kLeftPadding),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
),
);
}
Widget buildComic(Comic c) {
return AnimatedTapRegion(
borderRadius: 8,
onTap: () {
context.to(() => ComicPage(
id: c.id,
sourceKey: c.sourceKey,
));
},
child: Container(
height: _kComicHeight,
width: _comicWidth,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
),
child: AnimatedImage(
width: _comicWidth,
height: _kComicHeight,
fit: BoxFit.cover,
image: CachedImageProvider(c.cover),
),
),
).paddingLeft(_kLeftPadding);
}
@override
Widget build(BuildContext context) {
super.build(context);
return InkWell(
onTap: () {
context.to(
() => SearchResultPage(
text: widget.keyword,
sourceKey: widget.source.key,
),
);
},
child: Column(
children: [
ListTile(
mouseCursor: SystemMouseCursors.click,
title: Text(widget.source.name),
),
if (isLoading)
SizedBox(
height: _kComicHeight,
width: double.infinity,
child: Shimmer.fromColors(
baseColor: context.colorScheme.surfaceContainerLow,
highlightColor: context.colorScheme.surfaceContainer,
direction: ShimmerDirection.ltr,
child: LayoutBuilder(builder: (context, constrains) {
var itemWidth = _comicWidth + _kLeftPadding;
var items = (constrains.maxWidth / itemWidth).ceil();
return Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Row(
children: List.generate(
items,
(index) => buildPlaceHolder(),
),
),
)
],
);
}),
),
)
else
SizedBox(
height: _kComicHeight,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var c in comics!) buildComic(c),
],
),
),
],
).paddingBottom(16),
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -7,6 +7,7 @@ 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';
@@ -27,6 +28,8 @@ class _SearchPageState extends State<SearchPage> {
String searchTarget = "";
bool aggregatedSearch = false;
var focusNode = FocusNode();
var options = <String>[];
@@ -36,6 +39,11 @@ class _SearchPageState extends State<SearchPage> {
}
void search([String? text]) {
if (aggregatedSearch) {
context
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
.then((_) => update());
} else {
context
.to(
() => SearchResultPage(
@@ -46,6 +54,7 @@ class _SearchPageState extends State<SearchPage> {
)
.then((_) => update());
}
}
var suggestions = <Pair<String, TranslationType>>[];
@@ -189,6 +198,7 @@ class _SearchPageState extends State<SearchPage> {
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text("Search in".tl),
),
Wrap(
@@ -197,8 +207,9 @@ class _SearchPageState extends State<SearchPage> {
children: sources.map((e) {
return OptionChip(
text: e.name,
isSelected: searchTarget == e.key,
isSelected: searchTarget == e.key || aggregatedSearch,
onTap: () {
if (aggregatedSearch) return;
setState(() {
searchTarget = e.key;
useDefaultOptions();
@@ -207,6 +218,18 @@ class _SearchPageState extends State<SearchPage> {
);
}).toList(),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Aggregated Search".tl),
leading: Checkbox(
value: aggregatedSearch,
onChanged: (value) {
setState(() {
aggregatedSearch = value ?? false;
});
},
),
),
],
),
),
@@ -221,6 +244,10 @@ class _SearchPageState extends State<SearchPage> {
}
Widget buildSearchOptions() {
if (aggregatedSearch) {
return const SliverToBoxAdapter(child: SizedBox());
}
var children = <Widget>[];
final searchOptions =
@@ -262,9 +289,9 @@ class _SearchPageState extends State<SearchPage> {
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const Divider(
thickness: 0.6,
).paddingTop(16);
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(

View File

@@ -14,14 +14,14 @@ class SearchResultPage extends StatefulWidget {
super.key,
required this.text,
required this.sourceKey,
required this.options,
this.options,
});
final String text;
final String sourceKey;
final List<String> options;
final List<String>? options;
@override
State<SearchResultPage> createState() => _SearchResultPageState();
@@ -99,7 +99,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
onSearch: search,
);
sourceKey = widget.sourceKey;
options = widget.options;
options = widget.options ?? const [];
validateOptions();
text = widget.text;
appdata.addSearchHistory(text);

View File

@@ -852,6 +852,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter

View File

@@ -71,6 +71,7 @@ dependencies:
ref: 3315082b9f7055655610e4f6f136b69e48228c05
pdf: ^3.11.1
dynamic_color: ^1.7.0
shimmer: ^3.0.0
dev_dependencies:
flutter_test: