diff --git a/lib/components/button.dart b/lib/components/button.dart index cb8396f..5e91242 100644 --- a/lib/components/button.dart +++ b/lib/components/button.dart @@ -342,4 +342,34 @@ class _IconButtonState extends State<_IconButton> { ), ); } -} \ No newline at end of file +} + +class MenuButton extends StatefulWidget { + const MenuButton({super.key, required this.entries}); + + final List entries; + + @override + State createState() => _MenuButtonState(); +} + +class _MenuButtonState extends State { + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'more'.tl, + child: Button.icon( + icon: const Icon(Icons.more_horiz), + onPressed: () { + var renderBox = context.findRenderObject() as RenderBox; + var offset = renderBox.localToGlobal(Offset.zero); + showMenuX( + context, + offset, + widget.entries, + ); + }, + ), + ); + } +} diff --git a/lib/components/comic.dart b/lib/components/comic.dart index f00e08f..dd55eb4 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -53,6 +53,13 @@ class ComicTile extends StatelessWidget { App.rootContext.showMessage(message: 'Title copied'.tl); }, ), + MenuEntry( + icon: Icons.stars_outlined, + text: 'Add to favorites'.tl, + onClick: () { + addFavorite(comic); + }, + ), ...?menuOptions, ], ); diff --git a/lib/components/components.dart b/lib/components/components.dart index e292491..f0596c1 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -23,6 +23,7 @@ import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/state_controller.dart'; import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; diff --git a/lib/components/menu.dart b/lib/components/menu.dart index ed46738..f3c49e3 100644 --- a/lib/components/menu.dart +++ b/lib/components/menu.dart @@ -1,7 +1,7 @@ part of "components.dart"; void showMenuX(BuildContext context, Offset location, List entries) { - Navigator.of(context).push(_MenuRoute(entries, location)); + Navigator.of(context, rootNavigator: true).push(_MenuRoute(entries, location)); } class _MenuRoute extends PopupRoute { diff --git a/lib/components/message.dart b/lib/components/message.dart index d886f72..8c80003 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -1,12 +1,17 @@ part of "components.dart"; -void showToast({required String message, required BuildContext context, Widget? icon, Widget? trailing,}) { +void showToast({ + required String message, + required BuildContext context, + Widget? icon, + Widget? trailing, +}) { var newEntry = OverlayEntry( builder: (context) => _ToastOverlay( - message: message, - icon: icon, - trailing: trailing, - )); + message: message, + icon: icon, + trailing: trailing, + )); var state = context.findAncestorStateOfType(); @@ -36,9 +41,11 @@ class _ToastOverlay extends StatelessWidget { color: Theme.of(context).colorScheme.inverseSurface, borderRadius: BorderRadius.circular(8), elevation: 2, - textStyle: ts.withColor(Theme.of(context).colorScheme.onInverseSurface), + textStyle: + ts.withColor(Theme.of(context).colorScheme.onInverseSurface), child: IconTheme( - data: IconThemeData(color: Theme.of(context).colorScheme.onInverseSurface), + data: IconThemeData( + color: Theme.of(context).colorScheme.onInverseSurface), child: Container( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), child: Row( @@ -121,23 +128,28 @@ void showDialogMessage(BuildContext context, String title, String message) { ); } -void showConfirmDialog(BuildContext context, String title, String content, - void Function() onConfirm) { +void showConfirmDialog({ + required BuildContext context, + required String title, + required String content, + required void Function() onConfirm, +}) { showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(content), - actions: [ - TextButton(onPressed: context.pop, child: Text("Cancel".tl)), - TextButton( - onPressed: () { - context.pop(); - onConfirm(); - }, - child: Text("Confirm".tl)), - ], - )); + context: context, + builder: (context) => ContentDialog( + title: title, + content: Text(content).paddingHorizontal(16).paddingVertical(8), + actions: [ + FilledButton( + onPressed: () { + context.pop(); + onConfirm(); + }, + child: Text("Confirm".tl), + ), + ], + ), + ); } class LoadingDialogController { @@ -234,6 +246,7 @@ class ContentDialog extends StatelessWidget { Widget build(BuildContext context) { var content = Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Appbar( leading: IconButton( @@ -281,3 +294,83 @@ class ContentDialog extends StatelessWidget { ); } } + +void showInputDialog({ + required BuildContext context, + required String title, + required String hintText, + required FutureOr Function(String) onConfirm, + String? initialValue, + String confirmText = "Confirm", + String cancelText = "Cancel", +}) { + var controller = TextEditingController(text: initialValue); + bool isLoading = false; + String? error; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return ContentDialog( + title: title, + content: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + errorText: error, + ), + ).paddingHorizontal(12), + actions: [ + Button.filled( + isLoading: isLoading, + onPressed: () async { + var futureOr = onConfirm(controller.text); + Object? result; + if (futureOr is Future) { + setState(() => isLoading = true); + result = await futureOr; + setState(() => isLoading = false); + } else { + result = futureOr; + } + if(result == null) { + context.pop(); + } else { + setState(() => error = result.toString()); + } + }, + child: Text(confirmText.tl), + ), + ], + ); + }, + ); + }, + ); +} + +void showInfoDialog({ + required BuildContext context, + required String title, + required String content, + String confirmText = "OK", +}) { + showDialog( + context: context, + builder: (context) { + return ContentDialog( + title: title, + content: Text(content).paddingHorizontal(16).paddingVertical(8), + actions: [ + Button.filled( + onPressed: context.pop, + child: Text(confirmText.tl), + ), + ], + ); + }, + ); +} diff --git a/lib/components/navigation_bar.dart b/lib/components/navigation_bar.dart index 9eec4ab..9707776 100644 --- a/lib/components/navigation_bar.dart +++ b/lib/components/navigation_bar.dart @@ -135,7 +135,7 @@ class _NaviPaneState extends State controller.value = target; } else { StateController.findOrNull() - ?.setWithPadding(true, false, false); + ?.setWithPadding(false, false, false); controller.animateTo(target); } animationTarget = target; @@ -555,7 +555,15 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget> class NaviObserver extends NavigatorObserver implements Listenable { var routes = Queue(); - int get pageCount => routes.length; + int get pageCount { + int count = 0; + for (var route in routes) { + if (route is AppPageRoute) { + count++; + } + } + return count; + } @override void didPop(Route route, Route? previousRoute) { @@ -693,7 +701,12 @@ class NaviPaddingWidget extends StatelessWidget { : 0), ) : EdgeInsets.zero, - child: child, + child: MediaQuery.removePadding( + removeTop: controller._withPadding, + removeBottom: controller._withPadding, + context: context, + child: child, + ), ); }, ); diff --git a/lib/components/select.dart b/lib/components/select.dart index 3ac4852..66c746e 100644 --- a/lib/components/select.dart +++ b/lib/components/select.dart @@ -6,14 +6,17 @@ class Select extends StatelessWidget { required this.current, required this.values, this.onTap, + this.minWidth, }); - final String current; + final String? current; final List values; final void Function(int index)? onTap; + final double? minWidth; + @override Widget build(BuildContext context) { return Container( @@ -58,7 +61,12 @@ class Select extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(current, style: ts.s14), + ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth != null ? (minWidth! - 32) : 0, + ), + child: Text(current ?? ' ', style: ts.s14), + ), const SizedBox(width: 8), Icon(Icons.arrow_drop_down, color: context.colorScheme.primary), ], diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index d1d11fa..f5db8d8 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -47,8 +47,9 @@ class _Appdata { } Future init() async { + var dataPath = (await getApplicationSupportDirectory()).path; var file = File(FilePath.join( - (await getApplicationSupportDirectory()).path, + dataPath, 'appdata.json', )); if (!await file.exists()) { @@ -61,6 +62,10 @@ class _Appdata { } } searchHistory = List.from(json['searchHistory']); + var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json')); + if (await implicitDataFile.exists()) { + implicitData = jsonDecode(await implicitDataFile.readAsString()); + } } Map toJson() { @@ -69,6 +74,13 @@ class _Appdata { 'searchHistory': searchHistory, }; } + + var implicitData = {}; + + void writeImplicitData() { + var file = File(FilePath.join(App.dataPath, 'implicitData.json')); + file.writeAsString(jsonEncode(implicitData)); + } } final appdata = _Appdata(); diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 0c08c96..29c5cd7 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -319,9 +319,6 @@ class LocalFavoritesManager { /// delete a folder void deleteFolder(String name) { _modifiedAfterLastCache = true; - _db.execute(""" - delete from folder_sync where folder_name == ?; - """, [name]); _db.execute(""" drop table "$name"; """); diff --git a/lib/pages/categories_page.dart b/lib/pages/categories_page.dart index 5b38345..49d9390 100644 --- a/lib/pages/categories_page.dart +++ b/lib/pages/categories_page.dart @@ -47,7 +47,7 @@ class CategoriesPage extends StatelessWidget { key: Key(e), ); }).toList(), - ), + ).paddingTop(context.padding.top), Expanded( child: TabBarView( children: diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index f82b11b..3dc1a0d 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -8,7 +8,7 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/res.dart'; -import 'package:venera/pages/favorites/favorite_actions.dart'; +import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/utils/translations.dart'; import 'dart:math' as math; @@ -247,7 +247,7 @@ class _ComicPageState extends LoadingState ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: SelectableText(comic.description!), + child: SelectableText(comic.description!).fixWidth(double.infinity), ), const SizedBox(height: 16), const Divider(), diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index a4f965d..5a24e5b 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -49,12 +49,17 @@ class ComicSourcePage extends StatefulWidget { msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n"; } msg = msg.trim(); - showConfirmDialog(App.rootContext, "Updates Available".tl, msg, () { - for (var key in shouldUpdate) { - var source = ComicSource.find(key); - _BodyState.update(source!); - } - }); + showConfirmDialog( + context: App.rootContext, + title: "Updates Available".tl, + content: msg, + onConfirm: () { + for (var key in shouldUpdate) { + var source = ComicSource.find(key); + _BodyState.update(source!); + } + }, + ); } @override @@ -149,10 +154,10 @@ class _BodyState extends State<_Body> { void delete(ComicSource source) { showConfirmDialog( - App.rootContext, - "Delete".tl, - "Are you sure you want to delete it?".tl, - () { + context: App.rootContext, + title: "Delete".tl, + content: "Are you sure you want to delete it?".tl, + onConfirm: () { var file = File(source.filePath); file.delete(); ComicSource.remove(source.key); @@ -243,7 +248,8 @@ class _BodyState extends State<_Body> { .paddingBottom(32), Row( children: [ - TextButton(onPressed: selectFile, child: Text("Select file".tl)) + TextButton( + onPressed: selectFile, child: Text("Select file".tl)) .paddingLeft(8), const Spacer(), TextButton( diff --git a/lib/pages/explore_page.dart b/lib/pages/explore_page.dart index 3618e98..6f58300 100644 --- a/lib/pages/explore_page.dart +++ b/lib/pages/explore_page.dart @@ -64,7 +64,7 @@ class _ExplorePageState extends State tabs: pages.map((e) => buildTab(e)).toList(), controller: controller, ), - ); + ).paddingTop(context.padding.top); return Stack( children: [ diff --git a/lib/pages/favorites/favorite_actions.dart b/lib/pages/favorites/favorite_actions.dart index ccc5859..0182543 100644 --- a/lib/pages/favorites/favorite_actions.dart +++ b/lib/pages/favorites/favorite_actions.dart @@ -1,61 +1,115 @@ -import 'package:flutter/material.dart'; -import 'package:venera/components/components.dart'; -import 'package:venera/foundation/app.dart'; -import 'package:venera/foundation/favorites.dart'; -import 'package:venera/utils/translations.dart'; +part of 'favorites_page.dart'; /// Open a dialog to create a new favorite folder. Future newFolder() async { - return showDialog(context: App.rootContext, builder: (context) { - var controller = TextEditingController(); - var folders = LocalFavoritesManager().folderNames; - String? error; + return showDialog( + context: App.rootContext, + builder: (context) { + var controller = TextEditingController(); + var folders = LocalFavoritesManager().folderNames; + String? error; - return StatefulBuilder(builder: (context, setState) { - return ContentDialog( - title: "New Folder".tl, - content: Column( - children: [ - TextField( - controller: controller, - decoration: InputDecoration( - hintText: "Folder Name".tl, - errorText: error, + return StatefulBuilder(builder: (context, setState) { + return ContentDialog( + title: "New Folder".tl, + content: Column( + children: [ + TextField( + controller: controller, + decoration: InputDecoration( + hintText: "Folder Name".tl, + errorText: error, + ), + onChanged: (s) { + if (error != null) { + setState(() { + error = null; + }); + } + }, + ) + ], + ).paddingHorizontal(16), + actions: [ + FilledButton( + onPressed: () { + var e = validateFolderName(controller.text); + if (e != null) { + setState(() { + error = e; + }); + } else { + LocalFavoritesManager().createFolder(controller.text); + context.pop(); + } + }, + child: Text("Create".tl), ), - onChanged: (s) { - if(error != null) { - setState(() { - error = null; - }); + ], + ); + }); + }); +} + +String? validateFolderName(String newFolderName) { + var folders = LocalFavoritesManager().folderNames; + if (newFolderName.isEmpty) { + return "Folder name cannot be empty".tl; + } else if (newFolderName.length > 50) { + return "Folder name is too long".tl; + } else if (folders.contains(newFolderName)) { + return "Folder already exists".tl; + } + return null; +} + +void addFavorite(Comic comic) { + var folders = LocalFavoritesManager().folderNames; + + showDialog( + context: App.rootContext, + builder: (context) { + String? selectedFolder; + + return StatefulBuilder(builder: (context, setState) { + return ContentDialog( + title: "Select a folder".tl, + content: ListTile( + title: Text("Folder".tl), + trailing: Select( + current: selectedFolder, + values: folders, + minWidth: 112, + onTap: (v) { + setState(() { + selectedFolder = folders[v]; + }); + }, + ), + ), + actions: [ + FilledButton( + onPressed: () { + if (selectedFolder != null) { + LocalFavoritesManager().addComic( + selectedFolder!, + FavoriteItem( + id: comic.id, + name: comic.title, + coverPath: comic.cover, + author: comic.subtitle ?? '', + type: ComicType(comic.sourceKey.hashCode), + tags: comic.tags ?? [], + ), + ); + context.pop(); } }, - ) + child: Text("Confirm".tl), + ), ], - ).paddingHorizontal(16), - actions: [ - FilledButton( - onPressed: () { - if(controller.text.isEmpty) { - setState(() { - error = "Folder name cannot be empty".tl; - }); - } else if(controller.text.length > 50) { - setState(() { - error = "Folder name is too long".tl; - }); - } else if(folders.contains(controller.text)) { - setState(() { - error = "Folder already exists".tl; - }); - } else { - LocalFavoritesManager().createFolder(controller.text); - context.pop(); - } - }, - child: Text("Create".tl), - ), - ], - ); - }); - }); -} \ No newline at end of file + ); + }); + }, + ); +} diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart new file mode 100644 index 0000000..96c2114 --- /dev/null +++ b/lib/pages/favorites/favorites_page.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.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/comic_type.dart'; +import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/res.dart'; +import 'package:venera/utils/translations.dart'; + +part 'favorite_actions.dart'; +part 'side_bar.dart'; +part 'local_favorites_page.dart'; +part 'network_favorites_page.dart'; + +const _kLeftBarWidth = 256.0; + +const _kTwoPanelChangeWidth = 720.0; + +class FavoritesPage extends StatefulWidget { + const FavoritesPage({super.key}); + + @override + State createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends State { + String? folder; + + bool isNetwork = false; + + FolderList? folderList; + + void setFolder(bool isNetwork, String? folder) { + setState(() { + this.isNetwork = isNetwork; + this.folder = folder; + }); + folderList?.update(); + appdata.implicitData['favoriteFolder'] = { + 'name': folder, + 'isNetwork': isNetwork, + }; + appdata.writeImplicitData(); + } + + @override + void initState() { + var data = appdata.implicitData['favoriteFolder']; + if(data != null){ + folder = data['name']; + isNetwork = data['isNetwork'] ?? false; + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return IconTheme( + data: IconThemeData(color: Theme.of(context).colorScheme.secondary), + child: Stack( + children: [ + AnimatedPositioned( + left: context.width <= _kTwoPanelChangeWidth ? -_kLeftBarWidth : 0, + top: 0, + bottom: 0, + duration: const Duration(milliseconds: 200), + child: (const _LeftBar()).fixWidth(_kLeftBarWidth), + ), + Positioned( + top: 0, + left: context.width <= _kTwoPanelChangeWidth ? 0 : _kLeftBarWidth, + right: 0, + bottom: 0, + child: buildBody(), + ), + ], + ), + ); + } + + void showFolderSelector() { + Navigator.of(App.rootContext).push(PageRouteBuilder( + barrierDismissible: true, + fullscreenDialog: true, + opaque: false, + barrierColor: Colors.black.withOpacity(0.36), + pageBuilder: (context, animation, secondary) { + return Align( + alignment: Alignment.centerLeft, + child: Material( + color: context.colorScheme.surfaceContainerLow, + child: SizedBox( + width: 256, + child: _LeftBar( + withAppbar: true, + favPage: this, + onSelected: () { + context.pop(); + }, + ), + ), + ), + ); + }, + transitionsBuilder: (context, animation, secondary, child) { + var offset = + Tween(begin: const Offset(-1, 0), end: const Offset(0, 0)); + return SlideTransition( + position: offset.animate(CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + )), + child: child, + ); + }, + )); + } + + Widget buildBody() { + if (folder == null) { + return CustomScrollView( + slivers: [ + SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: showFolderSelector, + ) + : null, + ), + title: Text("Unselected".tl), + ), + ], + ); + } + if (!isNetwork) { + return _LocalFavoritesPage(folder: folder!, key: Key(folder!)); + } else { + var favoriteData = getFavoriteDataOrNull(folder!); + if (favoriteData == null) { + return const Center(child: Text("Unknown source")); + } else { + return NetworkFavoritePage(favoriteData, key: Key(folder!)); + } + } + } +} + +abstract interface class FolderList { + void update(); + + void updateFolders(); +} \ No newline at end of file diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart new file mode 100644 index 0000000..8c6bcf4 --- /dev/null +++ b/lib/pages/favorites/local_favorites_page.dart @@ -0,0 +1,257 @@ +part of 'favorites_page.dart'; + +class _LocalFavoritesPage extends StatefulWidget { + const _LocalFavoritesPage({required this.folder, super.key}); + + final String folder; + + @override + State<_LocalFavoritesPage> createState() => _LocalFavoritesPageState(); +} + +class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { + late _FavoritesPageState favPage; + + late List comics; + + void updateComics() { + setState(() { + comics = LocalFavoritesManager().getAllComics(widget.folder); + }); + } + + @override + void initState() { + favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; + comics = LocalFavoritesManager().getAllComics(widget.folder); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SmoothCustomScrollView( + slivers: [ + SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: favPage.showFolderSelector, + ) + : const SizedBox(), + ), + title: Text(favPage.folder ?? "Unselected".tl), + actions: [ + MenuButton( + entries: [ + MenuEntry( + icon: Icons.delete_outline, + text: "Delete Folder".tl, + onClick: () { + showConfirmDialog( + context: App.rootContext, + title: "Delete".tl, + content: + "Are you sure you want to delete this folder?".tl, + onConfirm: () { + favPage.setFolder(false, null); + LocalFavoritesManager().deleteFolder(widget.folder); + favPage.folderList?.updateFolders(); + context.pop(); + }, + ); + }), + MenuEntry( + icon: Icons.edit_outlined, + text: "Rename".tl, + onClick: () { + showInputDialog( + context: App.rootContext, + title: "Rename".tl, + hintText: "New Name".tl, + onConfirm: (value) { + var err = validateFolderName(value.toString()); + if (err != null) { + return err; + } + LocalFavoritesManager().rename( + widget.folder, + value.toString(), + ); + favPage.folderList?.updateFolders(); + favPage.setFolder(false, value.toString()); + return null; + }, + ); + }), + MenuEntry( + icon: Icons.reorder, + text: "Reorder".tl, + onClick: () { + context.to( + () { + return _ReorderComicsPage( + widget.folder, + (comics) { + this.comics = comics; + }, + ); + }, + ).then( + (value) { + setState(() {}); + }, + ); + }), + ], + ), + ], + ), + SliverGridComics( + comics: comics.map((e) { + var comicSource = e.type.comicSource; + return Comic( + e.name, + e.coverPath, + e.id, + e.author, + e.tags, + "${e.time} | ${comicSource?.name ?? "Unknown"}", + comicSource?.key ?? "Unknown", + null, + ); + }).toList(), + menuBuilder: (c) { + return [ + MenuEntry( + icon: Icons.delete_outline, + text: "Delete".tl, + onClick: () { + showConfirmDialog( + context: context, + title: "Delete".tl, + content: "Are you sure you want to delete this comic?".tl, + onConfirm: () { + LocalFavoritesManager().deleteComicWithId( + widget.folder, + c.id, + ComicType(c.sourceKey.hashCode), + ); + updateComics(); + }, + ); + }, + ), + ]; + }, + ), + ], + ); + } +} + +class _ReorderComicsPage extends StatefulWidget { + const _ReorderComicsPage(this.name, this.onReorder); + + final String name; + + final void Function(List) onReorder; + + @override + State<_ReorderComicsPage> createState() => _ReorderComicsPageState(); +} + +class _ReorderComicsPageState extends State<_ReorderComicsPage> { + final _key = GlobalKey(); + var reorderWidgetKey = UniqueKey(); + final _scrollController = ScrollController(); + late var comics = LocalFavoritesManager().getAllComics(widget.name); + bool changed = false; + + Color lightenColor(Color color, double lightenValue) { + int red = (color.red + ((255 - color.red) * lightenValue)).round(); + int green = (color.green + ((255 - color.green) * lightenValue)).round(); + int blue = (color.blue + ((255 - color.blue) * lightenValue)).round(); + + return Color.fromARGB(color.alpha, red, green, blue); + } + + @override + void dispose() { + if (changed) { + LocalFavoritesManager().reorder(comics, widget.name); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var tiles = comics.map( + (e) { + var comicSource = e.type.comicSource; + return ComicTile( + key: Key(e.hashCode.toString()), + comic: Comic( + e.name, + e.coverPath, + e.id, + e.author, + e.tags, + "${e.time} | ${comicSource?.name ?? "Unknown"}", + comicSource?.key ?? "Unknown", + null, + ), + ); + }, + ).toList(); + return Scaffold( + appBar: Appbar( + title: Text("Reorder".tl), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () { + showInfoDialog( + context: context, + title: "Reorder".tl, + content: "Long press and drag to reorder.".tl, + ); + }, + ), + ], + ), + body: ReorderableBuilder( + key: reorderWidgetKey, + scrollController: _scrollController, + longPressDelay: App.isDesktop + ? const Duration(milliseconds: 100) + : const Duration(milliseconds: 500), + onReorder: (reorderFunc) { + changed = true; + setState(() { + comics = reorderFunc(comics) as List; + }); + widget.onReorder(comics); + }, + dragChildBoxDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: lightenColor( + Theme.of(context).splashColor.withOpacity(1), + 0.2, + ), + ), + builder: (children) { + return GridView( + key: _key, + controller: _scrollController, + gridDelegate: SliverGridDelegateWithComics(), + children: children, + ); + }, + children: tiles, + ), + ); + } +} diff --git a/lib/pages/favorites/network_favorites_page.dart b/lib/pages/favorites/network_favorites_page.dart new file mode 100644 index 0000000..3ca3975 --- /dev/null +++ b/lib/pages/favorites/network_favorites_page.dart @@ -0,0 +1,433 @@ +part of 'favorites_page.dart'; + +class NetworkFavoritePage extends StatelessWidget { + const NetworkFavoritePage(this.data, {super.key}); + + final FavoriteData data; + + @override + Widget build(BuildContext context) { + return data.multiFolder + ? _MultiFolderFavoritesPage(data) + : _NormalFavoritePage(data); + } +} + +class _NormalFavoritePage extends StatelessWidget { + const _NormalFavoritePage(this.data); + + final FavoriteData data; + + @override + Widget build(BuildContext context) { + return ComicList( + leadingSliver: SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: context + .findAncestorStateOfType<_FavoritesPageState>()! + .showFolderSelector, + ) + : null, + ), + title: Text(data.title), + ), + errorLeading: Appbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: context + .findAncestorStateOfType<_FavoritesPageState>()! + .showFolderSelector, + ) + : null, + ), + title: Text(data.title), + ), + loadPage: (i) => data.loadComic(i), + ); + } +} + +class _MultiFolderFavoritesPage extends StatefulWidget { + const _MultiFolderFavoritesPage(this.data); + + final FavoriteData data; + + @override + State<_MultiFolderFavoritesPage> createState() => + _MultiFolderFavoritesPageState(); +} + +class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> { + bool _loading = true; + + String? _errorMessage; + + Map? folders; + + void loadPage() async { + var res = await widget.data.loadFolders!(); + _loading = false; + if (res.error) { + setState(() { + _errorMessage = res.errorMessage; + }); + } else { + setState(() { + folders = res.data; + }); + } + } + + void openFolder(String key, String title) { + context.to(() => _FavoriteFolder(widget.data, key, title)); + } + + @override + Widget build(BuildContext context) { + var sliverAppBar = SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: context + .findAncestorStateOfType<_FavoritesPageState>()! + .showFolderSelector, + ) + : null, + ), + title: Text(widget.data.title), + ); + + var appBar = Appbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: context + .findAncestorStateOfType<_FavoritesPageState>()! + .showFolderSelector, + ) + : null, + ), + title: Text(widget.data.title), + ); + + if (_loading) { + loadPage(); + return Column( + children: [ + appBar, + const Expanded( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ], + ); + } else if (_errorMessage != null) { + return Column( + children: [ + appBar, + Expanded( + child: NetworkError( + message: _errorMessage!, + withAppbar: false, + retry: () { + setState(() { + _loading = true; + _errorMessage = null; + }); + }, + ), + ) + ], + ); + } else { + var length = folders!.length; + if (widget.data.allFavoritesId != null) length++; + final keys = folders!.keys.toList(); + + return SmoothCustomScrollView( + slivers: [ + sliverAppBar, + SliverGridViewWithFixedItemHeight( + delegate: + SliverChildBuilderDelegate(childCount: length, (context, i) { + if (widget.data.allFavoritesId != null) { + if (i == 0) { + return _FolderTile( + name: "All".tl, + onTap: () => + openFolder(widget.data.allFavoritesId!, "All".tl)); + } else { + i--; + return _FolderTile( + name: folders![keys[i]]!, + onTap: () => openFolder(keys[i], folders![keys[i]]!), + deleteFolder: widget.data.deleteFolder == null + ? null + : () => widget.data.deleteFolder!(keys[i]), + updateState: () => setState(() { + _loading = true; + }), + ); + } + } else { + return _FolderTile( + name: folders![keys[i]]!, + onTap: () => openFolder(keys[i], folders![keys[i]]!), + deleteFolder: widget.data.deleteFolder == null + ? null + : () => widget.data.deleteFolder!(keys[i]), + updateState: () => setState(() { + _loading = true; + }), + ); + } + }), + maxCrossAxisExtent: 450, + itemHeight: 64, + ), + if (widget.data.addFolder != null) + SliverToBoxAdapter( + child: SizedBox( + height: 60, + width: double.infinity, + child: Center( + child: TextButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Create a folder".tl), + const Icon( + Icons.add, + size: 18, + ), + ], + ), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return _CreateFolderDialog( + widget.data, + () => setState(() { + _loading = true; + })); + }); + }, + ), + ), + ), + ) + ], + ); + } + } +} + +class _FolderTile extends StatelessWidget { + const _FolderTile( + {required this.name, + required this.onTap, + this.deleteFolder, + this.updateState}); + + final String name; + + final Future> Function()? deleteFolder; + + final void Function()? updateState; + + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return Material( + child: InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: Row( + children: [ + const SizedBox( + width: 16, + ), + Icon( + Icons.folder, + size: 35, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox( + width: 16, + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + name, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ), + if (deleteFolder != null) + IconButton( + icon: const Icon(Icons.delete_forever_outlined), + onPressed: () => onDeleteFolder(context), + ) + else + const Icon(Icons.arrow_right), + if (deleteFolder == null) + const SizedBox( + width: 8, + ) + ], + ), + ), + ), + ); + } + + void onDeleteFolder(BuildContext context) { + showDialog( + context: context, + builder: (context) { + bool loading = false; + return StatefulBuilder(builder: (context, setState) { + return ContentDialog( + title: "Delete".tl, + content: Text("Are you sure you want to delete this folder?".tl), + actions: [ + Button.filled( + isLoading: loading, + color: context.colorScheme.error, + onPressed: () async { + setState(() { + loading = true; + }); + var res = await deleteFolder!(); + if (res.success) { + context.showMessage(message: "Deleted".tl); + context.pop(); + updateState?.call(); + } else { + setState(() { + loading = false; + }); + context.showMessage(message: res.errorMessage!); + } + }, + child: Text("Confirm".tl), + ), + ], + ); + }); + }, + ); + } +} + +class _CreateFolderDialog extends StatefulWidget { + const _CreateFolderDialog(this.data, this.updateState); + + final FavoriteData data; + + final void Function() updateState; + + @override + State<_CreateFolderDialog> createState() => _CreateFolderDialogState(); +} + +class _CreateFolderDialogState extends State<_CreateFolderDialog> { + var controller = TextEditingController(); + bool loading = false; + + @override + Widget build(BuildContext context) { + return SimpleDialog( + title: Text("Create a folder".tl), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextField( + controller: controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: "name".tl, + ), + ), + ), + const SizedBox( + width: 200, + height: 10, + ), + if (loading) + const SizedBox( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else + SizedBox( + height: 35, + child: Center( + child: TextButton( + onPressed: () { + setState(() { + loading = true; + }); + widget.data.addFolder!(controller.text).then((b) { + if (b.error) { + context.showMessage(message: b.errorMessage!); + setState(() { + loading = false; + }); + } else { + context.pop(); + context.showMessage( + message: "Created successfully".tl); + widget.updateState(); + } + }); + }, + child: Text("Submit".tl)), + )) + ], + ); + } +} + +class _FavoriteFolder extends StatelessWidget { + const _FavoriteFolder(this.data, this.folderID, this.title); + + final FavoriteData data; + + final String folderID; + + final String title; + + @override + Widget build(BuildContext context) { + return ComicList( + leadingSliver: SliverAppbar( + title: Text(title), + ), + loadPage: (i) => data.loadComic(i, folderID), + ); + } +} diff --git a/lib/pages/favorites/side_bar.dart b/lib/pages/favorites/side_bar.dart new file mode 100644 index 0000000..4cede62 --- /dev/null +++ b/lib/pages/favorites/side_bar.dart @@ -0,0 +1,208 @@ +part of 'favorites_page.dart'; + +class _LeftBar extends StatefulWidget { + const _LeftBar({this.favPage, this.onSelected, this.withAppbar = false}); + + final _FavoritesPageState? favPage; + + final VoidCallback? onSelected; + + final bool withAppbar; + + @override + State<_LeftBar> createState() => _LeftBarState(); +} + +class _LeftBarState extends State<_LeftBar> implements FolderList { + late _FavoritesPageState favPage; + + var folders = []; + + var networkFolders = []; + + @override + void initState() { + favPage = widget.favPage ?? + context.findAncestorStateOfType<_FavoritesPageState>()!; + favPage.folderList = this; + folders = LocalFavoritesManager().folderNames; + networkFolders = ComicSource.all() + .where((e) => e.favoriteData != null) + .map((e) => e.favoriteData!.key) + .toList(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Column( + children: [ + if (widget.withAppbar) + SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 8), + const CloseButton(), + const SizedBox(width: 8), + Text("Folders".tl, style: ts.s18,), + ], + ), + ).paddingTop(context.padding.top), + Expanded( + child: ListView.builder( + padding: widget.withAppbar ? EdgeInsets.zero : EdgeInsets.only(top: context.padding.top), + itemCount: folders.length + networkFolders.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox(width: 16), + const Icon(Icons.local_activity), + const SizedBox(width: 8), + Text("Local".tl), + const Spacer(), + IconButton( + icon: const Icon(Icons.add), + color: context.colorScheme.primary, + onPressed: () { + newFolder().then((value) { + setState(() { + folders = LocalFavoritesManager().folderNames; + }); + }); + }, + ), + const SizedBox(width: 16), + ], + ), + ); + } + index--; + if (index < folders.length) { + return buildLocalFolder(folders[index]); + } + index -= folders.length; + if (index == 0) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox(width: 16), + const Icon(Icons.cloud), + const SizedBox(width: 8), + Text("Network".tl), + ], + ), + ); + } + index--; + return buildNetworkFolder(networkFolders[index]); + }, + ), + ) + ], + ), + ); + } + + Widget buildLocalFolder(String name) { + bool isSelected = name == favPage.folder && !favPage.isNetwork; + return InkWell( + onTap: () { + if (isSelected) { + return; + } + favPage.setFolder(false, name); + widget.onSelected?.call(); + }, + child: Container( + height: 42, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: isSelected + ? context.colorScheme.primaryContainer.withOpacity(0.36) + : null, + border: Border( + left: BorderSide( + color: + isSelected ? context.colorScheme.primary : Colors.transparent, + width: 2, + ), + ), + ), + padding: const EdgeInsets.only(left: 16), + child: Text(name), + ), + ); + } + + Widget buildNetworkFolder(String key) { + var data = getFavoriteDataOrNull(key); + if (data == null) { + return const SizedBox(); + } + bool isSelected = key == favPage.folder && favPage.isNetwork; + return InkWell( + onTap: () { + if (isSelected) { + return; + } + favPage.setFolder(true, key); + widget.onSelected?.call(); + }, + child: Container( + height: 42, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: isSelected + ? context.colorScheme.primaryContainer.withOpacity(0.36) + : null, + border: Border( + left: BorderSide( + color: + isSelected ? context.colorScheme.primary : Colors.transparent, + width: 2, + ), + ), + ), + padding: const EdgeInsets.only(left: 16), + child: Text(data.title), + ), + ); + } + + @override + void update() { + setState(() {}); + } + + @override + void updateFolders() { + setState(() { + folders = LocalFavoritesManager().folderNames; + networkFolders = ComicSource.all() + .where((e) => e.favoriteData != null) + .map((e) => e.favoriteData!.key) + .toList(); + }); + } +} diff --git a/lib/pages/favorites_page.dart b/lib/pages/favorites_page.dart deleted file mode 100644 index 13c7875..0000000 --- a/lib/pages/favorites_page.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class FavoritesPage extends StatelessWidget { - const FavoritesPage({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 896bf65..32b6ce5 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -22,13 +22,15 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - var widget = const SmoothCustomScrollView( + var widget = SmoothCustomScrollView( slivers: [ - _SearchBar(), - _History(), - _Local(), - _ComicSourceWidget(), - _AccountsWidget(), + SliverPadding(padding: EdgeInsets.only(top: context.padding.top)), + const _SearchBar(), + const _History(), + const _Local(), + const _ComicSourceWidget(), + const _AccountsWidget(), + SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), ], ); return context.width > changePoint ? widget.paddingHorizontal(8) : widget; diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index f2f50e9..37679b1 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -8,7 +8,7 @@ import '../components/components.dart'; import '../foundation/app.dart'; import '../foundation/app_page_route.dart'; import 'explore_page.dart'; -import 'favorites_page.dart'; +import 'favorites/favorites_page.dart'; import 'home_page.dart'; class MainPage extends StatefulWidget { diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 3dbbc5b..cf74168 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart';