diff --git a/assets/translation.json b/assets/translation.json index 1114ff3..c10221f 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -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": "聚合搜索" } } \ No newline at end of file diff --git a/lib/components/gesture.dart b/lib/components/gesture.dart index bd345f2..5efc32f 100644 --- a/lib/components/gesture.dart +++ b/lib/components/gesture.dart @@ -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 createState() => _AnimatedTapRegionState(); +} + +class _AnimatedTapRegionState extends State { + 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, + ), + ), + ), + ); + } +} diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index 9cbe82c..98fd287 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State { }, onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { + if (HardwareKeyboard.instance.isShiftPressed) { + return; + } if (pointerSignal.kind == PointerDeviceKind.mouse && !_isMouseScroll) { setState(() { diff --git a/lib/components/select.dart b/lib/components/select.dart index e2b9e05..201c78f 100644 --- a/lib/components/select.dart +++ b/lib/components/select.dart @@ -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), ), diff --git a/lib/pages/aggregated_search_page.dart b/lib/pages/aggregated_search_page.dart new file mode 100644 index 0000000..10883ad --- /dev/null +++ b/lib/pages/aggregated_search_page.dart @@ -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 createState() => _AggregatedSearchPageState(); +} + +class _AggregatedSearchPageState extends State { + late final List 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? 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; +} diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 735c7e5..44bf020 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -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 { String searchTarget = ""; + bool aggregatedSearch = false; + var focusNode = FocusNode(); var options = []; @@ -36,15 +39,21 @@ class _SearchPageState extends State { } void search([String? text]) { - context - .to( - () => SearchResultPage( - text: text ?? controller.text, - sourceKey: searchTarget, - options: options, - ), - ) - .then((_) => update()); + 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 = >[]; @@ -189,6 +198,7 @@ class _SearchPageState extends State { children: [ ListTile( contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.search), title: Text("Search in".tl), ), Wrap( @@ -197,8 +207,9 @@ class _SearchPageState extends State { 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 { ); }).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 { } Widget buildSearchOptions() { + if (aggregatedSearch) { + return const SliverToBoxAdapter(child: SizedBox()); + } + var children = []; final searchOptions = @@ -262,9 +289,9 @@ class _SearchPageState extends State { 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( diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart index 1d67e5f..9543c0b 100644 --- a/lib/pages/search_result_page.dart +++ b/lib/pages/search_result_page.dart @@ -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 options; + final List? options; @override State createState() => _SearchResultPageState(); @@ -99,7 +99,7 @@ class _SearchResultPageState extends State { onSearch: search, ); sourceKey = widget.sourceKey; - options = widget.options; + options = widget.options ?? const []; validateOptions(); text = widget.text; appdata.addSearchHistory(text); diff --git a/pubspec.lock b/pubspec.lock index e85ba84..4d57838 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index 7b4097e..e4da6a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: ref: 3315082b9f7055655610e4f6f136b69e48228c05 pdf: ^3.11.1 dynamic_color: ^1.7.0 + shimmer: ^3.0.0 dev_dependencies: flutter_test: