mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
aggregated search
This commit is contained in:
@@ -247,7 +247,8 @@
|
|||||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
|
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
|
||||||
"No new version available": "没有新版本可用",
|
"No new version available": "没有新版本可用",
|
||||||
"Export as pdf": "导出为pdf",
|
"Export as pdf": "导出为pdf",
|
||||||
"Export as epub": "导出为epub"
|
"Export as epub": "导出为epub",
|
||||||
|
"Aggregated Search": "聚合搜索"
|
||||||
},
|
},
|
||||||
"zh_TW": {
|
"zh_TW": {
|
||||||
"Home": "首頁",
|
"Home": "首頁",
|
||||||
@@ -497,6 +498,7 @@
|
|||||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
|
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
|
||||||
"No new version available": "沒有新版本可用",
|
"No new version available": "沒有新版本可用",
|
||||||
"Export as pdf": "匯出為pdf",
|
"Export as pdf": "匯出為pdf",
|
||||||
"Export as epub": "匯出為epub"
|
"Export as epub": "匯出為epub",
|
||||||
|
"Aggregated Search": "聚合搜索"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,7 +1,8 @@
|
|||||||
part of 'components.dart';
|
part of 'components.dart';
|
||||||
|
|
||||||
class MouseBackDetector extends StatelessWidget {
|
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;
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
|||||||
},
|
},
|
||||||
onPointerSignal: (pointerSignal) {
|
onPointerSignal: (pointerSignal) {
|
||||||
if (pointerSignal is PointerScrollEvent) {
|
if (pointerSignal is PointerScrollEvent) {
|
||||||
|
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pointerSignal.kind == PointerDeviceKind.mouse &&
|
if (pointerSignal.kind == PointerDeviceKind.mouse &&
|
||||||
!_isMouseScroll) {
|
!_isMouseScroll) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
@@ -267,13 +267,14 @@ class OptionChip extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return AnimatedContainer(
|
||||||
|
duration: _fastAnimationDuration,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? context.colorScheme.primaryContainer
|
? context.colorScheme.secondaryContainer
|
||||||
: context.colorScheme.surface,
|
: context.colorScheme.surface,
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? Border.all(color: context.colorScheme.primaryContainer)
|
? Border.all(color: context.colorScheme.secondaryContainer)
|
||||||
: Border.all(color: context.colorScheme.outline),
|
: Border.all(color: context.colorScheme.outline),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
|
213
lib/pages/aggregated_search_page.dart
Normal file
213
lib/pages/aggregated_search_page.dart
Normal 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;
|
||||||
|
}
|
@@ -7,6 +7,7 @@ import 'package:venera/foundation/app.dart';
|
|||||||
import 'package:venera/foundation/appdata.dart';
|
import 'package:venera/foundation/appdata.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
import 'package:venera/foundation/state_controller.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/pages/search_result_page.dart';
|
||||||
import 'package:venera/utils/app_links.dart';
|
import 'package:venera/utils/app_links.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
@@ -27,6 +28,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
|
|
||||||
String searchTarget = "";
|
String searchTarget = "";
|
||||||
|
|
||||||
|
bool aggregatedSearch = false;
|
||||||
|
|
||||||
var focusNode = FocusNode();
|
var focusNode = FocusNode();
|
||||||
|
|
||||||
var options = <String>[];
|
var options = <String>[];
|
||||||
@@ -36,15 +39,21 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void search([String? text]) {
|
void search([String? text]) {
|
||||||
context
|
if (aggregatedSearch) {
|
||||||
.to(
|
context
|
||||||
() => SearchResultPage(
|
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
|
||||||
text: text ?? controller.text,
|
.then((_) => update());
|
||||||
sourceKey: searchTarget,
|
} else {
|
||||||
options: options,
|
context
|
||||||
),
|
.to(
|
||||||
)
|
() => SearchResultPage(
|
||||||
.then((_) => update());
|
text: text ?? controller.text,
|
||||||
|
sourceKey: searchTarget,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((_) => update());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var suggestions = <Pair<String, TranslationType>>[];
|
var suggestions = <Pair<String, TranslationType>>[];
|
||||||
@@ -189,6 +198,7 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.search),
|
||||||
title: Text("Search in".tl),
|
title: Text("Search in".tl),
|
||||||
),
|
),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -197,8 +207,9 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
children: sources.map((e) {
|
children: sources.map((e) {
|
||||||
return OptionChip(
|
return OptionChip(
|
||||||
text: e.name,
|
text: e.name,
|
||||||
isSelected: searchTarget == e.key,
|
isSelected: searchTarget == e.key || aggregatedSearch,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (aggregatedSearch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
searchTarget = e.key;
|
searchTarget = e.key;
|
||||||
useDefaultOptions();
|
useDefaultOptions();
|
||||||
@@ -207,6 +218,18 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).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() {
|
Widget buildSearchOptions() {
|
||||||
|
if (aggregatedSearch) {
|
||||||
|
return const SliverToBoxAdapter(child: SizedBox());
|
||||||
|
}
|
||||||
|
|
||||||
var children = <Widget>[];
|
var children = <Widget>[];
|
||||||
|
|
||||||
final searchOptions =
|
final searchOptions =
|
||||||
@@ -262,9 +289,9 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
return const Divider(
|
return const SizedBox(
|
||||||
thickness: 0.6,
|
height: 16,
|
||||||
).paddingTop(16);
|
);
|
||||||
}
|
}
|
||||||
if (index == 1) {
|
if (index == 1) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
@@ -14,14 +14,14 @@ class SearchResultPage extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.sourceKey,
|
required this.sourceKey,
|
||||||
required this.options,
|
this.options,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
final String sourceKey;
|
final String sourceKey;
|
||||||
|
|
||||||
final List<String> options;
|
final List<String>? options;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SearchResultPage> createState() => _SearchResultPageState();
|
State<SearchResultPage> createState() => _SearchResultPageState();
|
||||||
@@ -99,7 +99,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
|||||||
onSearch: search,
|
onSearch: search,
|
||||||
);
|
);
|
||||||
sourceKey = widget.sourceKey;
|
sourceKey = widget.sourceKey;
|
||||||
options = widget.options;
|
options = widget.options ?? const [];
|
||||||
validateOptions();
|
validateOptions();
|
||||||
text = widget.text;
|
text = widget.text;
|
||||||
appdata.addSearchHistory(text);
|
appdata.addSearchHistory(text);
|
||||||
|
@@ -852,6 +852,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@@ -71,6 +71,7 @@ dependencies:
|
|||||||
ref: 3315082b9f7055655610e4f6f136b69e48228c05
|
ref: 3315082b9f7055655610e4f6f136b69e48228c05
|
||||||
pdf: ^3.11.1
|
pdf: ^3.11.1
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
|
shimmer: ^3.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Reference in New Issue
Block a user