From 2772289a1987d004e6163eea4b9a178d9c2f9b48 Mon Sep 17 00:00:00 2001 From: nyne Date: Fri, 4 Oct 2024 10:37:31 +0800 Subject: [PATCH] search page --- lib/components/appbar.dart | 355 ++++++++++++++++++++--------- lib/components/comic.dart | 7 +- lib/foundation/app_page_route.dart | 7 +- lib/foundation/appdata.dart | 46 +++- lib/pages/comic_source_page.dart | 6 +- lib/pages/home_page.dart | 214 ++++++++++------- lib/pages/main_page.dart | 19 +- lib/pages/search_page.dart | 173 ++++++++++++++ lib/pages/search_result_page.dart | 62 +++++ 9 files changed, 689 insertions(+), 200 deletions(-) create mode 100644 lib/pages/search_page.dart create mode 100644 lib/pages/search_result_page.dart diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index 8624075..6ef61d8 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -233,88 +233,6 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate { } } -class FloatingSearchBar extends StatefulWidget { - const FloatingSearchBar({ - super.key, - this.height = 56, - this.trailing, - required this.onSearch, - required this.controller, - this.onChanged, - }); - - /// height of search bar - final double height; - - /// end of search bar - final Widget? trailing; - - /// callback when user do search - final void Function(String) onSearch; - - /// controller of [TextField] - final TextEditingController controller; - - final void Function(String)? onChanged; - - @override - State createState() => _FloatingSearchBarState(); -} - -class _FloatingSearchBarState extends State { - double get effectiveHeight { - return math.max(widget.height, 53); - } - - @override - Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; - var text = widget.controller.text; - if (text.isEmpty) { - text = "Search"; - } - var padding = 12.0; - return Container( - padding: EdgeInsets.fromLTRB(padding, 9, padding, 0), - width: double.infinity, - height: effectiveHeight, - child: Material( - elevation: 0, - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(effectiveHeight / 2), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row(children: [ - Tooltip( - message: "返回".tl, - child: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: TextField( - controller: widget.controller, - decoration: const InputDecoration( - border: InputBorder.none, - ), - onSubmitted: (s) { - widget.onSearch(s); - }, - onChanged: widget.onChanged, - ), - ), - ), - if (widget.trailing != null) widget.trailing! - ]), - ), - ), - ); - } -} - class FilledTabBar extends StatefulWidget { const FilledTabBar({super.key, this.controller, required this.tabs}); @@ -420,21 +338,18 @@ class _FilledTabBarState extends State { }, ); return Container( - key: tabBarKey, - height: _kTabHeight, - width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: context.colorScheme.outlineVariant, - width: 0.6, + key: tabBarKey, + height: _kTabHeight, + width: double.infinity, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), ), ), - ), - child: widget.tabs.isEmpty - ? const SizedBox() - : child - ); + child: widget.tabs.isEmpty ? const SizedBox() : child); } int? previousIndex; @@ -482,11 +397,11 @@ class _FilledTabBarState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: DefaultTextStyle( style: DefaultTextStyle.of(context).style.copyWith( - color: i == _controller.index - ? context.colorScheme.primary - : context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), + color: i == _controller.index + ? context.colorScheme.primary + : context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), child: widget.tabs[i], ), ), @@ -611,8 +526,8 @@ class _IndicatorPainter extends CustomPainter { final Rect toRect = indicatorRect(size, to); _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs()); final Paint paint = Paint()..color = color; - final RRect rrect = - RRect.fromRectAndCorners(_currentRect!, topLeft: Radius.circular(radius), topRight: Radius.circular(radius)); + final RRect rrect = RRect.fromRectAndCorners(_currentRect!, + topLeft: Radius.circular(radius), topRight: Radius.circular(radius)); canvas.drawRRect(rrect, paint); } @@ -621,3 +536,239 @@ class _IndicatorPainter extends CustomPainter { return false; } } + +class SearchBarController { + _SearchBarMixin? _state; + + final void Function(String text)? onSearch; + + final String initialText; + + void setText(String text) { + _state?.setText(text); + } + + String get text => _state?.getText() ?? ''; + + SearchBarController({this.onSearch, this.initialText = ''}); +} + +abstract mixin class _SearchBarMixin { + void setText(String text); + + String getText(); +} + +class SliverSearchBar extends StatefulWidget { + const SliverSearchBar({super.key, required this.controller}); + + final SearchBarController controller; + + @override + State createState() => _SliverSearchBarState(); +} + +class _SliverSearchBarState extends State + with _SearchBarMixin { + late TextEditingController _editingController; + + late SearchBarController _controller; + + @override + void initState() { + _controller = widget.controller; + _controller._state = this; + _editingController = TextEditingController(text: _controller.initialText); + super.initState(); + } + + @override + void setText(String text) { + _editingController.text = text; + } + + @override + String getText() { + return _editingController.text; + } + + @override + Widget build(BuildContext context) { + return SliverPersistentHeader( + delegate: _SliverSearchBarDelegate( + editingController: _editingController, + controller: _controller, + topPadding: MediaQuery.of(context).padding.top, + ), + ); + } +} + +class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { + final TextEditingController editingController; + + final SearchBarController controller; + + final double topPadding; + + const _SliverSearchBarDelegate({ + required this.editingController, + required this.controller, + required this.topPadding, + }); + + static const _kAppBarHeight = 52.0; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + height: _kAppBarHeight + topPadding, + width: double.infinity, + padding: EdgeInsets.only(top: topPadding), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 8), + const BackButton(), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: TextField( + controller: editingController, + decoration: InputDecoration( + hintText: "Search".tl, + border: InputBorder.none, + ), + onSubmitted: (text) { + controller.onSearch?.call(text); + }, + ), + ), + ), + ListenableBuilder( + listenable: editingController, + builder: (context, child) { + return editingController.text.isEmpty + ? const SizedBox() + : IconButton( + iconSize: 20, + icon: const Icon(Icons.clear), + onPressed: () { + editingController.clear(); + }, + ); + }, + ), + const SizedBox(width: 8), + ], + ), + ); + } + + @override + double get maxExtent => _kAppBarHeight + topPadding; + + @override + double get minExtent => _kAppBarHeight + topPadding; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return oldDelegate is! _SliverSearchBarDelegate || + editingController != oldDelegate.editingController || + controller != oldDelegate.controller || + topPadding != oldDelegate.topPadding; + } +} + +class AppSearchBar extends StatefulWidget { + const AppSearchBar({super.key, required this.controller}); + + final SearchBarController controller; + + @override + State createState() => _SearchBarState(); +} + +class _SearchBarState extends State with _SearchBarMixin { + late TextEditingController _editingController; + + late SearchBarController _controller; + + @override + void setText(String text) { + _editingController.text = text; + } + + @override + String getText() { + return _editingController.text; + } + + @override + void initState() { + _controller = widget.controller; + _controller._state = this; + _editingController = TextEditingController(text: _controller.initialText); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.of(context).padding.top; + return Container( + height: _kAppBarHeight + topPadding, + width: double.infinity, + padding: EdgeInsets.only(top: topPadding), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 8), + const BackButton(), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: TextField( + controller: _editingController, + decoration: InputDecoration( + hintText: "Search".tl, + border: InputBorder.none, + ), + onSubmitted: (text) { + _controller.onSearch?.call(text); + }, + ), + ), + ), + ListenableBuilder( + listenable: _editingController, + builder: (context, child) { + return _editingController.text.isEmpty + ? const SizedBox() + : IconButton( + iconSize: 20, + icon: const Icon(Icons.clear), + onPressed: () { + _editingController.clear(); + }, + ); + }, + ), + const SizedBox(width: 8), + ], + ), + ); + } +} diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 9fdd82c..c2c8073 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -505,6 +505,7 @@ class ComicList extends StatefulWidget { this.loadNext, this.leadingSliver, this.trailingSliver, + this.errorLeading, }); final Future>> Function(int page)? loadPage; @@ -515,6 +516,8 @@ class ComicList extends StatefulWidget { final Widget? trailingSliver; + final Widget? errorLeading; + @override State createState() => _ComicListState(); } @@ -691,6 +694,7 @@ class _ComicListState extends State { if (error != null) { return Column( children: [ + if (widget.errorLeading != null) widget.errorLeading!, buildPageSelector(), Expanded( child: NetworkError( @@ -717,7 +721,8 @@ class _ComicListState extends State { if (widget.leadingSliver != null) widget.leadingSliver!, buildSliverPageSelector(), SliverGridComics(comics: data[page] ?? const []), - buildSliverPageSelector(), + if(data[page]!.length > 6) + buildSliverPageSelector(), if (widget.trailingSliver != null) widget.trailingSliver!, ], ); diff --git a/lib/foundation/app_page_route.dart b/lib/foundation/app_page_route.dart index e21ee97..ab4afb6 100644 --- a/lib/foundation/app_page_route.dart +++ b/lib/foundation/app_page_route.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:venera/components/components.dart'; const double _kBackGestureWidth = 20.0; const int _kMaxDroppedSwipePageForwardAnimationTime = 800; @@ -35,7 +36,11 @@ class AppPageRoute extends PageRoute with _AppRouteTransitionMixin{ @override Widget buildContent(BuildContext context) { var widget = builder(context); - label = widget.runtimeType.toString(); + if(widget is NaviPaddingWidget) { + label = widget.child.runtimeType.toString(); + } else { + label = widget.runtimeType.toString(); + } return widget; } diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 866d27f..ac7f06f 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -5,18 +5,53 @@ import 'package:venera/utils/io.dart'; class _Appdata { final _Settings settings = _Settings(); + + var searchHistory = []; - void saveSettings() async { - var data = jsonEncode(settings._data); - var file = File(FilePath.join(App.dataPath, 'settings.json')); + void saveData() async { + var data = jsonEncode(toJson()); + var file = File(FilePath.join(App.dataPath, 'appdata.json')); await file.writeAsString(data); } + void addSearchHistory(String keyword) { + if(searchHistory.contains(keyword)) { + searchHistory.remove(keyword); + } + searchHistory.insert(0, keyword); + if(searchHistory.length > 50) { + searchHistory.removeLast(); + } + saveData(); + } + + void removeSearchHistory(String keyword) { + searchHistory.remove(keyword); + saveData(); + } + + void clearSearchHistory() { + searchHistory.clear(); + saveData(); + } + Future init() async { - var json = jsonDecode(await File(FilePath.join(App.dataPath, 'settings.json')).readAsString()) as Map; - for(var key in json.keys) { + var file = File(FilePath.join(App.dataPath, 'appdata.json')); + if(!await file.exists()) { + return; + } + var json = jsonDecode(await file.readAsString()); + for(var key in json['settings'].keys) { settings[key] = json[key]; } + searchHistory = List.from(json['searchHistory']); + } + + Map toJson() { + return { + 'settings': settings._data, + 'searchHistory': searchHistory, + }; } } @@ -39,6 +74,7 @@ class _Settings { 'showFavoriteStatusOnTile': true, 'showHistoryStatusOnTile': false, 'blockedWords': [], + 'defaultSearchTarget': null, }; operator[](String key) { diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index ca13f1e..cc1da40 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -314,7 +314,7 @@ class _BodyState extends State<_Body> { var comicSource = await ComicSourceParser().createAndParse(js, fileName); ComicSource.add(comicSource); _addAllPagesWithComicSource(comicSource); - appdata.saveSettings(); + appdata.saveData(); App.forceRebuild(); } } @@ -429,7 +429,7 @@ void _validatePages() { appdata.settings['categories'] = categoryPages.toSet().toList(); appdata.settings['favorites'] = networkFavorites.toSet().toList(); - appdata.saveSettings(); + appdata.saveData(); } void _addAllPagesWithComicSource(ComicSource source) { @@ -457,5 +457,5 @@ void _addAllPagesWithComicSource(ComicSource source) { appdata.settings['categories'] = categoryPages.toSet().toList(); appdata.settings['favorites'] = networkFavorites.toSet().toList(); - appdata.saveSettings(); + appdata.saveData(); } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 15444fa..47cd5df 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -6,11 +6,13 @@ 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/comic_type.dart'; +import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/comic_source_page.dart'; +import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; @@ -19,14 +21,50 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return const SmoothCustomScrollView( + var widget = const SmoothCustomScrollView( slivers: [ + _SearchBar(), _History(), _Local(), _ComicSourceWidget(), _AccountsWidget(), ], ); + return context.width > changePoint ? widget.paddingHorizontal(8) : widget; + } +} + +class _SearchBar extends StatelessWidget { + const _SearchBar(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Container( + height: 52, + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Material( + color: context.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(32), + child: InkWell( + borderRadius: BorderRadius.circular(32), + onTap: () { + context.to(() => const SearchPage()); + }, + child: Row( + children: [ + const SizedBox(width: 16), + const Icon(Icons.search), + const SizedBox(width: 8), + Text('Search'.tl, style: ts.s16), + const Spacer(), + ], + ), + ), + ), + ), + ); } } @@ -65,17 +103,18 @@ class _HistoryState extends State<_History> { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: InkWell( - onTap: () {}, - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - width: 0.6, - ), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () {}, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -182,17 +221,18 @@ class _LocalState extends State<_Local> { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: InkWell( - onTap: () {}, - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - width: 0.6, - ), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () {}, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -320,7 +360,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { child: const Center( child: CircularProgressIndicator(), ), - ) + ) : Column( key: key, crossAxisAlignment: CrossAxisAlignment.start, @@ -411,20 +451,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { }); final picker = DirectoryPicker(); final path = await picker.pickDirectory(); - if(!loading) { + if (!loading) { picker.dispose(); return; } - if(path == null) { + if (path == null) { setState(() { loading = false; }); return; } Map comics = {}; - if(type == 0) { + if (type == 0) { var result = await checkSingleComic(path); - if(result != null) { + if (result != null) { comics[path] = result; } else { context.showMessage(message: "Invalid Comic".tl); @@ -434,31 +474,30 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { return; } } else { - await for(var entry in path.list()) { - if(entry is Directory) { + await for (var entry in path.list()) { + if (entry is Directory) { var result = await checkSingleComic(entry); - if(result != null) { + if (result != null) { comics[entry] = result; } } } } bool shouldCopy = true; - for(var comic in comics.keys) { - if(comic.parent.path == LocalManager().path) { + for (var comic in comics.keys) { + if (comic.parent.path == LocalManager().path) { shouldCopy = false; break; } } - if(shouldCopy && comics.isNotEmpty) { + if (shouldCopy && comics.isNotEmpty) { try { // copy the comics to the local directory await compute, void>(_copyDirectories, { 'toBeCopied': comics.keys.map((e) => e.path).toList(), 'destination': LocalManager().path, }); - } - catch(e) { + } catch (e) { context.showMessage(message: "Failed to import comics".tl); Log.error("Import Comic", e.toString()); setState(() { @@ -467,11 +506,12 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { return; } } - for(var comic in comics.values) { + for (var comic in comics.values) { LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); } context.pop(); - context.showMessage(message: "Imported @a comics".tlParams({ + context.showMessage( + message: "Imported @a comics".tlParams({ 'a': comics.length, })); } @@ -479,14 +519,16 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { static _copyDirectories(Map data) { var toBeCopied = data['toBeCopied'] as List; var destination = data['destination'] as String; - for(var dir in toBeCopied) { + for (var dir in toBeCopied) { var source = Directory(dir); var dest = Directory("$destination/${source.name}"); - if(dest.existsSync()) { + if (dest.existsSync()) { // The destination directory already exists, and it is not managed by the app. // Rename the old directory to avoid conflicts. - Log.info("Import Comic", "Directory already exists: ${source.name}\nRenaming the old directory."); - dest.rename(findValidDirectoryName(dest.parent.path, "${dest.path}_old")); + Log.info("Import Comic", + "Directory already exists: ${source.name}\nRenaming the old directory."); + dest.rename( + findValidDirectoryName(dest.parent.path, "${dest.path}_old")); } dest.createSync(); copyDirectory(source, dest); @@ -494,47 +536,49 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { } Future checkSingleComic(Directory directory) async { - if(!(await directory.exists())) return null; + if (!(await directory.exists())) return null; var name = directory.name; bool hasChapters = false; var chapters = []; var coverPath = ''; // relative path to the cover image - await for(var entry in directory.list()) { - if(entry is Directory) { + await for (var entry in directory.list()) { + if (entry is Directory) { hasChapters = true; - if(LocalManager().findByName(entry.name) != null) { + if (LocalManager().findByName(entry.name) != null) { Log.info("Import Comic", "Comic already exists: $name"); return null; } chapters.add(entry.name); - await for(var file in entry.list()) { - if(file is Directory) { - Log.info("Import Comic", "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); + await for (var file in entry.list()) { + if (file is Directory) { + Log.info("Import Comic", + "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); return null; } } - } else if(entry is File){ - if(entry.name.startsWith('cover')) { + } else if (entry is File) { + if (entry.name.startsWith('cover')) { coverPath = entry.name; } const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; - if(!coverPath.startsWith('cover') && imageExtensions.contains(entry.extension)) { + if (!coverPath.startsWith('cover') && + imageExtensions.contains(entry.extension)) { coverPath = entry.name; } } } chapters.sort(); - if(hasChapters && coverPath == '') { + if (hasChapters && coverPath == '') { // use the first image in the first chapter as the cover var firstChapter = Directory('${directory.path}/${chapters.first}'); - await for(var entry in firstChapter.list()) { - if(entry is File) { + await for (var entry in firstChapter.list()) { + if (entry is File) { coverPath = entry.name; break; } } } - if(coverPath == '') { + if (coverPath == '') { Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); return null; } @@ -584,19 +628,20 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: InkWell( - onTap: () { - context.to(() => const ComicSourcePage()); - }, - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - width: 0.6, - ), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + context.to(() => const ComicSourcePage()); + }, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -615,14 +660,15 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), - child: Text(comicSources.length.toString(), style: ts.s12), + child: + Text(comicSources.length.toString(), style: ts.s12), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), - if(comicSources.isNotEmpty) + if (comicSources.isNotEmpty) SizedBox( width: double.infinity, child: Wrap( @@ -633,7 +679,8 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, + color: + Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(e), @@ -661,8 +708,8 @@ class _AccountsWidgetState extends State<_AccountsWidget> { void onComicSourceChange() { setState(() { - for(var c in ComicSource.all()) { - if(c.isLogged) { + for (var c in ComicSource.all()) { + if (c.isLogged) { accounts.add(c.name); } } @@ -672,8 +719,8 @@ class _AccountsWidgetState extends State<_AccountsWidget> { @override void initState() { accounts = []; - for(var c in ComicSource.all()) { - if(c.isLogged) { + for (var c in ComicSource.all()) { + if (c.isLogged) { accounts.add(c.name); } } @@ -690,17 +737,18 @@ class _AccountsWidgetState extends State<_AccountsWidget> { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: InkWell( - onTap: () {}, - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - width: 0.6, - ), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () {}, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 8983619..750b888 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:venera/pages/categories_page.dart'; +import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/translations.dart'; import '../components/components.dart'; @@ -48,6 +49,8 @@ class _MainPageState extends State { const CategoriesPage(), ]; + var index = 0; + @override Widget build(BuildContext context) { return NaviPane( @@ -75,11 +78,14 @@ class _MainPageState extends State { ), ], paneActions: [ - PaneActionEntry( - icon: Icons.search, - label: "Search".tl, - onTap: () {}, - ), + if(index != 0) + PaneActionEntry( + icon: Icons.search, + label: "Search".tl, + onTap: () { + to(() => const SearchPage()); + }, + ), PaneActionEntry( icon: Icons.settings, label: "Settings".tl, @@ -100,6 +106,9 @@ class _MainPageState extends State { ); }, onPageChange: (index) { + setState(() { + this.index = index; + }); _navigatorKey!.currentState?.pushAndRemoveUntil( AppPageRoute( preventRebuild: false, diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart new file mode 100644 index 0000000..3922a27 --- /dev/null +++ b/lib/pages/search_page.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.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/pages/search_result_page.dart'; +import 'package:venera/utils/translations.dart'; + +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + late final SearchBarController controller; + + String searchTarget = ""; + + var options = []; + + void update() { + setState(() {}); + } + + void search([String? text]) { + context.to( + () => SearchResultPage( + text: text ?? controller.text, + sourceKey: searchTarget, + options: options, + ), + ); + } + + @override + void initState() { + var defaultSearchTarget = appdata.settings['defaultSearchTarget']; + if (defaultSearchTarget != null && + ComicSource.find(defaultSearchTarget) != null) { + searchTarget = defaultSearchTarget; + } else { + searchTarget = ComicSource.all().first.key; + } + controller = SearchBarController( + onSearch: search, + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SmoothCustomScrollView( + slivers: [ + SliverSearchBar(controller: controller), + buildSearchTarget(), + buildSearchOptions(), + ], + ), + ); + } + + 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, + title: Text("Search From".tl), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: sources.map((e) { + return OptionChip( + text: e.name.tl, + isSelected: searchTarget == e.key, + onTap: () { + setState(() { + searchTarget = e.key; + }); + }, + ); + }).toList(), + ), + ], + ), + ), + ); + } + + Widget buildSearchOptions() { + var children = []; + + final searchOptions = + ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? + []; + if (searchOptions.length != options.length) { + options = searchOptions.map((e) => e.defaultValue).toList(); + } + if (searchOptions.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox()); + } + for (int i = 0; i < searchOptions.length; i++) { + final option = searchOptions[i]; + children.add(ListTile( + title: Text(option.label.tl), + )); + children.add(Wrap( + runSpacing: 8, + spacing: 8, + children: option.options.entries.map((e) { + return OptionChip( + text: e.value.tl, + isSelected: options[i] == e.key, + onTap: () { + options[i] = e.key; + update(); + }, + ); + }).toList(), + ).paddingHorizontal(16)); + } + + return SliverToBoxAdapter( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text("Search Options".tl), + ), + ...children, + ], + ), + ), + ); + } + + Widget buildSearchHistory() { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text("Search History".tl), + ); + } + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(appdata.searchHistory[index - 1]), + onTap: () { + search(appdata.searchHistory[index - 1]); + }, + ); + }, + childCount: 1 + appdata.searchHistory.length, + ), + ).sliverPaddingHorizontal(16); + } +} diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart new file mode 100644 index 0000000..f26b361 --- /dev/null +++ b/lib/pages/search_result_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; + +class SearchResultPage extends StatefulWidget { + const SearchResultPage({ + super.key, + required this.text, + required this.sourceKey, + required this.options, + }); + + final String text; + + final String sourceKey; + + final List options; + + @override + State createState() => _SearchResultPageState(); +} + +class _SearchResultPageState extends State { + late SearchBarController controller; + + late String sourceKey; + + late List options; + + void search([String? text]) {} + + @override + void initState() { + controller = SearchBarController( + initialText: widget.text, + onSearch: search, + ); + sourceKey = widget.sourceKey; + options = widget.options; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ComicList( + errorLeading: AppSearchBar( + controller: controller, + ), + leadingSliver: SliverSearchBar( + controller: controller, + ), + loadPage: (i) { + var source = ComicSource.find(sourceKey); + return source!.searchPageData!.loadPage!( + controller.initialText, + i, + options, + ); + }, + ); + } +}