favorites page

This commit is contained in:
nyne
2024-10-12 20:38:24 +08:00
parent a26e5e20de
commit 5a3537657a
22 changed files with 1388 additions and 120 deletions

View File

@@ -343,3 +343,33 @@ class _IconButtonState extends State<_IconButton> {
);
}
}
class MenuButton extends StatefulWidget {
const MenuButton({super.key, required this.entries});
final List<MenuEntry> entries;
@override
State<MenuButton> createState() => _MenuButtonState();
}
class _MenuButtonState extends State<MenuButton> {
@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,
);
},
),
);
}
}

View File

@@ -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,
],
);

View File

@@ -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';

View File

@@ -1,7 +1,7 @@
part of "components.dart";
void showMenuX(BuildContext context, Offset location, List<MenuEntry> entries) {
Navigator.of(context).push(_MenuRoute(entries, location));
Navigator.of(context, rootNavigator: true).push(_MenuRoute(entries, location));
}
class _MenuRoute<T> extends PopupRoute<T> {

View File

@@ -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<OverlayWidgetState>();
@@ -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<Object?> 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),
),
],
);
},
);
}

View File

@@ -135,7 +135,7 @@ class _NaviPaneState extends State<NaviPane>
controller.value = target;
} else {
StateController.findOrNull<NaviPaddingWidgetController>()
?.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<Route>();
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,
),
);
},
);

View File

@@ -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<String> 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),
],

View File

@@ -47,8 +47,9 @@ class _Appdata {
}
Future<void> 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<String, dynamic> toJson() {
@@ -69,6 +74,13 @@ class _Appdata {
'searchHistory': searchHistory,
};
}
var implicitData = <String, dynamic>{};
void writeImplicitData() {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
file.writeAsString(jsonEncode(implicitData));
}
}
final appdata = _Appdata();

View File

@@ -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";
""");

View File

@@ -47,7 +47,7 @@ class CategoriesPage extends StatelessWidget {
key: Key(e),
);
}).toList(),
),
).paddingTop(context.padding.top),
Expanded(
child: TabBarView(
children:

View File

@@ -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<ComicPage, ComicDetails>
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!),
child: SelectableText(comic.description!).fixWidth(double.infinity),
),
const SizedBox(height: 16),
const Divider(),

View File

@@ -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(

View File

@@ -64,7 +64,7 @@ class _ExplorePageState extends State<ExplorePage>
tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller,
),
);
).paddingTop(context.padding.top);
return Stack(
children: [

View File

@@ -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<void> 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),
),
],
);
});
});
);
});
},
);
}

View File

@@ -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<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
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<Offset>(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();
}

View File

@@ -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<FavoriteItem> 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<FavoriteItem>) 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<FavoriteItem>;
});
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,
),
);
}
}

View File

@@ -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<String, String>? 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<Res<bool>> 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),
);
}
}

View File

@@ -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 = <String>[];
var networkFolders = <String>[];
@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();
});
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';