mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
initial commit
This commit is contained in:
623
lib/components/appbar.dart
Normal file
623
lib/components/appbar.dart
Normal file
@@ -0,0 +1,623 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const Appbar(
|
||||
{required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
super.key});
|
||||
|
||||
final Widget title;
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
State<Appbar> createState() => _AppbarState();
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(56);
|
||||
}
|
||||
|
||||
class _AppbarState extends State<Appbar> {
|
||||
ScrollNotificationObserverState? _scrollNotificationObserver;
|
||||
bool _scrolledUnder = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scrollNotificationObserver?.removeListener(_handleScrollNotification);
|
||||
_scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context);
|
||||
_scrollNotificationObserver?.addListener(_handleScrollNotification);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scrollNotificationObserver != null) {
|
||||
_scrollNotificationObserver!.removeListener(_handleScrollNotification);
|
||||
_scrollNotificationObserver = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
defaultScrollNotificationPredicate(notification)) {
|
||||
final bool oldScrolledUnder = _scrolledUnder;
|
||||
final ScrollMetrics metrics = notification.metrics;
|
||||
switch (metrics.axisDirection) {
|
||||
case AxisDirection.up:
|
||||
// Scroll view is reversed
|
||||
_scrolledUnder = metrics.extentAfter > 0;
|
||||
case AxisDirection.down:
|
||||
_scrolledUnder = metrics.extentBefore > 0;
|
||||
case AxisDirection.right:
|
||||
case AxisDirection.left:
|
||||
// Scrolled under is only supported in the vertical axis, and should
|
||||
// not be altered based on horizontal notifications of the same
|
||||
// predicate since it could be a 2D scroller.
|
||||
break;
|
||||
}
|
||||
|
||||
if (_scrolledUnder != oldScrolledUnder) {
|
||||
setState(() {
|
||||
// React to a change in MaterialState.scrolledUnder
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = SizedBox(
|
||||
height: _kAppBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
widget.leading ??
|
||||
Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: widget.title,
|
||||
),
|
||||
),
|
||||
...?widget.actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
if (widget.backgroundColor != Colors.transparent) {
|
||||
return Material(
|
||||
elevation: (_scrolledUnder && context.width < changePoint) ? 1 : 0,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
color: widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverAppbar extends StatelessWidget {
|
||||
const SliverAppbar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.color,
|
||||
this.radius = 0,
|
||||
});
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
final Widget title;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _MySliverAppBarDelegate(
|
||||
leading: leading,
|
||||
title: title,
|
||||
actions: actions,
|
||||
topPadding: MediaQuery.of(context).padding.top,
|
||||
color: color,
|
||||
radius: radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _kAppBarHeight = 58.0;
|
||||
|
||||
class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final Widget? leading;
|
||||
|
||||
final Widget title;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final double radius;
|
||||
|
||||
_MySliverAppBarDelegate(
|
||||
{this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.color,
|
||||
required this.topPadding,
|
||||
this.radius = 0});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
color: color,
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "返回".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxExtent => _kAppBarHeight + topPadding;
|
||||
|
||||
@override
|
||||
double get minExtent => _kAppBarHeight + topPadding;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
|
||||
return oldDelegate is! _MySliverAppBarDelegate ||
|
||||
leading != oldDelegate.leading ||
|
||||
title != oldDelegate.title ||
|
||||
actions != oldDelegate.actions;
|
||||
}
|
||||
}
|
||||
|
||||
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<FloatingSearchBar> createState() => _FloatingSearchBarState();
|
||||
}
|
||||
|
||||
class _FloatingSearchBarState extends State<FloatingSearchBar> {
|
||||
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});
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
final List<Tab> tabs;
|
||||
|
||||
@override
|
||||
State<FilledTabBar> createState() => _FilledTabBarState();
|
||||
}
|
||||
|
||||
class _FilledTabBarState extends State<FilledTabBar> {
|
||||
late TabController _controller;
|
||||
|
||||
late List<GlobalKey> keys;
|
||||
|
||||
static const _kTabHeight = 48.0;
|
||||
|
||||
static const tabPadding = EdgeInsets.symmetric(horizontal: 6, vertical: 6);
|
||||
|
||||
static const tabRadius = 12.0;
|
||||
|
||||
_IndicatorPainter? painter;
|
||||
|
||||
var scrollController = ScrollController();
|
||||
|
||||
var tabBarKey = GlobalKey();
|
||||
|
||||
var offsets = <double>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
keys = widget.tabs.map((e) => GlobalKey()).toList();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
initPainter();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilledTabBar oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
initPainter();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
void initPainter() {
|
||||
var old = painter;
|
||||
painter = _IndicatorPainter(
|
||||
controller: _controller,
|
||||
color: context.colorScheme.primary,
|
||||
padding: tabPadding,
|
||||
radius: tabRadius,
|
||||
);
|
||||
if (old != null) {
|
||||
painter!.update(old.offsets!, old.itemHeight!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: buildTabBar,
|
||||
);
|
||||
}
|
||||
|
||||
void _tabLayoutCallback(List<double> offsets, double itemHeight) {
|
||||
painter!.update(offsets, itemHeight);
|
||||
this.offsets = offsets;
|
||||
}
|
||||
|
||||
Widget buildTabBar(BuildContext context, Widget? _) {
|
||||
var child = SmoothScrollProvider(
|
||||
controller: scrollController,
|
||||
builder: (context, controller, physics) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
child: CustomPaint(
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
callback: _tabLayoutCallback,
|
||||
children: List.generate(widget.tabs.length, buildTab),
|
||||
),
|
||||
).paddingHorizontal(4),
|
||||
);
|
||||
},
|
||||
);
|
||||
return Container(
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
|
||||
void onTabChanged() {
|
||||
final int i = _controller.index;
|
||||
if (i == previousIndex) {
|
||||
return;
|
||||
}
|
||||
updateScrollOffset(i);
|
||||
previousIndex = i;
|
||||
}
|
||||
|
||||
void updateScrollOffset(int i) {
|
||||
// try to scroll to center the tab
|
||||
final RenderBox tabBarBox =
|
||||
tabBarKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final double tabLeft = offsets[i];
|
||||
final double tabRight = offsets[i + 1];
|
||||
final double tabWidth = tabRight - tabLeft;
|
||||
final double tabCenter = tabLeft + tabWidth / 2;
|
||||
final double tabBarWidth = tabBarBox.size.width;
|
||||
final double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
if (scrollOffset == scrollController.offset) {
|
||||
return;
|
||||
}
|
||||
scrollController.animateTo(
|
||||
scrollOffset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void onTabClicked(int i) {
|
||||
_controller.animateTo(i);
|
||||
}
|
||||
|
||||
Widget buildTab(int i) {
|
||||
return InkWell(
|
||||
onTap: () => onTabClicked(i),
|
||||
borderRadius: BorderRadius.circular(tabRadius),
|
||||
child: KeyedSubtree(
|
||||
key: keys[i],
|
||||
child: Padding(
|
||||
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,
|
||||
),
|
||||
child: widget.tabs[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(tabPadding);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _TabRenderCallback = void Function(
|
||||
List<double> offsets,
|
||||
double itemHeight,
|
||||
);
|
||||
|
||||
class _TabRow extends Row {
|
||||
const _TabRow({required this.callback, required super.children});
|
||||
|
||||
final _TabRenderCallback callback;
|
||||
|
||||
@override
|
||||
RenderFlex createRenderObject(BuildContext context) {
|
||||
return _RenderTabFlex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
textDirection: Directionality.of(context),
|
||||
verticalDirection: VerticalDirection.down,
|
||||
callback: callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderTabFlex renderObject) {
|
||||
super.updateRenderObject(context, renderObject);
|
||||
renderObject.callback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderTabFlex extends RenderFlex {
|
||||
_RenderTabFlex({
|
||||
required super.direction,
|
||||
required super.mainAxisSize,
|
||||
required super.mainAxisAlignment,
|
||||
required super.crossAxisAlignment,
|
||||
required TextDirection super.textDirection,
|
||||
required super.verticalDirection,
|
||||
required this.callback,
|
||||
});
|
||||
|
||||
_TabRenderCallback callback;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
RenderBox? child = firstChild;
|
||||
final List<double> xOffsets = <double>[];
|
||||
while (child != null) {
|
||||
final FlexParentData childParentData =
|
||||
child.parentData! as FlexParentData;
|
||||
xOffsets.add(childParentData.offset.dx);
|
||||
assert(child.parentData == childParentData);
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
xOffsets.add(size.width);
|
||||
callback(xOffsets, firstChild!.size.height);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorPainter extends CustomPainter {
|
||||
_IndicatorPainter({
|
||||
required this.controller,
|
||||
required this.color,
|
||||
required this.padding,
|
||||
this.radius = 4.0,
|
||||
}) : super(repaint: controller.animation);
|
||||
|
||||
final TabController controller;
|
||||
final Color color;
|
||||
final EdgeInsets padding;
|
||||
final double radius;
|
||||
|
||||
List<double>? offsets;
|
||||
double? itemHeight;
|
||||
Rect? _currentRect;
|
||||
|
||||
void update(List<double> offsets, double itemHeight) {
|
||||
this.offsets = offsets;
|
||||
this.itemHeight = itemHeight;
|
||||
}
|
||||
|
||||
int get maxTabIndex => offsets!.length - 2;
|
||||
|
||||
Rect indicatorRect(Size tabBarSize, int tabIndex) {
|
||||
assert(offsets != null);
|
||||
assert(offsets!.isNotEmpty);
|
||||
assert(tabIndex >= 0);
|
||||
assert(tabIndex <= maxTabIndex);
|
||||
var (tabLeft, tabRight) = (offsets![tabIndex], offsets![tabIndex + 1]);
|
||||
|
||||
const horizontalPadding = 12.0;
|
||||
|
||||
var rect = Rect.fromLTWH(
|
||||
tabLeft + padding.left + horizontalPadding,
|
||||
_FilledTabBarState._kTabHeight - 3.6,
|
||||
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
|
||||
3,
|
||||
);
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (offsets == null || itemHeight == null) {
|
||||
return;
|
||||
}
|
||||
final double index = controller.index.toDouble();
|
||||
final double value = controller.animation!.value;
|
||||
final bool ltr = index > value;
|
||||
final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex);
|
||||
final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex);
|
||||
final Rect fromRect = indicatorRect(size, from);
|
||||
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));
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
309
lib/components/button.dart
Normal file
309
lib/components/button.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class HoverBox extends StatefulWidget {
|
||||
const HoverBox(
|
||||
{super.key, required this.child, this.borderRadius = BorderRadius.zero});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
State<HoverBox> createState() => _HoverBoxState();
|
||||
}
|
||||
|
||||
class _HoverBoxState extends State<HoverBox> {
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: isHover
|
||||
? Theme.of(context).colorScheme.surfaceContainerLow
|
||||
: null,
|
||||
borderRadius: widget.borderRadius),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ButtonType { filled, outlined, text, normal }
|
||||
|
||||
class Button extends StatefulWidget {
|
||||
const Button(
|
||||
{super.key,
|
||||
required this.type,
|
||||
required this.child,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
required this.onPressed});
|
||||
|
||||
const Button.filled(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.filled;
|
||||
|
||||
const Button.outlined(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.outlined;
|
||||
|
||||
const Button.text(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.text;
|
||||
|
||||
const Button.normal(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.normal;
|
||||
|
||||
static Widget icon(
|
||||
{Key? key,
|
||||
required Widget icon,
|
||||
required VoidCallback onPressed,
|
||||
double? size,
|
||||
Color? color,
|
||||
String? tooltip}) {
|
||||
return _IconButton(
|
||||
key: key,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
size: size,
|
||||
color: color,
|
||||
tooltip: tooltip,
|
||||
);
|
||||
}
|
||||
|
||||
final ButtonType type;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
final void Function(Offset location)? onPressedAt;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<Button> createState() => _ButtonState();
|
||||
}
|
||||
|
||||
class _ButtonState extends State<Button> {
|
||||
bool isHover = false;
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Button oldWidget) {
|
||||
if (oldWidget.isLoading != widget.isLoading) {
|
||||
setState(() => isLoading = widget.isLoading);
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var padding = widget.padding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
var width = widget.width;
|
||||
if (width != null) {
|
||||
width = width - padding.horizontal;
|
||||
}
|
||||
var height = widget.height;
|
||||
if (height != null) {
|
||||
height = height - padding.vertical;
|
||||
}
|
||||
Widget child = DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(
|
||||
color: widget.type == ButtonType.filled
|
||||
? context.colorScheme.inversePrimary
|
||||
: context.colorScheme.primary,
|
||||
strokeWidth: 1.8,
|
||||
).fixWidth(16).fixHeight(16)
|
||||
: widget.child,
|
||||
);
|
||||
if (width != null || height != null) {
|
||||
child = child.toCenter();
|
||||
}
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (isLoading) return;
|
||||
widget.onPressed();
|
||||
if (widget.onPressedAt != null) {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
widget.onPressedAt!(offset);
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: (isHover && !isLoading && widget.type == ButtonType.filled)
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
border: widget.type == ButtonType.outlined
|
||||
? Border.all(
|
||||
color: widget.color ??
|
||||
Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color get buttonColor {
|
||||
if (widget.type == ButtonType.filled) {
|
||||
var color = widget.color ?? context.colorScheme.primary;
|
||||
if (isHover) {
|
||||
return color.withOpacity(0.9);
|
||||
} else {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
if (isHover) {
|
||||
return context.colorScheme.outline.withOpacity(0.2);
|
||||
}
|
||||
return Colors.transparent;
|
||||
}
|
||||
|
||||
Color get textColor {
|
||||
if (widget.type == ButtonType.outlined) {
|
||||
return widget.color ?? context.colorScheme.onSurface;
|
||||
}
|
||||
return widget.type == ButtonType.filled
|
||||
? context.colorScheme.onPrimary
|
||||
: (widget.type == ButtonType.text
|
||||
? widget.color ?? context.colorScheme.primary
|
||||
: context.colorScheme.onSurface);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatefulWidget {
|
||||
const _IconButton(
|
||||
{super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.size,
|
||||
this.color,
|
||||
this.tooltip});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
final double? size;
|
||||
|
||||
final String? tooltip;
|
||||
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<_IconButton> createState() => _IconButtonState();
|
||||
}
|
||||
|
||||
class _IconButtonState extends State<_IconButton> {
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: widget.onPressed,
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Tooltip(
|
||||
message: widget.tooltip ?? "",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isHover ? Theme.of(context).colorScheme.surfaceContainer : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
size: widget.size ?? 24,
|
||||
color: widget.color ?? context.colorScheme.primary),
|
||||
child: widget.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
34
lib/components/components.dart
Normal file
34
lib/components/components.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
library components;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/app_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
part 'image.dart';
|
||||
part 'appbar.dart';
|
||||
part 'button.dart';
|
||||
part 'consts.dart';
|
||||
part 'flyout.dart';
|
||||
part 'layout.dart';
|
||||
part 'loading.dart';
|
||||
part 'menu.dart';
|
||||
part 'message.dart';
|
||||
part 'navigation_bar.dart';
|
||||
part 'pop_up_widget.dart';
|
||||
part 'scroll.dart';
|
||||
part 'select.dart';
|
||||
part 'side_bar.dart';
|
3
lib/components/consts.dart
Normal file
3
lib/components/consts.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
part of 'components.dart';
|
||||
|
||||
const _fastAnimationDuration = Duration(milliseconds: 160);
|
222
lib/components/custom_slider.dart
Normal file
222
lib/components/custom_slider.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// copied from flutter source
|
||||
class _SliderDefaultsM3 extends SliderThemeData {
|
||||
_SliderDefaultsM3(this.context)
|
||||
: super(trackHeight: 4.0);
|
||||
|
||||
final BuildContext context;
|
||||
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
||||
|
||||
@override
|
||||
Color? get activeTrackColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
|
||||
|
||||
@override
|
||||
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get thumbColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface);
|
||||
|
||||
@override
|
||||
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return _colors.primary.withOpacity(0.08);
|
||||
}
|
||||
if (states.contains(WidgetState.focused)) {
|
||||
return _colors.primary.withOpacity(0.12);
|
||||
}
|
||||
if (states.contains(WidgetState.dragged)) {
|
||||
return _colors.primary.withOpacity(0.12);
|
||||
}
|
||||
|
||||
return Colors.transparent;
|
||||
});
|
||||
|
||||
@override
|
||||
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
|
||||
color: _colors.onPrimary,
|
||||
);
|
||||
|
||||
@override
|
||||
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
|
||||
}
|
||||
|
||||
class CustomSlider extends StatefulWidget {
|
||||
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, this.reversed = false, super.key});
|
||||
|
||||
final double min;
|
||||
|
||||
final double max;
|
||||
|
||||
final double value;
|
||||
|
||||
final int divisions;
|
||||
|
||||
final void Function(double) onChanged;
|
||||
|
||||
final bool reversed;
|
||||
|
||||
@override
|
||||
State<CustomSlider> createState() => _CustomSliderState();
|
||||
}
|
||||
|
||||
class _CustomSliderState extends State<CustomSlider> {
|
||||
late double value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
value = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomSlider oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
setState(() {
|
||||
value = widget.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final theme = _SliderDefaultsM3(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constrains) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapDown: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(widget.reversed){
|
||||
dx = constrains.maxWidth - dx;
|
||||
}
|
||||
var gap = constrains.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
onVerticalDragUpdate: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(dx > constrains.maxWidth || dx < 0) return;
|
||||
if(widget.reversed){
|
||||
dx = constrains.maxWidth - dx;
|
||||
}
|
||||
var gap = constrains.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.inactiveTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if(constrains.maxWidth / widget.divisions > 10)
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: (){
|
||||
var res = <Widget>[];
|
||||
for(int i = 0; i<widget.divisions-1; i++){
|
||||
res.add(const Spacer());
|
||||
res.add(Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withRed(10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
));
|
||||
}
|
||||
res.add(const Spacer());
|
||||
return res;
|
||||
}.call(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : 0,
|
||||
right: widget.reversed ? 0 : null,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
right: !widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
315
lib/components/flyout.dart
Normal file
315
lib/components/flyout.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
part of "components.dart";
|
||||
|
||||
const minFlyoutWidth = 256.0;
|
||||
const minFlyoutHeight = 128.0;
|
||||
|
||||
class FlyoutController {
|
||||
Function? _show;
|
||||
|
||||
void show() {
|
||||
if (_show == null) {
|
||||
throw "FlyoutController is not attached to a Flyout";
|
||||
}
|
||||
_show!();
|
||||
}
|
||||
}
|
||||
|
||||
class Flyout extends StatefulWidget {
|
||||
const Flyout(
|
||||
{super.key,
|
||||
required this.flyoutBuilder,
|
||||
required this.child,
|
||||
this.enableTap = false,
|
||||
this.enableDoubleTap = false,
|
||||
this.enableLongPress = false,
|
||||
this.enableSecondaryTap = false,
|
||||
this.withInkWell = false,
|
||||
this.borderRadius = 0,
|
||||
this.controller,
|
||||
this.navigator});
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final bool enableTap;
|
||||
|
||||
final bool enableDoubleTap;
|
||||
|
||||
final bool enableLongPress;
|
||||
|
||||
final bool enableSecondaryTap;
|
||||
|
||||
final bool withInkWell;
|
||||
|
||||
final double borderRadius;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
final FlyoutController? controller;
|
||||
|
||||
@override
|
||||
State<Flyout> createState() => _FlyoutState();
|
||||
}
|
||||
|
||||
class _FlyoutState extends State<Flyout> {
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.controller != null) {
|
||||
widget.controller?._show = show;
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (widget.controller != null) {
|
||||
widget.controller?._show = show;
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.withInkWell) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
onTap: widget.enableTap ? show : null,
|
||||
onDoubleTap: widget.enableDoubleTap ? show : null,
|
||||
onLongPress: widget.enableLongPress ? show : null,
|
||||
onSecondaryTap: widget.enableSecondaryTap ? show : null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: widget.enableTap ? show : null,
|
||||
onDoubleTap: widget.enableDoubleTap ? show : null,
|
||||
onLongPress: widget.enableLongPress ? show : null,
|
||||
onSecondaryTap: widget.enableSecondaryTap ? show : null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void show() {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var rect = renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||
var navigator = widget.navigator ?? Navigator.of(context);
|
||||
navigator.push(PageRouteBuilder(
|
||||
fullscreenDialog: true,
|
||||
barrierDismissible: true,
|
||||
opaque: false,
|
||||
transitionDuration: _fastAnimationDuration,
|
||||
reverseTransitionDuration: _fastAnimationDuration,
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
var left = rect.left;
|
||||
var top = rect.bottom;
|
||||
|
||||
if (left + minFlyoutWidth > MediaQuery.of(context).size.width) {
|
||||
left = MediaQuery.of(context).size.width - minFlyoutWidth;
|
||||
}
|
||||
if (top + minFlyoutHeight > MediaQuery.of(context).size.height) {
|
||||
top = MediaQuery.of(context).size.height - minFlyoutHeight;
|
||||
}
|
||||
|
||||
Widget transition(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget flyout) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -0.05),
|
||||
end: const Offset(0, 0),
|
||||
).animate(animation),
|
||||
child: flyout,
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: navigator.pop,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, builder) {
|
||||
return ColoredBox(
|
||||
color: Colors.black.withOpacity(0.3 * animation.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: left,
|
||||
right: 0,
|
||||
top: top,
|
||||
bottom: 0,
|
||||
child: transition(
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: widget.flyoutBuilder(context),
|
||||
)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutContent extends StatelessWidget {
|
||||
const FlyoutContent(
|
||||
{super.key, required this.title, required this.actions, this.content});
|
||||
|
||||
final String title;
|
||||
|
||||
final String? content;
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
type: MaterialType.card,
|
||||
elevation: 1,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: minFlyoutWidth,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (content != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(content!, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [const Spacer(), ...actions],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingAll(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutTextButton extends StatefulWidget {
|
||||
const FlyoutTextButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutTextButton> createState() => _FlyoutTextButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutTextButtonState extends State<FlyoutTextButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutIconButton extends StatefulWidget {
|
||||
const FlyoutIconButton(
|
||||
{super.key,
|
||||
required this.icon,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutIconButton> createState() => _FlyoutIconButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutIconButtonState extends State<FlyoutIconButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
icon: widget.icon,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutFilledButton extends StatefulWidget {
|
||||
const FlyoutFilledButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutFilledButton> createState() => _FlyoutFilledButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutFilledButtonState extends State<FlyoutFilledButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
314
lib/components/image.dart
Normal file
314
lib/components/image.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class AnimatedImage extends StatefulWidget {
|
||||
/// show animation when loading is complete.
|
||||
AnimatedImage({
|
||||
required ImageProvider image,
|
||||
super.key,
|
||||
double scale = 1.0,
|
||||
this.semanticLabel,
|
||||
this.excludeFromSemantics = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.color,
|
||||
this.opacity,
|
||||
this.colorBlendMode,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.repeat = ImageRepeat.noRepeat,
|
||||
this.centerSlice,
|
||||
this.matchTextDirection = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.filterQuality = FilterQuality.medium,
|
||||
this.isAntiAlias = false,
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
}
|
||||
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
|
||||
assert(cacheWidth == null || cacheWidth > 0),
|
||||
assert(cacheHeight == null || cacheHeight > 0);
|
||||
|
||||
final ImageProvider image;
|
||||
|
||||
final String? semanticLabel;
|
||||
|
||||
final bool excludeFromSemantics;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final bool gaplessPlayback;
|
||||
|
||||
final bool matchTextDirection;
|
||||
|
||||
final Rect? centerSlice;
|
||||
|
||||
final ImageRepeat repeat;
|
||||
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
final BoxFit? fit;
|
||||
|
||||
final BlendMode? colorBlendMode;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
final Animation<double>? opacity;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
static void clear() => _AnimatedImageState.clear();
|
||||
|
||||
@override
|
||||
State<AnimatedImage> createState() => _AnimatedImageState();
|
||||
}
|
||||
|
||||
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
bool _isListeningToStream = false;
|
||||
late bool _invertColors;
|
||||
int? _frameNumber;
|
||||
bool _wasSynchronouslyLoaded = false;
|
||||
late DisposableBuildContext<State<AnimatedImage>> _scrollAwareContext;
|
||||
Object? _lastException;
|
||||
ImageStreamCompleterHandle? _completerHandle;
|
||||
|
||||
static final Map<int, Size> _cache = {};
|
||||
|
||||
static clear() => _cache.clear();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<AnimatedImage>>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
assert(_imageStream != null);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_stopListeningToStream();
|
||||
_completerHandle?.dispose();
|
||||
_scrollAwareContext.dispose();
|
||||
_replaceImage(info: null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_updateInvertColors();
|
||||
_resolveImage();
|
||||
|
||||
if (TickerMode.of(context)) {
|
||||
_listenToStream();
|
||||
} else {
|
||||
_stopListeningToStream(keepStreamAlive: true);
|
||||
}
|
||||
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.image != oldWidget.image) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAccessibilityFeatures() {
|
||||
super.didChangeAccessibilityFeatures();
|
||||
setState(() {
|
||||
_updateInvertColors();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
_resolveImage(); // in case the image cache was flushed
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
void _updateInvertColors() {
|
||||
_invertColors = MediaQuery.maybeInvertColorsOf(context)
|
||||
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: widget.image,
|
||||
);
|
||||
final ImageStream newStream =
|
||||
provider.resolve(createLocalImageConfiguration(
|
||||
context,
|
||||
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
|
||||
));
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStreamListener _getListener({bool recreateListener = false}) {
|
||||
if(_imageStreamListener == null || recreateListener) {
|
||||
_lastException = null;
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
_handleImageFrame,
|
||||
onChunk: _handleImageChunk,
|
||||
onError: (Object error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_lastException = error;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
|
||||
setState(() {
|
||||
_replaceImage(info: imageInfo);
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
|
||||
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _replaceImage({required ImageInfo? info}) {
|
||||
final ImageInfo? oldImageInfo = _imageInfo;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
|
||||
_imageInfo = info;
|
||||
}
|
||||
|
||||
// Updates _imageStream to newStream, and moves the stream listener
|
||||
// registration from the old stream to the new stream (if a listener was
|
||||
// registered).
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.removeListener(_getListener());
|
||||
}
|
||||
|
||||
if (!widget.gaplessPlayback) {
|
||||
setState(() { _replaceImage(info: null); });
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingProgress = null;
|
||||
_frameNumber = null;
|
||||
_wasSynchronouslyLoaded = false;
|
||||
});
|
||||
|
||||
_imageStream = newStream;
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.addListener(_getListener());
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToStream() {
|
||||
if (_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
_imageStream!.addListener(_getListener());
|
||||
_completerHandle?.dispose();
|
||||
_completerHandle = null;
|
||||
|
||||
_isListeningToStream = true;
|
||||
}
|
||||
|
||||
/// Stops listening to the image stream, if this state object has attached a
|
||||
/// listener.
|
||||
///
|
||||
/// If the listener from this state is the last listener on the stream, the
|
||||
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
|
||||
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
|
||||
/// alive and is compatible with the [TickerMode] being off.
|
||||
void _stopListeningToStream({bool keepStreamAlive = false}) {
|
||||
if (!_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
|
||||
_completerHandle = _imageStream!.completer!.keepAlive();
|
||||
}
|
||||
|
||||
_imageStream!.removeListener(_getListener());
|
||||
_isListeningToStream = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget result;
|
||||
|
||||
if(_imageInfo != null){
|
||||
result = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: BoxFit.cover,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(Icons.error),
|
||||
);
|
||||
|
||||
if (!widget.excludeFromSemantics) {
|
||||
result = Semantics(
|
||||
container: widget.semanticLabel != null,
|
||||
image: true,
|
||||
label: widget.semanticLabel ?? '',
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
} else{
|
||||
result = const Center();
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
||||
super.debugFillProperties(description);
|
||||
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
|
||||
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
|
||||
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
|
||||
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
|
||||
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
|
||||
}
|
||||
}
|
139
lib/components/layout.dart
Normal file
139
lib/components/layout.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
|
||||
const SliverGridViewWithFixedItemHeight(
|
||||
{required this.delegate,
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
super.key});
|
||||
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
final double maxCrossAxisExtent;
|
||||
|
||||
final double itemHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverLayoutBuilder(
|
||||
builder: ((context, constraints) => SliverGrid(
|
||||
delegate: delegate,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
childAspectRatio:
|
||||
calcChildAspectRatio(constraints.crossAxisExtent)),
|
||||
)));
|
||||
}
|
||||
|
||||
double calcChildAspectRatio(double width) {
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
final itemWidth = width / crossItems;
|
||||
return itemWidth / itemHeight;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithFixedHeight extends SliverGridDelegate{
|
||||
const SliverGridDelegateWithFixedHeight({
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
});
|
||||
|
||||
final double maxCrossAxisExtent;
|
||||
|
||||
final double itemHeight;
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
final width = constraints.crossAxisExtent;
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossItems,
|
||||
mainAxisStride: itemHeight,
|
||||
crossAxisStride: width / crossItems,
|
||||
childMainAxisExtent: itemHeight,
|
||||
childCrossAxisExtent: width / crossItems,
|
||||
reverseCrossAxis: false
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if(oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
|
||||
if(oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent
|
||||
|| oldDelegate.itemHeight != itemHeight){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithComics extends SliverGridDelegate{
|
||||
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
|
||||
|
||||
final bool useBriefMode;
|
||||
|
||||
final double? scale;
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
if(appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode){
|
||||
return getBriefModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
|
||||
} else {
|
||||
return getDetailedModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
|
||||
}
|
||||
}
|
||||
|
||||
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale){
|
||||
const maxCrossAxisExtent = 650;
|
||||
final itemHeight = 164 * scale;
|
||||
final width = constraints.crossAxisExtent;
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossItems,
|
||||
mainAxisStride: itemHeight,
|
||||
crossAxisStride: width / crossItems,
|
||||
childMainAxisExtent: itemHeight,
|
||||
childCrossAxisExtent: width / crossItems,
|
||||
reverseCrossAxis: false
|
||||
);
|
||||
}
|
||||
|
||||
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale){
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.72;
|
||||
const crossAxisSpacing = 0.0;
|
||||
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
// Ensure a minimum count of 1, can be zero and result in an infinite extent
|
||||
// below when the window size is 0.
|
||||
crossAxisCount = math.max(1, crossAxisCount);
|
||||
final double usableCrossAxisExtent = math.max(
|
||||
0.0,
|
||||
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
|
||||
);
|
||||
final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
|
||||
final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisStride: childMainAxisExtent,
|
||||
crossAxisStride: childCrossAxisExtent + crossAxisSpacing,
|
||||
childMainAxisExtent: childMainAxisExtent,
|
||||
childCrossAxisExtent: childCrossAxisExtent,
|
||||
reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
391
lib/components/loading.dart
Normal file
391
lib/components/loading.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class NetworkError extends StatelessWidget {
|
||||
const NetworkError({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
});
|
||||
|
||||
final String message;
|
||||
|
||||
final void Function()? retry;
|
||||
|
||||
final bool withAppbar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 60,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
),
|
||||
if (retry != null)
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
if (retry != null)
|
||||
FilledButton(onPressed: retry, child: Text('重试'.tl))
|
||||
],
|
||||
),
|
||||
);
|
||||
if (withAppbar) {
|
||||
body = Column(
|
||||
children: [
|
||||
const Appbar(title: Text("")),
|
||||
Expanded(
|
||||
child: body,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListLoadingIndicator extends StatelessWidget {
|
||||
const ListLoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
child: Center(
|
||||
child: FiveDotLoadingAnimation(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
extends State<T> {
|
||||
bool isLoading = false;
|
||||
|
||||
S? data;
|
||||
|
||||
String? error;
|
||||
|
||||
Future<Res<S>> loadData();
|
||||
|
||||
Widget buildContent(BuildContext context, S data);
|
||||
|
||||
Widget? buildFrame(BuildContext context, Widget child) => null;
|
||||
|
||||
Widget buildLoading() {
|
||||
return Center(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(32).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
||||
void retry() {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
});
|
||||
loadData().then((value) {
|
||||
if (value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildError() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
error!,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Button.text(
|
||||
onPressed: retry,
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
isLoading = true;
|
||||
Future.microtask(() {
|
||||
loadData().then((value) {
|
||||
if (value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
|
||||
if (isLoading) {
|
||||
child = buildLoading();
|
||||
} else if (error != null) {
|
||||
child = buildError();
|
||||
} else {
|
||||
child = buildContent(context, data!);
|
||||
}
|
||||
|
||||
return buildFrame(context, child) ?? child;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
||||
extends State<T> {
|
||||
bool _isFirstLoading = true;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
List<S>? data;
|
||||
|
||||
String? _error;
|
||||
|
||||
int _page = 1;
|
||||
|
||||
int _maxPage = 1;
|
||||
|
||||
Future<Res<List<S>>> loadData(int page);
|
||||
|
||||
Widget? buildFrame(BuildContext context, Widget child) => null;
|
||||
|
||||
Widget buildContent(BuildContext context, List<S> data);
|
||||
|
||||
bool get isLoading => _isLoading || _isFirstLoading;
|
||||
|
||||
bool get isFirstLoading => _isFirstLoading;
|
||||
|
||||
bool get haveNextPage => _page <= _maxPage;
|
||||
|
||||
void nextPage() {
|
||||
if (_page > _maxPage) return;
|
||||
if (_isLoading) return;
|
||||
_isLoading = true;
|
||||
loadData(_page).then((value) {
|
||||
_isLoading = false;
|
||||
if (mounted) {
|
||||
if (value.success) {
|
||||
_page++;
|
||||
if (value.subData is int) {
|
||||
_maxPage = value.subData as int;
|
||||
}
|
||||
setState(() {
|
||||
data!.addAll(value.data);
|
||||
});
|
||||
} else {
|
||||
var message = value.errorMessage ?? "Network Error";
|
||||
if (message.length > 20) {
|
||||
message = "${message.substring(0, 20)}...";
|
||||
}
|
||||
context.showMessage(message: message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
setState(() {
|
||||
_isFirstLoading = true;
|
||||
_isLoading = false;
|
||||
data = null;
|
||||
_error = null;
|
||||
_page = 1;
|
||||
});
|
||||
firstLoad();
|
||||
}
|
||||
|
||||
void firstLoad() {
|
||||
Future.microtask(() {
|
||||
loadData(_page).then((value) {
|
||||
if (!mounted) return;
|
||||
if (value.success) {
|
||||
_page++;
|
||||
if (value.subData is int) {
|
||||
_maxPage = value.subData as int;
|
||||
}
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
firstLoad();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget buildLoading(BuildContext context) {
|
||||
return Center(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(32).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildError(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, maxLines: 3),
|
||||
const SizedBox(height: 12),
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
reset();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
|
||||
if (_isFirstLoading) {
|
||||
child = buildLoading(context);
|
||||
} else if (_error != null) {
|
||||
child = buildError(context, _error!);
|
||||
} else {
|
||||
child = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.maxScrollExtent) {
|
||||
nextPage();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: buildContent(context, data!),
|
||||
);
|
||||
}
|
||||
|
||||
return buildFrame(context, child) ?? child;
|
||||
}
|
||||
}
|
||||
|
||||
class FiveDotLoadingAnimation extends StatefulWidget {
|
||||
const FiveDotLoadingAnimation({super.key});
|
||||
|
||||
@override
|
||||
State<FiveDotLoadingAnimation> createState() =>
|
||||
_FiveDotLoadingAnimationState();
|
||||
}
|
||||
|
||||
class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
upperBound: 6,
|
||||
)..repeat(min: 0, max: 5.2, period: const Duration(milliseconds: 1200));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _colors = [
|
||||
Colors.red,
|
||||
Colors.green,
|
||||
Colors.blue,
|
||||
Colors.yellow,
|
||||
Colors.purple
|
||||
];
|
||||
|
||||
static const _padding = 12.0;
|
||||
|
||||
static const _dotSize = 12.0;
|
||||
|
||||
static const _height = 24.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: _dotSize * 5 + _padding * 6,
|
||||
height: _height,
|
||||
child: Stack(
|
||||
children: List.generate(5, (index) => buildDot(index)),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildDot(int index) {
|
||||
var value = _controller.value;
|
||||
var startValue = index * 0.8;
|
||||
return Positioned(
|
||||
left: index * _dotSize + (index + 1) * _padding,
|
||||
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
||||
(_height - _dotSize),
|
||||
child: Container(
|
||||
width: _dotSize,
|
||||
height: _dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: _colors[index],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
117
lib/components/menu.dart
Normal file
117
lib/components/menu.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
part of "components.dart";
|
||||
|
||||
void showDesktopMenu(
|
||||
BuildContext context, Offset location, List<DesktopMenuEntry> entries) {
|
||||
Navigator.of(context).push(DesktopMenuRoute(entries, location));
|
||||
}
|
||||
|
||||
class DesktopMenuRoute<T> extends PopupRoute<T> {
|
||||
final List<DesktopMenuEntry> entries;
|
||||
|
||||
final Offset location;
|
||||
|
||||
DesktopMenuRoute(this.entries, this.location);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.transparent;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "menu";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
const width = 196.0;
|
||||
final size = MediaQuery.of(context).size;
|
||||
var left = location.dx;
|
||||
if (left + width > size.width - 10) {
|
||||
left = size.width - width - 10;
|
||||
}
|
||||
var top = location.dy;
|
||||
var height = 16 + 32 * entries.length;
|
||||
if (top + height > size.height - 15) {
|
||||
top = size.height - height - 15;
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]),
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: entries.map((e) => buildEntry(e, context)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEntry(DesktopMenuEntry entry, BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
entry.onClick();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
if (entry.icon != null)
|
||||
Icon(
|
||||
entry.icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(entry.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return FadeTransition(
|
||||
opacity: animation.drive(Tween<double>(begin: 0, end: 1)
|
||||
.chain(CurveTween(curve: Curves.ease))),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopMenuEntry {
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final void Function() onClick;
|
||||
|
||||
DesktopMenuEntry({required this.text, this.icon, required this.onClick});
|
||||
}
|
217
lib/components/message.dart
Normal file
217
lib/components/message.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
part of "components.dart";
|
||||
|
||||
class OverlayWidget extends StatefulWidget {
|
||||
const OverlayWidget(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<OverlayWidget> createState() => OverlayWidgetState();
|
||||
}
|
||||
|
||||
class OverlayWidgetState extends State<OverlayWidget> {
|
||||
final overlayKey = GlobalKey<OverlayState>();
|
||||
|
||||
var entries = <OverlayEntry>[];
|
||||
|
||||
void addOverlay(OverlayEntry entry) {
|
||||
if (overlayKey.currentState != null) {
|
||||
overlayKey.currentState!.insert(entry);
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(OverlayEntry entry) {
|
||||
if (entries.remove(entry)) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void removeAll() {
|
||||
for (var entry in entries) {
|
||||
entry.remove();
|
||||
}
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Overlay(
|
||||
key: overlayKey,
|
||||
initialEntries: [OverlayEntry(builder: (context) => widget.child)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showDialogMessage(BuildContext context, String title, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: context.pop,
|
||||
child: Text("OK".tl),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showConfirmDialog(BuildContext context, String title, String content,
|
||||
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)),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
class LoadingDialogController {
|
||||
void Function()? closeDialog;
|
||||
|
||||
bool closed = false;
|
||||
|
||||
void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (closeDialog == null) {
|
||||
Future.microtask(closeDialog!);
|
||||
} else {
|
||||
closeDialog!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
{void Function()? onCancel,
|
||||
bool barrierDismissible = true,
|
||||
bool allowCancel = true,
|
||||
String? message,
|
||||
String cancelButtonText = "Cancel"}) {
|
||||
var controller = LoadingDialogController();
|
||||
|
||||
var loadingDialogRoute = DialogRoute(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Text(
|
||||
message ?? 'Loading',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const Spacer(),
|
||||
if (allowCancel)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
controller.close();
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(cancelButtonText.tl))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
var navigator = Navigator.of(context);
|
||||
|
||||
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
||||
|
||||
controller.closeDialog = () {
|
||||
navigator.removeRoute(loadingDialogRoute);
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
class ContentDialog extends StatelessWidget {
|
||||
const ContentDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.dismissible = true,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
final Widget content;
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
final bool dismissible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Appbar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: dismissible ? context.pop : null,
|
||||
),
|
||||
title: Text(title),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
this.content,
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions,
|
||||
).paddingRight(12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
insetPadding: context.width < 400
|
||||
? const EdgeInsets.symmetric(horizontal: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: IntrinsicWidth(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600,
|
||||
minWidth: math.min(400, context.width - 16),
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
removeBottom: true,
|
||||
context: context,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
695
lib/components/navigation_bar.dart
Normal file
695
lib/components/navigation_bar.dart
Normal file
@@ -0,0 +1,695 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class PaneItemEntry {
|
||||
String label;
|
||||
|
||||
IconData icon;
|
||||
|
||||
IconData activeIcon;
|
||||
|
||||
PaneItemEntry(
|
||||
{required this.label, required this.icon, required this.activeIcon});
|
||||
}
|
||||
|
||||
class PaneActionEntry {
|
||||
String label;
|
||||
|
||||
IconData icon;
|
||||
|
||||
VoidCallback onTap;
|
||||
|
||||
PaneActionEntry(
|
||||
{required this.label, required this.icon, required this.onTap});
|
||||
}
|
||||
|
||||
class NaviPane extends StatefulWidget {
|
||||
const NaviPane(
|
||||
{required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChange,
|
||||
required this.observer,
|
||||
super.key});
|
||||
|
||||
final List<PaneItemEntry> paneItems;
|
||||
|
||||
final List<PaneActionEntry> paneActions;
|
||||
|
||||
final Widget Function(int page) pageBuilder;
|
||||
|
||||
final void Function(int index)? onPageChange;
|
||||
|
||||
final int initialPage;
|
||||
|
||||
final NaviObserver observer;
|
||||
|
||||
@override
|
||||
State<NaviPane> createState() => _NaviPaneState();
|
||||
}
|
||||
|
||||
class _NaviPaneState extends State<NaviPane>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentPage = widget.initialPage;
|
||||
|
||||
int get currentPage => _currentPage;
|
||||
|
||||
set currentPage(int value) {
|
||||
if (value == _currentPage) return;
|
||||
_currentPage = value;
|
||||
widget.onPageChange?.call(value);
|
||||
}
|
||||
|
||||
late AnimationController controller;
|
||||
|
||||
static const _kBottomBarHeight = 58.0;
|
||||
|
||||
static const _kFoldedSideBarWidth = 80.0;
|
||||
|
||||
static const _kSideBarWidth = 256.0;
|
||||
|
||||
static const _kTopBarHeight = 48.0;
|
||||
|
||||
double get bottomBarHeight =>
|
||||
_kBottomBarHeight + MediaQuery.of(context).padding.bottom;
|
||||
|
||||
void onNavigatorStateChange() {
|
||||
onRebuild(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
lowerBound: 0,
|
||||
upperBound: 3,
|
||||
vsync: this,
|
||||
);
|
||||
widget.observer.addListener(onNavigatorStateChange);
|
||||
StateController.put(NaviPaddingWidgetController());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
StateController.remove<NaviPaddingWidgetController>();
|
||||
controller.dispose();
|
||||
widget.observer.removeListener(onNavigatorStateChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double targetFormContext(BuildContext context) {
|
||||
var width = MediaQuery.of(context).size.width;
|
||||
double target = 0;
|
||||
if (widget.observer.pageCount > 1) {
|
||||
target = 1;
|
||||
}
|
||||
if (width > changePoint) {
|
||||
target = 2;
|
||||
}
|
||||
if (width > changePoint2) {
|
||||
target = 3;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
double? animationTarget;
|
||||
|
||||
void onRebuild(BuildContext context) {
|
||||
double target = targetFormContext(context);
|
||||
if (controller.value != target || animationTarget != target) {
|
||||
if (controller.isAnimating) {
|
||||
if (animationTarget == target) {
|
||||
return;
|
||||
} else {
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
if (target == 1) {
|
||||
StateController.find<NaviPaddingWidgetController>()
|
||||
.setWithPadding(true);
|
||||
controller.value = target;
|
||||
} else if (controller.value == 1 && target == 0) {
|
||||
StateController.findOrNull<NaviPaddingWidgetController>()
|
||||
?.setWithPadding(false);
|
||||
controller.value = target;
|
||||
} else {
|
||||
controller.animateTo(target);
|
||||
}
|
||||
animationTarget = target;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
onRebuild(context);
|
||||
return _NaviPopScope(
|
||||
action: () {
|
||||
if (App.mainNavigatorKey!.currentState!.canPop()) {
|
||||
App.mainNavigatorKey!.currentState!.pop();
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
},
|
||||
popGesture: App.isIOS && context.width >= changePoint,
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
final value = controller.value;
|
||||
return Stack(
|
||||
children: [
|
||||
if (value <= 1)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: bottomBarHeight * (0 - value),
|
||||
child: buildBottom(),
|
||||
),
|
||||
if (value <= 1)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _kTopBarHeight * (0 - value) +
|
||||
MediaQuery.of(context).padding.top * (1 - value),
|
||||
child: buildTop(),
|
||||
),
|
||||
Positioned(
|
||||
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: buildLeft(),
|
||||
),
|
||||
Positioned(
|
||||
top: _kTopBarHeight * ((1 - value).clamp(0, 1)) +
|
||||
MediaQuery.of(context).padding.top * (value == 1 ? 0 : 1),
|
||||
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
||||
((value - 2).clamp(0, 1)),
|
||||
right: 0,
|
||||
bottom: bottomBarHeight * ((1 - value).clamp(0, 1)),
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: value >= 2 || value == 0,
|
||||
context: context,
|
||||
child: Material(child: widget.pageBuilder(currentPage)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTop() {
|
||||
return Material(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
height: _kTopBarHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.paneItems[currentPage].label,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
for (var action in widget.paneActions)
|
||||
Tooltip(
|
||||
message: action.label,
|
||||
child: IconButton(
|
||||
icon: Icon(action.icon),
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBottom() {
|
||||
return Material(
|
||||
textStyle: Theme.of(context).textTheme.labelSmall,
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: _kBottomBarHeight + MediaQuery.of(context).padding.bottom,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
|
||||
child: Row(
|
||||
children: List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) => Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
},
|
||||
key: ValueKey(index),
|
||||
))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLeft() {
|
||||
final value = controller.value;
|
||||
const paddingHorizontal = 16.0;
|
||||
return Material(
|
||||
child: Container(
|
||||
width: _kFoldedSideBarWidth +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: value == 3
|
||||
? (_kSideBarWidth - paddingHorizontal * 2 - 1)
|
||||
: (_kFoldedSideBarWidth - paddingHorizontal * 2 - 1),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
...List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) => _SideNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
showTitle: value == 3,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...List<Widget>.generate(
|
||||
widget.paneActions.length,
|
||||
(index) => _PaneActionWidget(
|
||||
entry: widget.paneActions[index],
|
||||
showTitle: value == 3,
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SideNaviWidget extends StatefulWidget {
|
||||
const _SideNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
final PaneItemEntry entry;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_SideNaviWidget> createState() => _SideNaviWidgetState();
|
||||
}
|
||||
|
||||
class _SideNaviWidgetState extends State<_SideNaviWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? colorScheme.primaryContainer
|
||||
: isHovering
|
||||
? colorScheme.surfaceContainerHigh
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaneActionWidget extends StatefulWidget {
|
||||
const _PaneActionWidget(
|
||||
{required this.entry, required this.showTitle, super.key});
|
||||
|
||||
final PaneActionEntry entry;
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_PaneActionWidget> createState() => _PaneActionWidgetState();
|
||||
}
|
||||
|
||||
class _PaneActionWidgetState extends State<_PaneActionWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon = Icon(widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.entry.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: isHovering ? colorScheme.surfaceContainerHigh : null,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidget extends StatefulWidget {
|
||||
const _SingleBottomNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
final PaneItemEntry entry;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_SingleBottomNaviWidget> createState() =>
|
||||
_SingleBottomNaviWidgetState();
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController controller;
|
||||
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SingleBottomNaviWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.enabled != widget.enabled) {
|
||||
if (widget.enabled) {
|
||||
controller.forward(from: 0);
|
||||
} else {
|
||||
controller.reverse(from: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
value: widget.enabled ? 1 : 0,
|
||||
vsync: this,
|
||||
duration: _fastAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: CurvedAnimation(parent: controller, curve: Curves.ease),
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: buildContent(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContent() {
|
||||
final value = controller.value;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
color: isHovering ? colorScheme.surfaceContainer : Colors.transparent,
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32 + value * 32,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
color: value != 0
|
||||
? colorScheme.secondaryContainer
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Center(child: icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NaviObserver extends NavigatorObserver implements Listenable {
|
||||
var routes = Queue<Route>();
|
||||
|
||||
int get pageCount => routes.length;
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
routes.removeLast();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
routes.addLast(route);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route route, Route? previousRoute) {
|
||||
routes.remove(route);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||
routes.remove(oldRoute);
|
||||
if (newRoute != null) {
|
||||
routes.add(newRoute);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<VoidCallback> listeners = [];
|
||||
|
||||
@override
|
||||
void addListener(VoidCallback listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
void notifyListeners() {
|
||||
for (var listener in listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NaviPopScope extends StatelessWidget {
|
||||
const _NaviPopScope(
|
||||
{required this.child, this.popGesture = false, required this.action});
|
||||
|
||||
final Widget child;
|
||||
final bool popGesture;
|
||||
final VoidCallback action;
|
||||
|
||||
static bool panStartAtEdge = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget res = App.isIOS
|
||||
? child
|
||||
: PopScope(
|
||||
canPop: App.isAndroid ? false : true,
|
||||
// flutter <3.24.0 api
|
||||
onPopInvoked: (value) {
|
||||
action();
|
||||
},
|
||||
/*
|
||||
flutter >=3.24.0 api
|
||||
onPopInvokedWithResult: (value, result) {
|
||||
action();
|
||||
},
|
||||
*/
|
||||
child: child,
|
||||
);
|
||||
if (popGesture) {
|
||||
res = GestureDetector(
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class NaviPaddingWidgetController extends StateController {
|
||||
NaviPaddingWidgetController();
|
||||
|
||||
bool _withPadding = false;
|
||||
|
||||
void setWithPadding(bool value) {
|
||||
_withPadding = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class NaviPaddingWidget extends StatelessWidget {
|
||||
const NaviPaddingWidget({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<NaviPaddingWidgetController>(
|
||||
builder: (controller) {
|
||||
return Padding(
|
||||
padding: controller._withPadding
|
||||
? EdgeInsets.only(
|
||||
top: _NaviPaneState._kTopBarHeight + context.padding.top,
|
||||
bottom:
|
||||
_NaviPaneState._kBottomBarHeight + context.padding.bottom,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
184
lib/components/pop_up_widget.dart
Normal file
184
lib/components/pop_up_widget.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class PopUpWidget<T> extends PopupRoute<T> {
|
||||
PopUpWidget(this.widget);
|
||||
|
||||
final Widget widget;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.black54;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "exit";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
var height = MediaQuery.of(context).size.height * 0.9;
|
||||
bool showPopUp = MediaQuery.of(context).size.width > 500;
|
||||
Widget body = PopupIndicatorWidget(
|
||||
child: Container(
|
||||
decoration: showPopUp
|
||||
? const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
)
|
||||
: null,
|
||||
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
|
||||
width: showPopUp ? 500 : double.infinity,
|
||||
height: showPopUp ? height : double.infinity,
|
||||
child: ClipRect(
|
||||
child: Navigator(
|
||||
onGenerateRoute: (settings) => MaterialPageRoute(
|
||||
builder: (context) => widget,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (App.isIOS) {
|
||||
body = IOSBackGestureDetector(
|
||||
enabledCallback: () => true,
|
||||
gestureWidth: 20.0,
|
||||
onStartPopGesture: () =>
|
||||
IOSBackGestureController(controller!, navigator!),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
if (showPopUp) {
|
||||
return MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
context: context,
|
||||
child: Center(
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 350);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return FadeTransition(
|
||||
opacity: animation.drive(
|
||||
Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.ease)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PopupIndicatorWidget extends InheritedWidget {
|
||||
const PopupIndicatorWidget({super.key, required super.child});
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
|
||||
|
||||
static PopupIndicatorWidget? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<PopupIndicatorWidget>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
||||
return await Navigator.of(context).push(PopUpWidget(widget));
|
||||
}
|
||||
|
||||
class PopUpWidgetScaffold extends StatefulWidget {
|
||||
const PopUpWidgetScaffold(
|
||||
{required this.title, required this.body, this.tailing, super.key});
|
||||
|
||||
final Widget body;
|
||||
final List<Widget>? tailing;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<PopUpWidgetScaffold> createState() => _PopUpWidgetScaffoldState();
|
||||
}
|
||||
|
||||
class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 56 + context.padding.top,
|
||||
padding: EdgeInsets.only(top: context.padding.top),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "返回".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_sharp),
|
||||
onPressed: () => context.canPop()
|
||||
? context.pop()
|
||||
: App.rootNavigatorKey.currentContext?.pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.tailing != null) ...widget.tailing!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
context: context,
|
||||
child: Expanded(child: widget.body),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).viewInsets.bottom -
|
||||
0.05 * MediaQuery.of(context).size.height >
|
||||
0
|
||||
? MediaQuery.of(context).viewInsets.bottom -
|
||||
0.05 * MediaQuery.of(context).size.height
|
||||
: 0,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
100
lib/components/scroll.dart
Normal file
100
lib/components/scroll.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SmoothCustomScrollView extends StatelessWidget {
|
||||
const SmoothCustomScrollView({super.key, required this.slivers, this.controller});
|
||||
|
||||
final ScrollController? controller;
|
||||
|
||||
final List<Widget> slivers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothScrollProvider(
|
||||
controller: controller,
|
||||
builder: (context, controller, physics) {
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
slivers: slivers,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SmoothScrollProvider extends StatefulWidget {
|
||||
const SmoothScrollProvider({super.key, this.controller, required this.builder});
|
||||
|
||||
final ScrollController? controller;
|
||||
|
||||
final Widget Function(BuildContext, ScrollController, ScrollPhysics) builder;
|
||||
|
||||
static bool get isMouseScroll => _SmoothScrollProviderState._isMouseScroll;
|
||||
|
||||
@override
|
||||
State<SmoothScrollProvider> createState() => _SmoothScrollProviderState();
|
||||
}
|
||||
|
||||
class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
late final ScrollController _controller;
|
||||
|
||||
double? _futurePosition;
|
||||
|
||||
static bool _isMouseScroll = App.isDesktop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_controller = widget.controller ?? ScrollController();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(App.isMacOS) {
|
||||
return widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
const ClampingScrollPhysics(),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
if (_isMouseScroll) {
|
||||
setState(() {
|
||||
_isMouseScroll = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onPointerSignal: (pointerSignal) {
|
||||
if (pointerSignal is PointerScrollEvent) {
|
||||
if (pointerSignal.kind == PointerDeviceKind.mouse &&
|
||||
!_isMouseScroll) {
|
||||
setState(() {
|
||||
_isMouseScroll = true;
|
||||
});
|
||||
}
|
||||
if (!_isMouseScroll) return;
|
||||
var currentLocation = _controller.position.pixels;
|
||||
_futurePosition ??= currentLocation;
|
||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||
_futurePosition =
|
||||
_futurePosition! + pointerSignal.scrollDelta.dy * k;
|
||||
_futurePosition = _futurePosition!.clamp(
|
||||
_controller.position.minScrollExtent,
|
||||
_controller.position.maxScrollExtent);
|
||||
_controller.animateTo(_futurePosition!,
|
||||
duration: _fastAnimationDuration, curve: Curves.linear);
|
||||
}
|
||||
},
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
_isMouseScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
346
lib/components/select.dart
Normal file
346
lib/components/select.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Select extends StatefulWidget {
|
||||
const Select({
|
||||
required this.initialValue,
|
||||
this.width = 120,
|
||||
required this.onChange,
|
||||
super.key,
|
||||
required this.values,
|
||||
this.disabledValues = const [],
|
||||
this.outline = false,
|
||||
});
|
||||
|
||||
///初始值, 提供values的下标
|
||||
final int? initialValue;
|
||||
|
||||
///可供选取的值
|
||||
final List<String> values;
|
||||
|
||||
///宽度
|
||||
final double width;
|
||||
|
||||
///发生改变时的回调
|
||||
final void Function(int) onChange;
|
||||
|
||||
/// 禁用的值
|
||||
final List<int> disabledValues;
|
||||
|
||||
/// 是否为边框模式
|
||||
final bool outline;
|
||||
|
||||
@override
|
||||
State<Select> createState() => _SelectState();
|
||||
}
|
||||
|
||||
class _SelectState extends State<Select> {
|
||||
late int? value = widget.initialValue;
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (value != null && value! < 0) value = null;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.values.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final renderBox = context.findRenderObject() as RenderBox;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
var size = MediaQuery.of(context).size;
|
||||
showMenu<int>(
|
||||
context: App.rootNavigatorKey.currentContext!,
|
||||
initialValue: value,
|
||||
position: RelativeRect.fromLTRB(offset.dx, offset.dy,
|
||||
offset.dx + widget.width, size.height - offset.dy),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.width,
|
||||
minWidth: widget.width,
|
||||
),
|
||||
color: context.colorScheme.surfaceContainerLowest,
|
||||
items: [
|
||||
for (int i = 0; i < widget.values.length; i++)
|
||||
if (!widget.disabledValues.contains(i))
|
||||
PopupMenuItem(
|
||||
value: i,
|
||||
height: App.isDesktop ? 38 : 42,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
value = i;
|
||||
widget.onChange(i);
|
||||
});
|
||||
},
|
||||
child: Text(widget.values[i]),
|
||||
)
|
||||
]);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(widget.outline ? 4 : 8),
|
||||
border: widget.outline
|
||||
? Border.all(
|
||||
color: context.colorScheme.outline,
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
width: widget.width,
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value == null ? "" : widget.values[value!],
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down_sharp),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color get color {
|
||||
if (widget.outline) {
|
||||
return isHover
|
||||
? context.colorScheme.outline.withOpacity(0.1)
|
||||
: Colors.transparent;
|
||||
} else {
|
||||
var color = context.colorScheme.surfaceContainerHigh;
|
||||
if (isHover) {
|
||||
color = color.withOpacity(0.8);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterChipFixedWidth extends StatefulWidget {
|
||||
const FilterChipFixedWidth(
|
||||
{required this.label,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
super.key});
|
||||
|
||||
final Widget label;
|
||||
|
||||
final bool selected;
|
||||
|
||||
final void Function(bool) onSelected;
|
||||
|
||||
@override
|
||||
State<FilterChipFixedWidth> createState() => _FilterChipFixedWidthState();
|
||||
}
|
||||
|
||||
class _FilterChipFixedWidthState extends State<FilterChipFixedWidth> {
|
||||
get selected => widget.selected;
|
||||
|
||||
double? labelWidth;
|
||||
|
||||
double? labelHeight;
|
||||
|
||||
var key = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Future.microtask(measureSize);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void measureSize() {
|
||||
final RenderBox renderBox =
|
||||
key.currentContext!.findRenderObject() as RenderBox;
|
||||
labelWidth = renderBox.size.width;
|
||||
labelHeight = renderBox.size.height;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onSelected(true),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AnimatedContainer(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: labelWidth == null ? firstBuild() : buildContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget firstBuild() {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
key: key,
|
||||
child: widget.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContent() {
|
||||
const iconSize = 18.0;
|
||||
const gap = 4.0;
|
||||
return SizedBox(
|
||||
width: iconSize + labelWidth! + gap,
|
||||
height: math.max(iconSize, labelHeight!),
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _fastAnimationDuration,
|
||||
left: selected ? (iconSize + gap) : (iconSize + gap) / 2,
|
||||
child: widget.label,
|
||||
),
|
||||
if (selected)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: labelWidth! + gap,
|
||||
child: const AnimatedCheckIcon(size: iconSize).toCenter(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCheckWidget extends AnimatedWidget {
|
||||
const AnimatedCheckWidget({
|
||||
super.key,
|
||||
required Animation<double> animation,
|
||||
this.size,
|
||||
}) : super(listenable: animation);
|
||||
|
||||
final double? size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var iconSize = size ?? IconTheme.of(context).size ?? 25;
|
||||
final animation = listenable as Animation<double>;
|
||||
return SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: animation.value,
|
||||
child: ClipRRect(
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: iconSize,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCheckIcon extends StatefulWidget {
|
||||
const AnimatedCheckIcon({this.size, super.key});
|
||||
|
||||
final double? size;
|
||||
|
||||
@override
|
||||
State<AnimatedCheckIcon> createState() => _AnimatedCheckIconState();
|
||||
}
|
||||
|
||||
class _AnimatedCheckIconState extends State<AnimatedCheckIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late Animation<double> animation;
|
||||
late AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fastAnimationDuration,
|
||||
);
|
||||
animation = Tween<double>(begin: 0, end: 1).animate(controller)
|
||||
..addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
controller.forward();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedCheckWidget(
|
||||
animation: animation,
|
||||
size: widget.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OptionChip extends StatelessWidget {
|
||||
const OptionChip(
|
||||
{super.key,
|
||||
required this.text,
|
||||
required this.isSelected,
|
||||
required this.onTap});
|
||||
|
||||
final String text;
|
||||
|
||||
final bool isSelected;
|
||||
|
||||
final void Function() onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.colorScheme.primaryContainer
|
||||
: context.colorScheme.surface,
|
||||
border: isSelected
|
||||
? Border.all(color: context.colorScheme.primaryContainer)
|
||||
: Border.all(color: context.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Text(text),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
223
lib/components/side_bar.dart
Normal file
223
lib/components/side_bar.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SideBarRoute<T> extends PopupRoute<T> {
|
||||
SideBarRoute(this.title, this.widget,
|
||||
{this.showBarrier = true,
|
||||
this.useSurfaceTintColor = false,
|
||||
required this.width,
|
||||
this.addBottomPadding = true,
|
||||
this.addTopPadding = true});
|
||||
|
||||
final String? title;
|
||||
|
||||
final Widget widget;
|
||||
|
||||
final bool showBarrier;
|
||||
|
||||
final bool useSurfaceTintColor;
|
||||
|
||||
final double width;
|
||||
|
||||
final bool addTopPadding;
|
||||
|
||||
final bool addBottomPadding;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => showBarrier ? Colors.black54 : Colors.transparent;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "exit";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
bool showSideBar = MediaQuery.of(context).size.width > width;
|
||||
|
||||
Widget body = SidebarBody(
|
||||
title: title,
|
||||
widget: widget,
|
||||
autoChangeTitleBarColor: !useSurfaceTintColor,
|
||||
);
|
||||
|
||||
if (addTopPadding) {
|
||||
body = Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sideBarWidth = math.min(width, MediaQuery.of(context).size.width);
|
||||
|
||||
body = Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: showSideBar
|
||||
? const BorderRadius.horizontal(left: Radius.circular(16))
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.surfaceTint),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
constraints: BoxConstraints(maxWidth: sideBarWidth),
|
||||
height: MediaQuery.of(context).size.height,
|
||||
child: GestureDetector(
|
||||
child: Material(
|
||||
child: ClipRect(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0,
|
||||
0,
|
||||
MediaQuery.of(context).padding.right,
|
||||
addBottomPadding
|
||||
? MediaQuery.of(context).padding.bottom +
|
||||
MediaQuery.of(context).viewInsets.bottom
|
||||
: 0),
|
||||
color: useSurfaceTintColor
|
||||
? Theme.of(context).colorScheme.surfaceTint.withAlpha(20)
|
||||
: null,
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (App.isIOS) {
|
||||
body = IOSBackGestureDetector(
|
||||
enabledCallback: () => true,
|
||||
gestureWidth: 20.0,
|
||||
onStartPopGesture: () =>
|
||||
IOSBackGestureController(controller!, navigator!),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarBody extends StatefulWidget {
|
||||
const SidebarBody(
|
||||
{required this.title,
|
||||
required this.widget,
|
||||
required this.autoChangeTitleBarColor,
|
||||
super.key});
|
||||
|
||||
final String? title;
|
||||
final Widget widget;
|
||||
final bool autoChangeTitleBarColor;
|
||||
|
||||
@override
|
||||
State<SidebarBody> createState() => _SidebarBodyState();
|
||||
}
|
||||
|
||||
class _SidebarBodyState extends State<SidebarBody> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Expanded(child: widget.widget);
|
||||
|
||||
if (widget.autoChangeTitleBarColor) {
|
||||
body = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
height: 60 + MediaQuery.of(context).padding.top,
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "返回",
|
||||
child: IconButton(
|
||||
iconSize: 25,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
body
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showSideBar(BuildContext context, Widget widget,
|
||||
{String? title,
|
||||
bool showBarrier = true,
|
||||
bool useSurfaceTintColor = false,
|
||||
double width = 500,
|
||||
bool addTopPadding = false}) {
|
||||
Navigator.of(context).push(
|
||||
SideBarRoute(
|
||||
title,
|
||||
widget,
|
||||
showBarrier: showBarrier,
|
||||
useSurfaceTintColor: useSurfaceTintColor,
|
||||
width: width,
|
||||
addTopPadding: addTopPadding,
|
||||
addBottomPadding: true,
|
||||
),
|
||||
);
|
||||
}
|
624
lib/components/window_frame.dart
Normal file
624
lib/components/window_frame.dart
Normal file
@@ -0,0 +1,624 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
const _kTitleBarHeight = 36.0;
|
||||
|
||||
class WindowFrameController extends StateController {
|
||||
bool useDarkTheme = false;
|
||||
|
||||
bool isHideWindowFrame = false;
|
||||
|
||||
void setDarkTheme() {
|
||||
useDarkTheme = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void resetTheme() {
|
||||
useDarkTheme = false;
|
||||
update();
|
||||
}
|
||||
|
||||
VoidCallback openSideBar = () {};
|
||||
|
||||
void hideWindowFrame() {
|
||||
isHideWindowFrame = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void showWindowFrame() {
|
||||
isHideWindowFrame = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class WindowFrame extends StatelessWidget {
|
||||
const WindowFrame(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
StateController.putIfNotExists<WindowFrameController>(
|
||||
WindowFrameController());
|
||||
if (App.isMobile) return child;
|
||||
return StateBuilder<WindowFrameController>(builder: (controller) {
|
||||
if (controller.isHideWindowFrame) return child;
|
||||
|
||||
var body = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
brightness: controller.useDarkTheme ? Brightness.dark : null,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
if (App.isMacOS)
|
||||
const DragToMoveArea(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
width: 16,
|
||||
),
|
||||
).paddingRight(52)
|
||||
else
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Text(
|
||||
'Venera',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: (controller.useDarkTheme ||
|
||||
context.brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
).toAlign(Alignment.centerLeft).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
if (!App.isMacOS)
|
||||
const WindowButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
if (App.isLinux) {
|
||||
return VirtualWindowFrame(child: body);
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildMenuButton(
|
||||
WindowFrameController controller, BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
controller.openSideBar();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 42,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(18, 20),
|
||||
painter: _MenuPainter(
|
||||
color: (controller.useDarkTheme ||
|
||||
Theme.of(context).brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
_MenuPainter({this.color = Colors.black});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = getPaint(color);
|
||||
final path = Path()
|
||||
..moveTo(0, size.height / 4)
|
||||
..lineTo(size.width, size.height / 4)
|
||||
..moveTo(0, size.height / 4 * 2)
|
||||
..lineTo(size.width, size.height / 4 * 2)
|
||||
..moveTo(0, size.height / 4 * 3)
|
||||
..lineTo(size.width, size.height / 4 * 3);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class WindowButtons extends StatefulWidget {
|
||||
const WindowButtons({super.key});
|
||||
|
||||
@override
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
bool isMaximized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
windowManager.isMaximized().then((value) {
|
||||
if (value) {
|
||||
setState(() {
|
||||
isMaximized = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
isMaximized = true;
|
||||
});
|
||||
super.onWindowMaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
isMaximized = false;
|
||||
});
|
||||
super.onWindowUnmaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
final color = dark ? Colors.white : Colors.black;
|
||||
final hoverColor = dark ? Colors.white30 : Colors.black12;
|
||||
|
||||
return SizedBox(
|
||||
width: 138,
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
WindowButton(
|
||||
icon: MinimizeIcon(color: color),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () async {
|
||||
bool isMinimized = await windowManager.isMinimized();
|
||||
if (isMinimized) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.minimize();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isMaximized)
|
||||
WindowButton(
|
||||
icon: RestoreIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.unmaximize();
|
||||
},
|
||||
)
|
||||
else
|
||||
WindowButton(
|
||||
icon: MaximizeIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.maximize();
|
||||
},
|
||||
),
|
||||
WindowButton(
|
||||
icon: CloseIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverIcon: CloseIcon(
|
||||
color: !dark ? Colors.white : Colors.black,
|
||||
),
|
||||
hoverColor: Colors.red,
|
||||
onPressed: () {
|
||||
windowManager.close();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowButton extends StatefulWidget {
|
||||
const WindowButton(
|
||||
{required this.icon,
|
||||
required this.onPressed,
|
||||
required this.hoverColor,
|
||||
this.hoverIcon,
|
||||
super.key});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
final Color hoverColor;
|
||||
|
||||
final Widget? hoverIcon;
|
||||
|
||||
@override
|
||||
State<WindowButton> createState() => _WindowButtonState();
|
||||
}
|
||||
|
||||
class _WindowButtonState extends State<WindowButton> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
isHovering = true;
|
||||
}),
|
||||
onExit: (event) => setState(() {
|
||||
isHovering = false;
|
||||
}),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
child: Container(
|
||||
width: 46,
|
||||
height: double.infinity,
|
||||
decoration:
|
||||
BoxDecoration(color: isHovering ? widget.hoverColor : null),
|
||||
child: isHovering ? widget.hoverIcon ?? widget.icon : widget.icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close
|
||||
class CloseIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const CloseIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_ClosePainter(color));
|
||||
}
|
||||
|
||||
class _ClosePainter extends _IconPainter {
|
||||
_ClosePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color, true);
|
||||
canvas.drawLine(const Offset(0, 0), Offset(size.width, size.height), p);
|
||||
canvas.drawLine(Offset(0, size.height), Offset(size.width, 0), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximize
|
||||
class MaximizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MaximizeIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||
}
|
||||
|
||||
class _MaximizePainter extends _IconPainter {
|
||||
_MaximizePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore
|
||||
class RestoreIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const RestoreIcon({
|
||||
super.key,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||
}
|
||||
|
||||
class _RestorePainter extends _IconPainter {
|
||||
_RestorePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
||||
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
||||
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
||||
canvas.drawLine(
|
||||
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
||||
canvas.drawLine(Offset(size.width, size.height - 2),
|
||||
Offset(size.width - 2, size.height - 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize
|
||||
class MinimizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MinimizeIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||
}
|
||||
|
||||
class _MinimizePainter extends _IconPainter {
|
||||
_MinimizePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers
|
||||
abstract class _IconPainter extends CustomPainter {
|
||||
_IconPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _AlignedPaint extends StatelessWidget {
|
||||
const _AlignedPaint(this.painter);
|
||||
|
||||
final CustomPainter painter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
||||
}
|
||||
}
|
||||
|
||||
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..isAntiAlias = isAntiAlias
|
||||
..strokeWidth = 1;
|
||||
|
||||
class WindowPlacement {
|
||||
final Rect rect;
|
||||
|
||||
final bool isMaximized;
|
||||
|
||||
const WindowPlacement(this.rect, this.isMaximized);
|
||||
|
||||
Future<void> applyToWindow() async {
|
||||
await windowManager.setBounds(rect);
|
||||
|
||||
if (!validate(rect)) {
|
||||
await windowManager.center();
|
||||
}
|
||||
|
||||
if (isMaximized) {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> writeToFile() async {
|
||||
var file = File("${App.dataPath}/window_placement");
|
||||
await file.writeAsString(jsonEncode({
|
||||
'width': rect.width,
|
||||
'height': rect.height,
|
||||
'x': rect.topLeft.dx,
|
||||
'y': rect.topLeft.dy,
|
||||
'isMaximized': isMaximized
|
||||
}));
|
||||
}
|
||||
|
||||
static Future<WindowPlacement> loadFromFile() async {
|
||||
try {
|
||||
var file = File("${App.dataPath}/window_placement");
|
||||
if (!file.existsSync()) {
|
||||
return defaultPlacement;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
var rect =
|
||||
Rect.fromLTWH(json['x'], json['y'], json['width'], json['height']);
|
||||
return WindowPlacement(rect, json['isMaximized']);
|
||||
} catch (e) {
|
||||
return defaultPlacement;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<WindowPlacement> get current async {
|
||||
var rect = await windowManager.getBounds();
|
||||
var isMaximized = await windowManager.isMaximized();
|
||||
return WindowPlacement(rect, isMaximized);
|
||||
}
|
||||
|
||||
static const defaultPlacement =
|
||||
WindowPlacement(Rect.fromLTWH(10, 10, 900, 600), false);
|
||||
|
||||
static WindowPlacement cache = defaultPlacement;
|
||||
|
||||
static Timer? timer;
|
||||
|
||||
static void loop() async {
|
||||
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
|
||||
var placement = await WindowPlacement.current;
|
||||
if (!validate(placement.rect)) {
|
||||
return;
|
||||
}
|
||||
if (placement.rect != cache.rect ||
|
||||
placement.isMaximized != cache.isMaximized) {
|
||||
cache = placement;
|
||||
await placement.writeToFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static bool validate(Rect rect) {
|
||||
return rect.topLeft.dx >= 0 && rect.topLeft.dy >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
class VirtualWindowFrame extends StatefulWidget {
|
||||
const VirtualWindowFrame({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// The [child] contained by the VirtualWindowFrame.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _VirtualWindowFrameState();
|
||||
}
|
||||
|
||||
class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
with WindowListener {
|
||||
bool _isFocused = true;
|
||||
bool _isMaximized = false;
|
||||
bool _isFullScreen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildVirtualWindowFrame(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: (_isMaximized || _isFullScreen) ? 0 : 1,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (!_isMaximized && !_isFullScreen)
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
offset: Offset(0.0, _isFocused ? 4 : 2),
|
||||
blurRadius: 6,
|
||||
)
|
||||
],
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DragToResizeArea(
|
||||
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
setState(() {
|
||||
_isFocused = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
setState(() {
|
||||
_isFocused = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
_isMaximized = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
_isMaximized = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
setState(() {
|
||||
_isFullScreen = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
setState(() {
|
||||
_isFullScreen = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
TransitionBuilder VirtualWindowFrameInit() {
|
||||
return (_, Widget? child) {
|
||||
return VirtualWindowFrame(
|
||||
child: child!,
|
||||
);
|
||||
};
|
||||
}
|
73
lib/foundation/app.dart
Normal file
73
lib/foundation/app.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'appdata.dart';
|
||||
|
||||
|
||||
export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.0.0";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
bool get isIOS => Platform.isIOS;
|
||||
bool get isWindows => Platform.isWindows;
|
||||
bool get isLinux => Platform.isLinux;
|
||||
bool get isMacOS => Platform.isMacOS;
|
||||
bool get isDesktop =>
|
||||
Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" &&
|
||||
deviceLocale.scriptCode == "Hant") {
|
||||
deviceLocale = const Locale("zh", "TW");
|
||||
}
|
||||
return deviceLocale;
|
||||
}
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
GlobalKey<NavigatorState>? mainNavigatorKey;
|
||||
|
||||
BuildContext get rootContext => rootNavigatorKey.currentContext!;
|
||||
|
||||
void rootPop() {
|
||||
rootNavigatorKey.currentState?.pop();
|
||||
}
|
||||
|
||||
void pop() {
|
||||
if(rootNavigatorKey.currentState?.canPop() ?? false) {
|
||||
rootNavigatorKey.currentState?.pop();
|
||||
} else {
|
||||
mainNavigatorKey?.currentState?.pop();
|
||||
}
|
||||
}
|
||||
|
||||
var mainColor = Colors.blue;
|
||||
|
||||
Future<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
mainColor = switch(appdata.settings['color']) {
|
||||
'red' => Colors.red,
|
||||
'pink' => Colors.pink,
|
||||
'purple' => Colors.purple,
|
||||
'green' => Colors.green,
|
||||
'orange' => Colors.orange,
|
||||
'blue' => Colors.blue,
|
||||
_ => Colors.blue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
final App = _App();
|
356
lib/foundation/app_page_route.dart
Normal file
356
lib/foundation/app_page_route.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
const int _kMaxPageBackAnimationTime = 300;
|
||||
const double _kMinFlingVelocity = 1.0;
|
||||
|
||||
class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
/// Construct a MaterialPageRoute whose contents are defined by [builder].
|
||||
AppPageRoute({
|
||||
required this.builder,
|
||||
super.settings,
|
||||
this.maintainState = true,
|
||||
super.fullscreenDialog,
|
||||
super.allowSnapshotting = true,
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.preventRebuild = true,
|
||||
this.isRootRoute = false,
|
||||
}) {
|
||||
assert(opaque);
|
||||
}
|
||||
|
||||
/// Builds the primary contents of the route.
|
||||
final WidgetBuilder builder;
|
||||
|
||||
String? label;
|
||||
|
||||
@override
|
||||
toString() => "/$label";
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context) {
|
||||
var widget = builder(context);
|
||||
label = widget.runtimeType.toString();
|
||||
return widget;
|
||||
}
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
|
||||
@override
|
||||
String get debugLabel => '${super.debugLabel}(${settings.name})';
|
||||
|
||||
@override
|
||||
final bool enableIOSGesture;
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
|
||||
@override
|
||||
final bool isRootRoute;
|
||||
}
|
||||
|
||||
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
/// Builds the primary contents of the route.
|
||||
@protected
|
||||
Widget buildContent(BuildContext context);
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||
// Don't perform outgoing animation if the next route is a fullscreen dialog.
|
||||
return nextRoute is PageRoute && !nextRoute.fullscreenDialog;
|
||||
}
|
||||
|
||||
bool get enableIOSGesture;
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
bool get isRootRoute;
|
||||
|
||||
Widget? _child;
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
Widget result;
|
||||
|
||||
if(preventRebuild){
|
||||
result = _child ?? (_child = buildContent(context));
|
||||
} else {
|
||||
result = buildContent(context);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
|
||||
if (route.isFirst ||
|
||||
route.willHandlePopInternally ||
|
||||
route.popDisposition == RoutePopDisposition.doNotPop ||
|
||||
route.fullscreenDialog ||
|
||||
route.animation!.status != AnimationStatus.completed ||
|
||||
route.secondaryAnimation!.status != AnimationStatus.dismissed ||
|
||||
route.navigator!.userGestureInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
if(isRootRoute) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: 1.0, end: 0).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SlidePageTransitionBuilder().buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
return IOSBackGestureController(route.controller!, route.navigator!);
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureController {
|
||||
final AnimationController controller;
|
||||
|
||||
final NavigatorState navigator;
|
||||
|
||||
IOSBackGestureController(this.controller, this.navigator) {
|
||||
navigator.didStartUserGesture();
|
||||
}
|
||||
|
||||
void dragEnd(double velocity) {
|
||||
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||
final bool animateForward;
|
||||
|
||||
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
animateForward = velocity <= 0;
|
||||
} else {
|
||||
animateForward = controller.value > 0.5;
|
||||
}
|
||||
|
||||
if (animateForward) {
|
||||
final droppedPageForwardAnimationTime = min(
|
||||
lerpDouble(
|
||||
_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value)!
|
||||
.floor(),
|
||||
_kMaxPageBackAnimationTime,
|
||||
);
|
||||
controller.animateTo(1.0,
|
||||
duration: Duration(milliseconds: droppedPageForwardAnimationTime),
|
||||
curve: animationCurve);
|
||||
} else {
|
||||
navigator.pop();
|
||||
if (controller.isAnimating) {
|
||||
final droppedPageBackAnimationTime = lerpDouble(
|
||||
0, _kMaxDroppedSwipePageForwardAnimationTime, controller.value)!
|
||||
.floor();
|
||||
controller.animateBack(0.0,
|
||||
duration: Duration(milliseconds: droppedPageBackAnimationTime),
|
||||
curve: animationCurve);
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.isAnimating) {
|
||||
late AnimationStatusListener animationStatusCallback;
|
||||
animationStatusCallback = (status) {
|
||||
navigator.didStopUserGesture();
|
||||
controller.removeStatusListener(animationStatusCallback);
|
||||
};
|
||||
controller.addStatusListener(animationStatusCallback);
|
||||
} else {
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
}
|
||||
|
||||
void dragUpdate(double delta) {
|
||||
controller.value -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureDetector extends StatefulWidget {
|
||||
const IOSBackGestureDetector(
|
||||
{required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key});
|
||||
|
||||
final double gestureWidth;
|
||||
|
||||
final bool Function() enabledCallback;
|
||||
|
||||
final IOSBackGestureController Function() onStartPopGesture;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
|
||||
}
|
||||
|
||||
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
IOSBackGestureController? _backGestureController;
|
||||
|
||||
late HorizontalDragGestureRecognizer _recognizer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
assert(mounted);
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
return -value;
|
||||
case TextDirection.ltr:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController == null);
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
PageRoute<T> route,
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child,),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
23
lib/foundation/appdata.dart
Normal file
23
lib/foundation/appdata.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
class _Appdata {
|
||||
final _Settings settings = _Settings();
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
||||
|
||||
class _Settings {
|
||||
_Settings();
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'comicDisplayMode': 'detailed', // detailed, brief
|
||||
'comicTileScale': 1.0, // 0.8-1.2
|
||||
'color': 'blue', // red, pink, purple, green, orange, blue
|
||||
'theme_mode': 'system', // light, dark, system
|
||||
'newFavoriteAddTo': 'end', // start, end
|
||||
'moveFavoriteAfterRead': 'none', // none, end, start
|
||||
'proxy': 'direct', // direct, system, proxy string
|
||||
};
|
||||
|
||||
operator[](String key) {
|
||||
return _data[key];
|
||||
}
|
||||
}
|
294
lib/foundation/cache_manager.dart
Normal file
294
lib/foundation/cache_manager.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class CacheManager {
|
||||
static String get cachePath => '${App.cachePath}/cache';
|
||||
|
||||
static CacheManager? instance;
|
||||
|
||||
late Database _db;
|
||||
|
||||
int? _currentSize;
|
||||
|
||||
/// size in bytes
|
||||
int get currentSize => _currentSize ?? 0;
|
||||
|
||||
int dir = 0;
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
CacheManager._create(){
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
dir TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
type TEXT
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
}
|
||||
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in MB
|
||||
void setLimitSize(int size){
|
||||
_limitSize = size * 1024 * 1024;
|
||||
}
|
||||
|
||||
void setType(String key, String? type){
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET type = ?
|
||||
WHERE key = ?
|
||||
''', [type, key]);
|
||||
}
|
||||
|
||||
String? getType(String key){
|
||||
var res = _db.select('''
|
||||
SELECT type FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
return res.first[0];
|
||||
}
|
||||
|
||||
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsBytes(data);
|
||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir.toString(), name, expires]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! + data.length;
|
||||
}
|
||||
if(_currentSize != null && _currentSize! > _limitSize){
|
||||
await checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<CachingFile> openWrite(String key) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
return CachingFile._(key, dir.toString(), name, file);
|
||||
}
|
||||
|
||||
Future<File?> findCache(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isChecking = false;
|
||||
|
||||
Future<void> checkCache() async{
|
||||
if(_isChecking){
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
for(var row in res){
|
||||
var dir = row[1] as int;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
|
||||
int count = 0;
|
||||
var res2 = _db.select('''
|
||||
SELECT COUNT(*) FROM cache
|
||||
''');
|
||||
if(res2.isNotEmpty){
|
||||
count = res2.first[0] as int;
|
||||
}
|
||||
|
||||
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
ORDER BY time ASC
|
||||
limit 10
|
||||
''');
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as int;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
var size = await file.length();
|
||||
await file.delete();
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
_currentSize = _currentSize! - size;
|
||||
if(_currentSize! <= _limitSize){
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
''');
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
Future<void> deleteKeyword(String keyword) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key LIKE ?
|
||||
''', ['%$keyword%']);
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
try {
|
||||
await file.delete();
|
||||
}
|
||||
finally {}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachingFile{
|
||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
||||
|
||||
final String key;
|
||||
|
||||
final String dir;
|
||||
|
||||
final String name;
|
||||
|
||||
final File file;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
Future<void> writeBytes(List<int> data) async{
|
||||
_buffer.addAll(data);
|
||||
if(_buffer.length > 1024 * 1024){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async{
|
||||
if(_buffer.isNotEmpty){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
}
|
||||
CacheManager()._db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
||||
}
|
||||
|
||||
Future<void> cancel() async{
|
||||
await file.deleteIgnoreError();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer.clear();
|
||||
if(file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
153
lib/foundation/comic_source/category.dart
Normal file
153
lib/foundation/comic_source/category.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
part of comic_source;
|
||||
|
||||
class CategoryData {
|
||||
/// The title is displayed in the tab bar.
|
||||
final String title;
|
||||
|
||||
/// 当使用中文语言时, 英文的分类标签将在构建页面时被翻译为中文
|
||||
final List<BaseCategoryPart> categories;
|
||||
|
||||
final bool enableRankingPage;
|
||||
|
||||
final String key;
|
||||
|
||||
final List<CategoryButtonData> buttons;
|
||||
|
||||
/// Data class for building category page.
|
||||
const CategoryData({
|
||||
required this.title,
|
||||
required this.categories,
|
||||
required this.enableRankingPage,
|
||||
required this.key,
|
||||
this.buttons = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class CategoryButtonData {
|
||||
final String label;
|
||||
|
||||
final void Function() onTap;
|
||||
|
||||
const CategoryButtonData({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class BaseCategoryPart {
|
||||
String get title;
|
||||
|
||||
List<String> get categories;
|
||||
|
||||
List<String>? get categoryParams => null;
|
||||
|
||||
bool get enableRandom;
|
||||
|
||||
String get categoryType;
|
||||
|
||||
/// Data class for building a part of category page.
|
||||
const BaseCategoryPart();
|
||||
}
|
||||
|
||||
class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final List<String> categories;
|
||||
|
||||
@override
|
||||
bool get enableRandom => false;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
@override
|
||||
final List<String>? categoryParams;
|
||||
|
||||
/// A [BaseCategoryPart] that show fixed tags on category page.
|
||||
const FixedCategoryPart(this.title, this.categories, this.categoryType,
|
||||
[this.categoryParams]);
|
||||
}
|
||||
|
||||
class RandomCategoryPart extends BaseCategoryPart {
|
||||
final List<String> tags;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
List<String> _categories() {
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags;
|
||||
}
|
||||
return tags.sublist(math.Random().nextInt(tags.length - randomNumber));
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
const RandomCategoryPart(
|
||||
this.title, this.tags, this.randomNumber, this.categoryType);
|
||||
}
|
||||
|
||||
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
|
||||
final Iterable<String> Function() loadTags;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
static final random = math.Random();
|
||||
|
||||
List<String> _categories() {
|
||||
var tags = loadTags();
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags.toList();
|
||||
}
|
||||
final start = random.nextInt(tags.length - randomNumber);
|
||||
var res = List.filled(randomNumber, '');
|
||||
int index = -1;
|
||||
for (var s in tags) {
|
||||
index++;
|
||||
if (start > index) {
|
||||
continue;
|
||||
} else if (index == start + randomNumber) {
|
||||
break;
|
||||
}
|
||||
res[index - start] = s;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
RandomCategoryPartWithRuntimeData(
|
||||
this.title, this.loadTags, this.randomNumber, this.categoryType);
|
||||
}
|
||||
|
||||
CategoryData getCategoryDataWithKey(String key) {
|
||||
for (var source in ComicSource.sources) {
|
||||
if (source.categoryData?.key == key) {
|
||||
return source.categoryData!;
|
||||
}
|
||||
}
|
||||
throw "Unknown category key $key";
|
||||
}
|
540
lib/foundation/comic_source/comic_source.dart
Normal file
540
lib/foundation/comic_source/comic_source.dart
Normal file
@@ -0,0 +1,540 @@
|
||||
library comic_source;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
import '../js_engine.dart';
|
||||
import '../log.dart';
|
||||
|
||||
part 'category.dart';
|
||||
|
||||
part 'favorites.dart';
|
||||
|
||||
part 'parser.dart';
|
||||
|
||||
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
class ComicSource {
|
||||
static List<ComicSource> sources = [];
|
||||
|
||||
static ComicSource? find(String key) =>
|
||||
sources.firstWhereOrNull((element) => element.key == key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
sources.firstWhereOrNull((element) => element.key.hashCode == key);
|
||||
|
||||
static Future<void> init() async {
|
||||
final path = "${App.dataPath}/comic_source";
|
||||
if (!(await Directory(path).exists())) {
|
||||
Directory(path).create();
|
||||
return;
|
||||
}
|
||||
await for (var entity in Directory(path).list()) {
|
||||
if (entity is File && entity.path.endsWith(".js")) {
|
||||
try {
|
||||
var source = await ComicSourceParser()
|
||||
.parse(await entity.readAsString(), entity.absolute.path);
|
||||
sources.add(source);
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSource", "$e\n$s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future reload() async {
|
||||
sources.clear();
|
||||
JsEngine().runCode("ComicSource.sources = {};");
|
||||
await init();
|
||||
}
|
||||
|
||||
/// Name of this source.
|
||||
final String name;
|
||||
|
||||
/// Identifier of this source.
|
||||
final String key;
|
||||
|
||||
int get intKey {
|
||||
return key.hashCode;
|
||||
}
|
||||
|
||||
/// Account config.
|
||||
final AccountConfig? account;
|
||||
|
||||
/// Category data used to build a static category tags page.
|
||||
final CategoryData? categoryData;
|
||||
|
||||
/// Category comics data used to build a comics page with a category tag.
|
||||
final CategoryComicsData? categoryComicsData;
|
||||
|
||||
/// Favorite data used to build favorite page.
|
||||
final FavoriteData? favoriteData;
|
||||
|
||||
/// Explore pages.
|
||||
final List<ExplorePageData> explorePages;
|
||||
|
||||
/// Search page.
|
||||
final SearchPageData? searchPageData;
|
||||
|
||||
/// Settings.
|
||||
final List<SettingItem> settings;
|
||||
|
||||
/// Load comic info.
|
||||
final LoadComicFunc? loadComicInfo;
|
||||
|
||||
/// Load comic pages.
|
||||
final LoadComicPagesFunc? loadComicPages;
|
||||
|
||||
final Map<String, dynamic> Function(
|
||||
String imageKey, String comicId, String epId)? getImageLoadingConfig;
|
||||
|
||||
final Map<String, dynamic> Function(String imageKey)?
|
||||
getThumbnailLoadingConfig;
|
||||
|
||||
final String? matchBriefIdReg;
|
||||
|
||||
var data = <String, dynamic>{};
|
||||
|
||||
bool get isLogin => data["account"] != null;
|
||||
|
||||
final String filePath;
|
||||
|
||||
final String url;
|
||||
|
||||
final String version;
|
||||
|
||||
final CommentsLoader? commentsLoader;
|
||||
|
||||
final SendCommentFunc? sendCommentFunc;
|
||||
|
||||
final RegExp? idMatcher;
|
||||
|
||||
Future<void> loadData() async {
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (await file.exists()) {
|
||||
data = Map.from(jsonDecode(await file.readAsString()));
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSaving = false;
|
||||
bool _haveWaitingTask = false;
|
||||
|
||||
Future<void> saveData() async {
|
||||
if (_haveWaitingTask) return;
|
||||
while (_isSaving) {
|
||||
_haveWaitingTask = true;
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
_haveWaitingTask = false;
|
||||
}
|
||||
_isSaving = true;
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (!await file.exists()) {
|
||||
await file.create(recursive: true);
|
||||
}
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
_isSaving = false;
|
||||
}
|
||||
|
||||
Future<bool> reLogin() async {
|
||||
if (data["account"] == null) {
|
||||
return false;
|
||||
}
|
||||
final List accountData = data["account"];
|
||||
var res = await account!.login!(accountData[0], accountData[1]);
|
||||
if (res.error) {
|
||||
Log.error("Failed to re-login", res.errorMessage ?? "Error");
|
||||
}
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
ComicSource(
|
||||
this.name,
|
||||
this.key,
|
||||
this.account,
|
||||
this.categoryData,
|
||||
this.categoryComicsData,
|
||||
this.favoriteData,
|
||||
this.explorePages,
|
||||
this.searchPageData,
|
||||
this.settings,
|
||||
this.loadComicInfo,
|
||||
this.loadComicPages,
|
||||
this.getImageLoadingConfig,
|
||||
this.getThumbnailLoadingConfig,
|
||||
this.matchBriefIdReg,
|
||||
this.filePath,
|
||||
this.url,
|
||||
this.version,
|
||||
this.commentsLoader,
|
||||
this.sendCommentFunc)
|
||||
: idMatcher = null;
|
||||
|
||||
ComicSource.unknown(this.key)
|
||||
: name = "Unknown",
|
||||
account = null,
|
||||
categoryData = null,
|
||||
categoryComicsData = null,
|
||||
favoriteData = null,
|
||||
explorePages = [],
|
||||
searchPageData = null,
|
||||
settings = [],
|
||||
loadComicInfo = null,
|
||||
loadComicPages = null,
|
||||
getImageLoadingConfig = null,
|
||||
getThumbnailLoadingConfig = null,
|
||||
matchBriefIdReg = null,
|
||||
filePath = "",
|
||||
url = "",
|
||||
version = "",
|
||||
commentsLoader = null,
|
||||
sendCommentFunc = null,
|
||||
idMatcher = null;
|
||||
}
|
||||
|
||||
class AccountConfig {
|
||||
final LoginFunction? login;
|
||||
|
||||
final FutureOr<void> Function(BuildContext)? onLogin;
|
||||
|
||||
final String? loginWebsite;
|
||||
|
||||
final String? registerWebsite;
|
||||
|
||||
final void Function() logout;
|
||||
|
||||
final bool allowReLogin;
|
||||
|
||||
final List<AccountInfoItem> infoItems;
|
||||
|
||||
const AccountConfig(
|
||||
this.login, this.loginWebsite, this.registerWebsite, this.logout,
|
||||
{this.onLogin})
|
||||
: allowReLogin = true,
|
||||
infoItems = const [];
|
||||
}
|
||||
|
||||
class AccountInfoItem {
|
||||
final String title;
|
||||
final String Function()? data;
|
||||
final void Function()? onTap;
|
||||
final WidgetBuilder? builder;
|
||||
|
||||
AccountInfoItem({required this.title, this.data, this.onTap, this.builder});
|
||||
}
|
||||
|
||||
class LoadImageRequest {
|
||||
String url;
|
||||
|
||||
Map<String, String> headers;
|
||||
|
||||
LoadImageRequest(this.url, this.headers);
|
||||
}
|
||||
|
||||
class ExplorePageData {
|
||||
final String title;
|
||||
|
||||
final ExplorePageType type;
|
||||
|
||||
final ComicListBuilder? loadPage;
|
||||
|
||||
final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
|
||||
/// return a `List` contains `List<Comic>` or `ExplorePagePart`
|
||||
final Future<Res<List<Object>>> Function(int index)? loadMixed;
|
||||
|
||||
final WidgetBuilder? overridePageBuilder;
|
||||
|
||||
ExplorePageData(this.title, this.type, this.loadPage, this.loadMultiPart)
|
||||
: loadMixed = null,
|
||||
overridePageBuilder = null;
|
||||
}
|
||||
|
||||
class ExplorePagePart {
|
||||
final String title;
|
||||
|
||||
final List<Comic> comics;
|
||||
|
||||
/// If this is not null, the [ExplorePagePart] will show a button to jump to new page.
|
||||
///
|
||||
/// Value of this field should match the following format:
|
||||
/// - search:keyword
|
||||
/// - category:categoryName
|
||||
///
|
||||
/// End with `@`+`param` if the category has a parameter.
|
||||
final String? viewMore;
|
||||
|
||||
const ExplorePagePart(this.title, this.comics, this.viewMore);
|
||||
}
|
||||
|
||||
enum ExplorePageType {
|
||||
multiPageComicList,
|
||||
singlePageWithMultiPart,
|
||||
mixed,
|
||||
override,
|
||||
}
|
||||
|
||||
typedef SearchFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, int page, List<String> searchOption);
|
||||
|
||||
class SearchPageData {
|
||||
/// If this is not null, the default value of search options will be first element.
|
||||
final List<SearchOptions>? searchOptions;
|
||||
|
||||
final Widget Function(BuildContext, List<String> initialValues, void Function(List<String>))?
|
||||
customOptionsBuilder;
|
||||
|
||||
final Widget Function(String keyword, List<String> options)?
|
||||
overrideSearchResultBuilder;
|
||||
|
||||
final SearchFunction? loadPage;
|
||||
|
||||
final bool enableLanguageFilter;
|
||||
|
||||
final bool enableTagsSuggestions;
|
||||
|
||||
const SearchPageData(this.searchOptions, this.loadPage)
|
||||
: enableLanguageFilter = false,
|
||||
customOptionsBuilder = null,
|
||||
overrideSearchResultBuilder = null,
|
||||
enableTagsSuggestions = false;
|
||||
}
|
||||
|
||||
class SearchOptions {
|
||||
final LinkedHashMap<String, String> options;
|
||||
|
||||
final String label;
|
||||
|
||||
const SearchOptions(this.options, this.label);
|
||||
|
||||
String get defaultValue => options.keys.first;
|
||||
}
|
||||
|
||||
class SettingItem {
|
||||
final String name;
|
||||
final String iconName;
|
||||
final SettingType type;
|
||||
final List<String>? options;
|
||||
|
||||
const SettingItem(this.name, this.iconName, this.type, this.options);
|
||||
}
|
||||
|
||||
enum SettingType {
|
||||
switcher,
|
||||
selector,
|
||||
input,
|
||||
}
|
||||
|
||||
class Comic {
|
||||
final String title;
|
||||
|
||||
final String cover;
|
||||
|
||||
final String id;
|
||||
|
||||
final String? subTitle;
|
||||
|
||||
final List<String>? tags;
|
||||
|
||||
final String description;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"cover": cover,
|
||||
"id": id,
|
||||
"subTitle": subTitle,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
};
|
||||
}
|
||||
|
||||
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"] ?? "",
|
||||
cover = json["cover"],
|
||||
id = json["id"],
|
||||
tags = List<String>.from(json["tags"] ?? []),
|
||||
description = json["description"] ?? "";
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String? subTitle;
|
||||
|
||||
@override
|
||||
final String cover;
|
||||
|
||||
final String? description;
|
||||
|
||||
final Map<String, List<String>> tags;
|
||||
|
||||
/// id-name
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
final List<String>? thumbnails;
|
||||
|
||||
final Future<Res<List<String>>> Function(String id, int page)?
|
||||
thumbnailLoader;
|
||||
|
||||
final int thumbnailMaxPage;
|
||||
|
||||
final List<Comic>? suggestions;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
final String comicId;
|
||||
|
||||
final bool? isFavorite;
|
||||
|
||||
final String? subId;
|
||||
|
||||
const ComicDetails(
|
||||
this.title,
|
||||
this.subTitle,
|
||||
this.cover,
|
||||
this.description,
|
||||
this.tags,
|
||||
this.chapters,
|
||||
this.thumbnails,
|
||||
this.thumbnailLoader,
|
||||
this.thumbnailMaxPage,
|
||||
this.suggestions,
|
||||
this.sourceKey,
|
||||
this.comicId,
|
||||
{this.isFavorite,
|
||||
this.subId});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"subTitle": subTitle,
|
||||
"cover": cover,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"chapters": chapters,
|
||||
"sourceKey": sourceKey,
|
||||
"comicId": comicId,
|
||||
"isFavorite": isFavorite,
|
||||
"subId": subId,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
res[key] = List<String>.from(value);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
ComicDetails.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"],
|
||||
cover = json["cover"],
|
||||
description = json["description"],
|
||||
tags = _generateMap(json["tags"]),
|
||||
chapters = Map<String, String>.from(json["chapters"]),
|
||||
sourceKey = json["sourceKey"],
|
||||
comicId = json["comicId"],
|
||||
thumbnails = null,
|
||||
thumbnailLoader = null,
|
||||
thumbnailMaxPage = 0,
|
||||
suggestions = null,
|
||||
isFavorite = json["isFavorite"],
|
||||
subId = json["subId"];
|
||||
|
||||
@override
|
||||
HistoryType get historyType => HistoryType(sourceKey.hashCode);
|
||||
|
||||
@override
|
||||
String get id => comicId;
|
||||
}
|
||||
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
final List<CategoryComicsOptions> options;
|
||||
|
||||
/// [category] is the one clicked by the user on the category page.
|
||||
|
||||
/// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null.
|
||||
///
|
||||
/// [Res.subData] should be maxPage or null if there is no limit.
|
||||
final CategoryComicsLoader load;
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData(this.options, this.load, {this.rankingData});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
final Map<String, String> options;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(String option, int page) load;
|
||||
|
||||
const RankingData(this.options, this.load);
|
||||
}
|
||||
|
||||
class CategoryComicsOptions {
|
||||
/// Use a [LinkedHashMap] to describe an option list.
|
||||
/// key is for loading comics, value is the name displayed on screen.
|
||||
/// Default value will be the first of the Map.
|
||||
final LinkedHashMap<String, String> options;
|
||||
|
||||
/// If [notShowWhen] contains category's name, the option will not be shown.
|
||||
final List<String> notShowWhen;
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
}
|
||||
|
||||
class Comment {
|
||||
final String userName;
|
||||
final String? avatar;
|
||||
final String content;
|
||||
final String? time;
|
||||
final int? replyCount;
|
||||
final String? id;
|
||||
|
||||
const Comment(this.userName, this.avatar, this.content, this.time,
|
||||
this.replyCount, this.id);
|
||||
}
|
50
lib/foundation/comic_source/favorites.dart
Normal file
50
lib/foundation/comic_source/favorites.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
typedef AddOrDelFavFunc = Future<Res<bool>> Function(String comicId, String folderId, bool isAdding);
|
||||
|
||||
class FavoriteData{
|
||||
final String key;
|
||||
|
||||
final String title;
|
||||
|
||||
final bool multiFolder;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page, [String? folder]) loadComic;
|
||||
|
||||
/// key-id, value-name
|
||||
///
|
||||
/// if comicId is not null, Res.subData is the folders that the comic is in
|
||||
final Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final Future<Res<bool>> Function(String key)? deleteFolder;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final Future<Res<bool>> Function(String name)? addFolder;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final String? allFavoritesId;
|
||||
|
||||
final AddOrDelFavFunc? addOrDelFavorite;
|
||||
|
||||
const FavoriteData({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.multiFolder,
|
||||
required this.loadComic,
|
||||
this.loadFolders,
|
||||
this.deleteFolder,
|
||||
this.addFolder,
|
||||
this.allFavoritesId,
|
||||
this.addOrDelFavorite});
|
||||
}
|
||||
|
||||
FavoriteData getFavoriteData(String key){
|
||||
var source = ComicSource.find(key) ?? (throw "Unknown source key: $key");
|
||||
return source.favoriteData!;
|
||||
}
|
||||
|
||||
FavoriteData? getFavoriteDataOrNull(String key){
|
||||
var source = ComicSource.find(key);
|
||||
return source?.favoriteData;
|
||||
}
|
652
lib/foundation/comic_source/parser.dart
Normal file
652
lib/foundation/comic_source/parser.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
bool compareSemVer(String ver1, String ver2) {
|
||||
ver1 = ver1.replaceFirst("-", ".");
|
||||
ver2 = ver2.replaceFirst("-", ".");
|
||||
List<String> v1 = ver1.split('.');
|
||||
List<String> v2 = ver2.split('.');
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int num1 = int.parse(v1[i]);
|
||||
int num2 = int.parse(v2[i]);
|
||||
|
||||
if (num1 > num2) {
|
||||
return true;
|
||||
} else if (num1 < num2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var v14 = v1.elementAtOrNull(3);
|
||||
var v24 = v2.elementAtOrNull(3);
|
||||
|
||||
if (v14 != v24) {
|
||||
if (v14 == null && v24 != "hotfix") {
|
||||
return true;
|
||||
} else if (v14 == null) {
|
||||
return false;
|
||||
}
|
||||
if (v24 == null) {
|
||||
if (v14 == "hotfix") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return v14.compareTo(v24) > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
class ComicSourceParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
ComicSourceParseException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
class ComicSourceParser {
|
||||
/// comic source key
|
||||
String? _key;
|
||||
|
||||
String? _name;
|
||||
|
||||
Future<ComicSource> createAndParse(String js, String fileName) async{
|
||||
if(!fileName.endsWith("js")){
|
||||
fileName = "$fileName.js";
|
||||
}
|
||||
var file = File("${App.dataPath}/comic_source/$fileName");
|
||||
if(file.existsSync()){
|
||||
int i = 0;
|
||||
while(file.existsSync()){
|
||||
file = File("${App.dataPath}/comic_source/$fileName($i).js");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
await file.writeAsString(js);
|
||||
try{
|
||||
return await parse(js, file.path);
|
||||
} catch (e) {
|
||||
await file.delete();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ComicSource> parse(String js, String filePath) async {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 = js.split('\n')
|
||||
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
|
||||
if(line1 == null || !line1.startsWith("class ") || !line1.contains("extends ComicSource")){
|
||||
throw ComicSourceParseException("Invalid Content");
|
||||
}
|
||||
var className = line1.split("class")[1].split("extends ComicSource").first;
|
||||
className = className.trim();
|
||||
JsEngine().runCode("""
|
||||
(() => {
|
||||
$js
|
||||
this['temp'] = new $className()
|
||||
}).call()
|
||||
""");
|
||||
_name = JsEngine().runCode("this['temp'].name")
|
||||
?? (throw ComicSourceParseException('name is required'));
|
||||
var key = JsEngine().runCode("this['temp'].key")
|
||||
?? (throw ComicSourceParseException('key is required'));
|
||||
var version = JsEngine().runCode("this['temp'].version")
|
||||
?? (throw ComicSourceParseException('version is required'));
|
||||
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
|
||||
var url = JsEngine().runCode("this['temp'].url");
|
||||
var matchBriefIdRegex = JsEngine().runCode("this['temp'].comic.matchBriefIdRegex");
|
||||
if(minAppVersion != null){
|
||||
if(compareSemVer(minAppVersion, App.version.split('-').first)){
|
||||
throw ComicSourceParseException("minAppVersion $minAppVersion is required");
|
||||
}
|
||||
}
|
||||
for(var source in ComicSource.sources){
|
||||
if(source.key == key){
|
||||
throw ComicSourceParseException("key($key) already exists");
|
||||
}
|
||||
}
|
||||
_key = key;
|
||||
_checkKeyValidation();
|
||||
|
||||
JsEngine().runCode("""
|
||||
ComicSource.sources.$_key = this['temp'];
|
||||
""");
|
||||
|
||||
final account = _loadAccountConfig();
|
||||
final explorePageData = _loadExploreData();
|
||||
final categoryPageData = _loadCategoryData();
|
||||
final categoryComicsData =
|
||||
_loadCategoryComicsData();
|
||||
final searchData = _loadSearchData();
|
||||
final loadComicFunc = _parseLoadComicFunc();
|
||||
final loadComicPagesFunc = _parseLoadComicPagesFunc();
|
||||
final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc();
|
||||
final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc();
|
||||
final favoriteData = _loadFavoriteData();
|
||||
final commentsLoader = _parseCommentsLoader();
|
||||
final sendCommentFunc = _parseSendCommentFunc();
|
||||
|
||||
var source = ComicSource(
|
||||
_name!,
|
||||
key,
|
||||
account,
|
||||
categoryPageData,
|
||||
categoryComicsData,
|
||||
favoriteData,
|
||||
explorePageData,
|
||||
searchData,
|
||||
[],
|
||||
loadComicFunc,
|
||||
loadComicPagesFunc,
|
||||
getImageLoadingConfigFunc,
|
||||
getThumbnailLoadingConfigFunc,
|
||||
matchBriefIdRegex,
|
||||
filePath,
|
||||
url ?? "",
|
||||
version ?? "1.0.0",
|
||||
commentsLoader,
|
||||
sendCommentFunc);
|
||||
|
||||
await source.loadData();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
_checkKeyValidation() {
|
||||
// 仅允许数字和字母以及下划线
|
||||
if (!_key!.contains(RegExp(r"^[a-zA-Z0-9_]+$"))) {
|
||||
throw ComicSourceParseException("key $_key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
bool _checkExists(String index){
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined");
|
||||
}
|
||||
|
||||
dynamic _getValue(String index) {
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index");
|
||||
}
|
||||
|
||||
AccountConfig? _loadAccountConfig() {
|
||||
if (!_checkExists("account")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Res<bool>> login(account, pwd) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
${jsonEncode(pwd)})
|
||||
""");
|
||||
var source = ComicSource.sources
|
||||
.firstWhere((element) => element.key == _key);
|
||||
source.data["account"] = <String>[account, pwd];
|
||||
source.saveData();
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void logout(){
|
||||
JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
|
||||
}
|
||||
|
||||
return AccountConfig(
|
||||
login,
|
||||
_getValue("account.login.website"),
|
||||
_getValue("account.registerWebsite"),
|
||||
logout
|
||||
);
|
||||
}
|
||||
|
||||
List<ExplorePageData> _loadExploreData() {
|
||||
if (!_checkExists("explore")) {
|
||||
return const [];
|
||||
}
|
||||
var length = JsEngine().runCode("ComicSource.sources.$_key.explore.length");
|
||||
var pages = <ExplorePageData>[];
|
||||
for (int i=0; i<length; i++) {
|
||||
final String title = _getValue("explore[$i].title");
|
||||
final String type = _getValue("explore[$i].type");
|
||||
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
if (type == "singlePageWithMultiPart") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
return Res(List.from(res.keys.map((e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null))
|
||||
.toList()));
|
||||
} catch (e, s) {
|
||||
Log.error("Data Analysis", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
} else if (type == "multiPageComicList") {
|
||||
loadPage = (int page) async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
pages.add(ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" =>
|
||||
ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
_ =>
|
||||
throw ComicSourceParseException("Unknown explore page type $type")
|
||||
},
|
||||
loadPage,
|
||||
loadMultiPart));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
CategoryData? _loadCategoryData() {
|
||||
var doc = _getValue("category");
|
||||
|
||||
if (doc?["title"] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String title = doc["title"];
|
||||
final bool? enableRankingPage = doc["enableRankingPage"];
|
||||
|
||||
var categoryParts = <BaseCategoryPart>[];
|
||||
|
||||
for (var c in doc["parts"]) {
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
final List<String>? categoryParams =
|
||||
c["categoryParams"] == null ? null : List.from(c["categoryParams"]);
|
||||
if (type == "fixed") {
|
||||
categoryParts
|
||||
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
|
||||
}
|
||||
}
|
||||
|
||||
return CategoryData(
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title);
|
||||
}
|
||||
|
||||
CategoryComicsData? _loadCategoryComicsData() {
|
||||
if (!_checkExists("categoryComics")) return null;
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList")) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"])
|
||||
));
|
||||
}
|
||||
RankingData? rankingData;
|
||||
if(_checkExists("categoryComics.ranking")){
|
||||
var options = <String, String>{};
|
||||
for(var option in _getValue("categoryComics.ranking.options")){
|
||||
if(option.isEmpty || !option.contains("-")){
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
options[key] = value;
|
||||
}
|
||||
rankingData = RankingData(options, (option, page) async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.ranking.load(
|
||||
${jsonEncode(option)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return CategoryComicsData(options, (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}, rankingData: rankingData);
|
||||
}
|
||||
|
||||
SearchPageData? _loadSearchData() {
|
||||
if (!_checkExists("search")) return null;
|
||||
var options = <SearchOptions>[];
|
||||
for (var element in _getValue("search.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(SearchOptions(map, element["label"]));
|
||||
}
|
||||
return SearchPageData(options, (keyword, page, searchOption) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.search.load(
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
LoadComicFunc? _parseLoadComicFunc() {
|
||||
return (id) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)})
|
||||
""");
|
||||
var tags = <String, List<String>>{};
|
||||
(res["tags"] as Map<String, dynamic>?)
|
||||
?.forEach((key, value) => tags[key] = List.from(value ?? const []));
|
||||
return Res(ComicDetails(
|
||||
res["title"],
|
||||
res["subTitle"],
|
||||
res["cover"],
|
||||
res["description"],
|
||||
tags,
|
||||
res["chapters"] == null ? null : Map.from(res["chapters"]),
|
||||
ListOrNull.from(res["thumbnails"]),
|
||||
// TODO: implement thumbnailLoader
|
||||
null,
|
||||
res["thumbnailMaxPage"] ?? 1,
|
||||
(res["recommend"] as List?)
|
||||
?.map((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
_key!,
|
||||
id,
|
||||
isFavorite: res["isFavorite"],
|
||||
subId: res["subId"],));
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
LoadComicPagesFunc? _parseLoadComicPagesFunc() {
|
||||
return (id, ep) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadEp(${jsonEncode(id)}, ${jsonEncode(ep)})
|
||||
""");
|
||||
return Res(List.from(res["images"]));
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
FavoriteData? _loadFavoriteData() {
|
||||
if (!_checkExists("favorites")) return null;
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async{
|
||||
if(!ComicSource.find(_key!)!.isLogin){
|
||||
return const Res.error("Not login");
|
||||
}
|
||||
var res = await func();
|
||||
if (res.error && res.errorMessage!.contains("Login expired")) {
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<Res<bool>> addOrDelFavFunc(comicId, folderId, isAdding) async {
|
||||
func() async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.addOrDelFavorite(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(folderId)}, ${jsonEncode(isAdding)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res<bool>.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return retryZone(func);
|
||||
}
|
||||
|
||||
Future<Res<List<Comic>>> loadComic(int page, [String? folder]) async {
|
||||
Future<Res<List<Comic>>> func() async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.loadComics(
|
||||
${jsonEncode(page)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
return retryZone(func);
|
||||
}
|
||||
|
||||
Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders;
|
||||
|
||||
Future<Res<bool>> Function(String name)? addFolder;
|
||||
|
||||
Future<Res<bool>> Function(String key)? deleteFolder;
|
||||
|
||||
if(multiFolder) {
|
||||
loadFolders = ([String? comicId]) async {
|
||||
Future<Res<Map<String, String>>> func() async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.loadFolders(${jsonEncode(comicId)})
|
||||
""");
|
||||
List<String>? subData;
|
||||
if(res["favorited"] != null){
|
||||
subData = List.from(res["favorited"]);
|
||||
}
|
||||
return Res(Map.from(res["folders"]), subData: subData);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return retryZone(func);
|
||||
};
|
||||
addFolder = (name) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.addFolder(${jsonEncode(name)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
deleteFolder = (key) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.deleteFolder(${jsonEncode(key)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return FavoriteData(
|
||||
key: _key!,
|
||||
title: _name!,
|
||||
multiFolder: multiFolder,
|
||||
loadComic: loadComic,
|
||||
loadFolders: loadFolders,
|
||||
addFolder: addFolder,
|
||||
deleteFolder: deleteFolder,
|
||||
addOrDelFavorite: addOrDelFavFunc,
|
||||
);
|
||||
}
|
||||
|
||||
CommentsLoader? _parseCommentsLoader(){
|
||||
if(!_checkExists("comic.loadComments")) return null;
|
||||
return (id, subId, page, replyTo) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadComments(
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment(
|
||||
e["userName"], e["avatar"], e["content"], e["time"], e["replyCount"], e["id"].toString()
|
||||
)).toList(),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SendCommentFunc? _parseSendCommentFunc(){
|
||||
if(!_checkExists("comic.sendComment")) return null;
|
||||
return (id, subId, content, replyTo) async {
|
||||
Future<Res<bool>> func() async{
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.sendComment(
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
var res = await func();
|
||||
if(res.error && res.errorMessage!.contains("Login expired")){
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc(){
|
||||
if(!_checkExists("comic.onImageLoad")){
|
||||
return null;
|
||||
}
|
||||
return (imageKey, comicId, ep) {
|
||||
return JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onImageLoad(
|
||||
${jsonEncode(imageKey)}, ${jsonEncode(comicId)}, ${jsonEncode(ep)})
|
||||
""") as Map<String, dynamic>;
|
||||
};
|
||||
}
|
||||
|
||||
GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc(){
|
||||
if(!_checkExists("comic.onThumbnailLoad")){
|
||||
return null;
|
||||
}
|
||||
return (imageKey) {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onThumbnailLoad(${jsonEncode(imageKey)})
|
||||
""");
|
||||
if(res is! Map) {
|
||||
Log.error("Network", "function onThumbnailLoad return invalid data");
|
||||
throw "function onThumbnailLoad return invalid data";
|
||||
}
|
||||
return res as Map<String, dynamic>;
|
||||
};
|
||||
}
|
||||
}
|
23
lib/foundation/comic_type.dart
Normal file
23
lib/foundation/comic_type.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
|
||||
class ComicType {
|
||||
final int value;
|
||||
|
||||
const ComicType(this.value);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is ComicType && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
ComicSource? get comicSource {
|
||||
if(this == local) {
|
||||
return null;
|
||||
} else {
|
||||
return ComicSource.sources.firstWhere((element) => element.intKey == value);
|
||||
}
|
||||
}
|
||||
|
||||
static const local = ComicType(0);
|
||||
}
|
6
lib/foundation/consts.dart
Normal file
6
lib/foundation/consts.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
const changePoint = 600;
|
||||
|
||||
const changePoint2 = 1300;
|
||||
|
||||
const webUA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
36
lib/foundation/context.dart
Normal file
36
lib/foundation/context.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_page_route.dart';
|
||||
|
||||
extension Navigation on BuildContext {
|
||||
void pop<T>([T? result]) {
|
||||
if(mounted) {
|
||||
Navigator.of(this).pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
bool canPop() {
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
|
||||
Future<T?> to<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.push<T>(AppPageRoute(builder: (context) => builder()));
|
||||
}
|
||||
|
||||
double get width => MediaQuery.of(this).size.width;
|
||||
|
||||
double get height => MediaQuery.of(this).size.height;
|
||||
|
||||
EdgeInsets get padding => MediaQuery.of(this).padding;
|
||||
|
||||
EdgeInsets get viewInsets => MediaQuery.of(this).viewInsets;
|
||||
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
Brightness get brightness => Theme.of(this).brightness;
|
||||
|
||||
void showMessage({required String message}) {
|
||||
// TODO: show message
|
||||
}
|
||||
}
|
487
lib/foundation/favorites.dart
Normal file
487
lib/foundation/favorites.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'app.dart';
|
||||
import 'comic_type.dart';
|
||||
|
||||
String _getCurTime() {
|
||||
return DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceFirst("T", " ")
|
||||
.substring(0, 19);
|
||||
}
|
||||
|
||||
class FavoriteItem {
|
||||
String name;
|
||||
String author;
|
||||
ComicType type;
|
||||
List<String> tags;
|
||||
String id;
|
||||
String coverPath;
|
||||
String time = _getCurTime();
|
||||
|
||||
FavoriteItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.coverPath,
|
||||
required this.author,
|
||||
required this.type,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
FavoriteItem.fromRow(Row row)
|
||||
: name = row["name"],
|
||||
author = row["author"],
|
||||
type = ComicType(row["type"]),
|
||||
tags = (row["tags"] as String).split(","),
|
||||
id = row["id"],
|
||||
coverPath = row["cover_path"],
|
||||
time = row["time"] {
|
||||
tags.remove("");
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FavoriteItem && other.id == id && other.type == type;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ type.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var s = "FavoriteItem: $name $author $coverPath $hashCode $tags";
|
||||
if(s.length > 100) {
|
||||
return s.substring(0, 100);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
class FavoriteItemWithFolderInfo {
|
||||
FavoriteItem comic;
|
||||
String folder;
|
||||
|
||||
FavoriteItemWithFolderInfo(this.comic, this.folder);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FavoriteItemWithFolderInfo &&
|
||||
other.comic == comic &&
|
||||
other.folder == folder;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => comic.hashCode ^ folder.hashCode;
|
||||
}
|
||||
|
||||
class LocalFavoritesManager {
|
||||
factory LocalFavoritesManager() =>
|
||||
cache ?? (cache = LocalFavoritesManager._create());
|
||||
|
||||
LocalFavoritesManager._create();
|
||||
|
||||
static LocalFavoritesManager? cache;
|
||||
|
||||
late Database _db;
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
||||
_db.execute("""
|
||||
create table if not exists folder_order (
|
||||
folder_name text primary key,
|
||||
order_value int
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
Future<List<String>> find(String id, ComicType type) async {
|
||||
var res = <String>[];
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
res.add(folder);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<List<String>> findWithModel(FavoriteItem item) async {
|
||||
var res = <String>[];
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [item.id, item.type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
res.add(folder);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
List<String> _getTablesWithDB() {
|
||||
final tables = _db
|
||||
.select("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
.map((element) => element["name"] as String)
|
||||
.toList();
|
||||
return tables;
|
||||
}
|
||||
|
||||
List<String> _getFolderNamesWithDB() {
|
||||
final folders = _getTablesWithDB();
|
||||
folders.remove('folder_sync');
|
||||
folders.remove('folder_order');
|
||||
var folderToOrder = <String, int>{};
|
||||
for (var folder in folders) {
|
||||
var res = _db.select("""
|
||||
select * from folder_order
|
||||
where folder_name == ?;
|
||||
""", [folder]);
|
||||
if (res.isNotEmpty) {
|
||||
folderToOrder[folder] = res.first["order_value"];
|
||||
} else {
|
||||
folderToOrder[folder] = 0;
|
||||
}
|
||||
}
|
||||
folders.sort((a, b) {
|
||||
return folderToOrder[a]! - folderToOrder[b]!;
|
||||
});
|
||||
return folders;
|
||||
}
|
||||
|
||||
void updateOrder(Map<String, int> order) {
|
||||
for (var folder in order.keys) {
|
||||
_db.execute("""
|
||||
insert or replace into folder_order (folder_name, order_value)
|
||||
values (?, ?);
|
||||
""", [folder, order[folder]]);
|
||||
}
|
||||
}
|
||||
|
||||
int count(String folderName) {
|
||||
return _db.select("""
|
||||
select count(*) as c
|
||||
from "$folderName"
|
||||
""").first["c"];
|
||||
}
|
||||
|
||||
List<String> get folderNames => _getFolderNamesWithDB();
|
||||
|
||||
int maxValue(String folder) {
|
||||
return _db.select("""
|
||||
SELECT MAX(display_order) AS max_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["max_value"] ?? 0;
|
||||
}
|
||||
|
||||
int minValue(String folder) {
|
||||
return _db.select("""
|
||||
SELECT MIN(display_order) AS min_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["min_value"] ?? 0;
|
||||
}
|
||||
|
||||
List<FavoriteItem> getAllComics(String folder) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
ORDER BY display_order;
|
||||
""");
|
||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
void addTagTo(String folder, String id, String tag) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set tags = '$tag,' || tags
|
||||
where id == ?
|
||||
""", [id]);
|
||||
}
|
||||
|
||||
List<FavoriteItemWithFolderInfo> allComics() {
|
||||
var res = <FavoriteItemWithFolderInfo>[];
|
||||
for (final folder in folderNames) {
|
||||
var comics = _db.select("""
|
||||
select * from "$folder";
|
||||
""");
|
||||
res.addAll(comics.map((element) =>
|
||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(element), folder)));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// create a folder
|
||||
String createFolder(String name, [bool renameWhenInvalidName = false]) {
|
||||
if (name.isEmpty) {
|
||||
if (renameWhenInvalidName) {
|
||||
int i = 0;
|
||||
while (folderNames.contains(i.toString())) {
|
||||
i++;
|
||||
}
|
||||
name = i.toString();
|
||||
} else {
|
||||
throw "name is empty!";
|
||||
}
|
||||
}
|
||||
if (folderNames.contains(name)) {
|
||||
if (renameWhenInvalidName) {
|
||||
var prevName = name;
|
||||
int i = 0;
|
||||
while (folderNames.contains(i.toString())) {
|
||||
i++;
|
||||
}
|
||||
name = prevName + i.toString();
|
||||
} else {
|
||||
throw Exception("Folder is existing");
|
||||
}
|
||||
}
|
||||
_db.execute("""
|
||||
create table "$name"(
|
||||
id text,
|
||||
name TEXT,
|
||||
author TEXT,
|
||||
type int,
|
||||
tags TEXT,
|
||||
cover_path TEXT,
|
||||
time TEXT,
|
||||
display_order int,
|
||||
primary key (id, type)
|
||||
);
|
||||
""");
|
||||
return name;
|
||||
}
|
||||
|
||||
bool comicExists(String folder, String id, ComicType type) {
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
return res.isNotEmpty;
|
||||
}
|
||||
|
||||
FavoriteItem getComic(String folder, String id, ComicType type) {
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (res.isEmpty) {
|
||||
throw Exception("Comic not found");
|
||||
}
|
||||
return FavoriteItem.fromRow(res.first);
|
||||
}
|
||||
|
||||
/// add comic to a folder
|
||||
///
|
||||
/// This method will download cover to local, to avoid problems like changing url
|
||||
void addComic(String folder, FavoriteItem comic, [int? order]) async {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!folderNames.contains(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == '${comic.id}';
|
||||
""");
|
||||
if (res.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
final params = [
|
||||
comic.id,
|
||||
comic.name,
|
||||
comic.author,
|
||||
comic.type.value,
|
||||
comic.tags.join(","),
|
||||
comic.coverPath,
|
||||
comic.time
|
||||
];
|
||||
if (order != null) {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, order]);
|
||||
} else if (appdata.settings['newFavoriteAddTo'] == "end") {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, maxValue(folder) + 1]);
|
||||
} else {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, minValue(folder) - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
delete from folder_sync where folder_name == ?;
|
||||
""", [name]);
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
}
|
||||
|
||||
void deleteComic(String folder, FavoriteItem comic) {
|
||||
_modifiedAfterLastCache = true;
|
||||
deleteComicWithId(folder, comic.id, comic.type);
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_db.dispose();
|
||||
File("${App.dataPath}/local_favorite.db").deleteSync();
|
||||
await init();
|
||||
}
|
||||
|
||||
void reorder(List<FavoriteItem> newFolder, String folder) async {
|
||||
if (!folderNames.contains(folder)) {
|
||||
throw Exception("Failed to reorder: folder not found");
|
||||
}
|
||||
deleteFolder(folder);
|
||||
createFolder(folder);
|
||||
for (int i = 0; i < newFolder.length; i++) {
|
||||
addComic(folder, newFolder[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
void rename(String before, String after) {
|
||||
if (folderNames.contains(after)) {
|
||||
throw "Name already exists!";
|
||||
}
|
||||
if (after.contains('"')) {
|
||||
throw "Invalid name";
|
||||
}
|
||||
_db.execute("""
|
||||
ALTER TABLE "$before"
|
||||
RENAME TO "$after";
|
||||
""");
|
||||
}
|
||||
|
||||
void onReadEnd(String id, ComicType type) async {
|
||||
_modifiedAfterLastCache = true;
|
||||
for (final folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
var newTime = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceFirst("T", " ")
|
||||
.substring(0, 19);
|
||||
String updateLocationSql = "";
|
||||
if (appdata.settings['moveFavoriteAfterRead'] == "end") {
|
||||
int maxValue = _db.select("""
|
||||
SELECT MAX(display_order) AS max_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["max_value"] ?? 0;
|
||||
updateLocationSql = "display_order = ${maxValue + 1},";
|
||||
} else if (appdata.settings['moveFavoriteAfterRead'] == "start") {
|
||||
int minValue = _db.select("""
|
||||
SELECT MIN(display_order) AS min_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["min_value"] ?? 0;
|
||||
updateLocationSql = "display_order = ${minValue - 1},";
|
||||
}
|
||||
_db.execute("""
|
||||
UPDATE "$folder"
|
||||
SET
|
||||
$updateLocationSql
|
||||
time = ?
|
||||
WHERE id == ?;
|
||||
""", [newTime, id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<FavoriteItemWithFolderInfo> search(String keyword) {
|
||||
var keywordList = keyword.split(" ");
|
||||
keyword = keywordList.first;
|
||||
var comics = <FavoriteItemWithFolderInfo>[];
|
||||
for (var table in folderNames) {
|
||||
keyword = "%$keyword%";
|
||||
var res = _db.select("""
|
||||
SELECT * FROM "$table"
|
||||
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ?;
|
||||
""", [keyword, keyword, keyword]);
|
||||
for (var comic in res) {
|
||||
comics.add(
|
||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
|
||||
}
|
||||
if (comics.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
|
||||
if (comic.comic.name.contains(keyword)) {
|
||||
return true;
|
||||
} else if (comic.comic.author.contains(keyword)) {
|
||||
return true;
|
||||
} else if (comic.comic.tags.any((element) => element.contains(keyword))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 1; i < keywordList.length; i++) {
|
||||
comics =
|
||||
comics.where((element) => test(element, keywordList[i])).toList();
|
||||
}
|
||||
|
||||
return comics;
|
||||
}
|
||||
|
||||
void editTags(String id, String folder, List<String> tags) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set tags = ?
|
||||
where id == ?;
|
||||
""", [tags.join(","), id]);
|
||||
}
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey(id);
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
|
||||
void _cacheFavoritedIds() {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var res = _db.select("""
|
||||
select id from "$folder";
|
||||
""");
|
||||
for (var row in res) {
|
||||
_cachedFavoritedIds[row["id"]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set name = ?, author = ?, cover_path = ?, tags = ?
|
||||
where id == ? and type == ?;
|
||||
""", [comic.name, comic.author, comic.coverPath, comic.tags.join(","), comic.id, comic.type.value]);
|
||||
}
|
||||
}
|
319
lib/foundation/history.dart
Normal file
319
lib/foundation/history.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'log.dart';
|
||||
|
||||
typedef HistoryType = ComicType;
|
||||
|
||||
abstract mixin class HistoryMixin {
|
||||
String get title;
|
||||
|
||||
String? get subTitle;
|
||||
|
||||
String get cover;
|
||||
|
||||
String get id;
|
||||
|
||||
int? get maxPage => null;
|
||||
|
||||
HistoryType get historyType;
|
||||
}
|
||||
|
||||
class History {
|
||||
HistoryType type;
|
||||
|
||||
DateTime time;
|
||||
|
||||
String title;
|
||||
|
||||
String subtitle;
|
||||
|
||||
String cover;
|
||||
|
||||
int ep;
|
||||
|
||||
int page;
|
||||
|
||||
String id;
|
||||
|
||||
Set<int> readEpisode;
|
||||
|
||||
int? maxPage;
|
||||
|
||||
History(this.type, this.time, this.title, this.subtitle, this.cover, this.ep,
|
||||
this.page, this.id,
|
||||
[this.readEpisode = const <int>{}, this.maxPage]);
|
||||
|
||||
History.fromModel(
|
||||
{required HistoryMixin model,
|
||||
required this.ep,
|
||||
required this.page,
|
||||
this.readEpisode = const <int>{},
|
||||
DateTime? time})
|
||||
: type = model.historyType,
|
||||
title = model.title,
|
||||
subtitle = model.subTitle ?? '',
|
||||
cover = model.cover,
|
||||
id = model.id,
|
||||
time = time ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
"type": type.value,
|
||||
"time": time.millisecondsSinceEpoch,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"cover": cover,
|
||||
"ep": ep,
|
||||
"page": page,
|
||||
"id": id,
|
||||
"readEpisode": readEpisode.toList(),
|
||||
"max_page": maxPage
|
||||
};
|
||||
|
||||
History.fromMap(Map<String, dynamic> map)
|
||||
: type = HistoryType(map["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
|
||||
title = map["title"],
|
||||
subtitle = map["subtitle"],
|
||||
cover = map["cover"],
|
||||
ep = map["ep"],
|
||||
page = map["page"],
|
||||
id = map["id"],
|
||||
readEpisode = Set<int>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
|
||||
maxPage = map["max_page"];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'History{type: $type, time: $time, title: $title, subtitle: $subtitle, cover: $cover, ep: $ep, page: $page, id: $id}';
|
||||
}
|
||||
|
||||
History.fromRow(Row row)
|
||||
: type = HistoryType(row["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(row["time"]),
|
||||
title = row["title"],
|
||||
subtitle = row["subtitle"],
|
||||
cover = row["cover"],
|
||||
ep = row["ep"],
|
||||
page = row["page"],
|
||||
id = row["id"],
|
||||
readEpisode = Set<int>.from((row["readEpisode"] as String)
|
||||
.split(',')
|
||||
.where((element) => element != "")
|
||||
.map((e) => int.parse(e))),
|
||||
maxPage = row["max_page"];
|
||||
|
||||
static Future<History> findOrCreate(
|
||||
HistoryMixin model, {
|
||||
int ep = 0,
|
||||
int page = 0,
|
||||
}) async {
|
||||
var history = await HistoryManager().find(model.id);
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: ep, page: page);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
|
||||
static Future<History> createIfNull(
|
||||
History? history, HistoryMixin model) async {
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: 0, page: 0);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryManager with ChangeNotifier {
|
||||
static HistoryManager? cache;
|
||||
|
||||
HistoryManager.create();
|
||||
|
||||
factory HistoryManager() =>
|
||||
cache == null ? (cache = HistoryManager.create()) : cache!;
|
||||
|
||||
late Database _db;
|
||||
|
||||
int get length => _db.select("select count(*) from history;").first[0] as int;
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
|
||||
Future<void> tryUpdateDb() async {
|
||||
var file = File("${App.dataPath}/history_temp.db");
|
||||
if (!file.existsSync()) {
|
||||
Log.info("HistoryManager.tryUpdateDb", "db file not exist");
|
||||
return;
|
||||
}
|
||||
var db = sqlite3.open(file.path);
|
||||
var newHistory0 = db.select("""
|
||||
select * from history
|
||||
order by time DESC;
|
||||
""");
|
||||
var newHistory =
|
||||
newHistory0.map((element) => History.fromRow(element)).toList();
|
||||
if (file.existsSync()) {
|
||||
var skips = 0;
|
||||
for (var history in newHistory) {
|
||||
if (findSync(history.id) == null) {
|
||||
addHistory(history);
|
||||
Log.info("HistoryManager", "merge history ${history.id}");
|
||||
} else {
|
||||
skips++;
|
||||
}
|
||||
}
|
||||
Log.info("HistoryManager",
|
||||
"merge history, skipped $skips, added ${newHistory.length - skips}");
|
||||
}
|
||||
db.dispose();
|
||||
file.deleteSync();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
_db.execute("""
|
||||
create table if not exists history (
|
||||
id text primary key,
|
||||
title text,
|
||||
subtitle text,
|
||||
cover text,
|
||||
time int,
|
||||
type int,
|
||||
ep int,
|
||||
page int,
|
||||
readEpisode text,
|
||||
max_page int
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/// add history. if exists, update time.
|
||||
///
|
||||
/// This function would be called when user start reading.
|
||||
Future<void> addHistory(History newItem) async {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
if (res.isEmpty) {
|
||||
_db.execute("""
|
||||
insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
newItem.id,
|
||||
newItem.title,
|
||||
newItem.subtitle,
|
||||
newItem.cover,
|
||||
newItem.time.millisecondsSinceEpoch,
|
||||
newItem.type.value,
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage
|
||||
]);
|
||||
} else {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> saveReadHistory(History history) async {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ?
|
||||
where id == ?;
|
||||
""", [
|
||||
history.ep,
|
||||
history.page,
|
||||
history.readEpisode.join(','),
|
||||
history.maxPage,
|
||||
history.id
|
||||
]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
_db.execute("delete from history;");
|
||||
updateCache();
|
||||
}
|
||||
|
||||
void remove(String id) async {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == '$id';
|
||||
""");
|
||||
updateCache();
|
||||
}
|
||||
|
||||
Future<History?> find(String id) async {
|
||||
return findSync(id);
|
||||
}
|
||||
|
||||
void updateCache() {
|
||||
_cachedHistory = {};
|
||||
var res = _db.select("""
|
||||
select * from history;
|
||||
""");
|
||||
for (var element in res) {
|
||||
_cachedHistory![element["id"] as String] = true;
|
||||
}
|
||||
}
|
||||
|
||||
History? findSync(String id) {
|
||||
if(_cachedHistory == null) {
|
||||
updateCache();
|
||||
}
|
||||
if (!_cachedHistory!.containsKey(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [id]);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return History.fromRow(res.first);
|
||||
}
|
||||
|
||||
List<History> getAll() {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
order by time DESC;
|
||||
""");
|
||||
return res.map((element) => History.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
/// 获取最近阅读的漫画
|
||||
List<History> getRecent() {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
order by time DESC
|
||||
limit 20;
|
||||
""");
|
||||
return res.map((element) => History.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
/// 获取历史记录的数量
|
||||
int count() {
|
||||
var res = _db.select("""
|
||||
select count(*) from history;
|
||||
""");
|
||||
return res.first[0] as int;
|
||||
}
|
||||
}
|
148
lib/foundation/image_provider/base_image_provider.dart
Normal file
148
lib/foundation/image_provider/base_image_provider.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:async' show Future, StreamController, scheduleMicrotask;
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
|
||||
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
extends ImageProvider<T> {
|
||||
const BaseImageProvider();
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadBufferAsync(key, chunkEvents, decode),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>(
|
||||
'Image provider: $this \n Image key: $key',
|
||||
this,
|
||||
style: DiagnosticsTreeStyle.errorProperty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadBufferAsync(
|
||||
T key,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
try {
|
||||
int retryTime = 1;
|
||||
|
||||
bool stop = false;
|
||||
|
||||
chunkEvents.onCancel = () {
|
||||
stop = true;
|
||||
};
|
||||
|
||||
Uint8List? data;
|
||||
|
||||
while (data == null && !stop) {
|
||||
try {
|
||||
if(_cache.containsKey(key.key)){
|
||||
data = _cache[key.key];
|
||||
} else {
|
||||
data = await load(chunkEvents);
|
||||
_checkCacheSize();
|
||||
_cache[key.key] = data;
|
||||
_cacheSize += data.length;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains("handshake")) {
|
||||
if (retryTime < 5) {
|
||||
retryTime = 5;
|
||||
}
|
||||
}
|
||||
retryTime <<= 1;
|
||||
if (retryTime > (1 << 3) || stop) {
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: retryTime));
|
||||
}
|
||||
}
|
||||
|
||||
if(stop) {
|
||||
throw Exception("Image loading is stopped");
|
||||
}
|
||||
|
||||
if(data!.isEmpty) {
|
||||
throw Exception("Empty image data");
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromUint8List(data);
|
||||
return await decode(buffer);
|
||||
} catch (e) {
|
||||
await CacheManager().delete(this.key);
|
||||
Object error = e;
|
||||
if (data.length < 2 * 1024) {
|
||||
// data is too short, it's likely that the data is text, not image
|
||||
try {
|
||||
var text = const Utf8Codec(allowMalformed: false).decoder.convert(data);
|
||||
error = Exception("Expected image data, but got text: $text");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (e) {
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
rethrow;
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
}
|
||||
}
|
||||
|
||||
static final _cache = LinkedHashMap<String, Uint8List>();
|
||||
|
||||
static var _cacheSize = 0;
|
||||
|
||||
static var _cacheSizeLimit = 50 * 1024 * 1024;
|
||||
|
||||
static void _checkCacheSize(){
|
||||
while (_cacheSize > _cacheSizeLimit){
|
||||
var firstKey = _cache.keys.first;
|
||||
_cacheSize -= _cache[firstKey]!.length;
|
||||
_cache.remove(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache(){
|
||||
_cache.clear();
|
||||
_cacheSize = 0;
|
||||
}
|
||||
|
||||
static void setCacheSizeLimit(int size){
|
||||
_cacheSizeLimit = size;
|
||||
_checkCacheSize();
|
||||
}
|
||||
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
|
||||
|
||||
String get key;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BaseImageProvider<T> && key == other.key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$runtimeType($key)";
|
||||
}
|
||||
}
|
||||
|
||||
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
|
80
lib/foundation/image_provider/cached_image.dart
Normal file
80
lib/foundation/image_provider/cached_image.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'cached_image.dart' as image_provider;
|
||||
|
||||
class CachedImageProvider
|
||||
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey});
|
||||
|
||||
final String url;
|
||||
|
||||
final Map<String, String>? headers;
|
||||
|
||||
final String? sourceKey;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
final cacheKey = "$url@$sourceKey";
|
||||
final cache = await CacheManager().findCache(cacheKey);
|
||||
|
||||
if (cache != null) {
|
||||
return await cache.readAsBytes();
|
||||
}
|
||||
|
||||
var configs = <String, dynamic>{};
|
||||
if (sourceKey != null) {
|
||||
var comicSource = ComicSource.find(sourceKey!);
|
||||
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
|
||||
}
|
||||
configs['headers'] ??= {
|
||||
'user-agent': webUA,
|
||||
};
|
||||
|
||||
var dio = AppDio(BaseOptions(
|
||||
headers: configs['headers'],
|
||||
method: configs['method'] ?? 'GET',
|
||||
responseType: ResponseType.stream,
|
||||
));
|
||||
|
||||
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
||||
data: configs['data']);
|
||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||
int? expectedBytes = req.data!.contentLength;
|
||||
if (expectedBytes == -1) {
|
||||
expectedBytes = null;
|
||||
}
|
||||
var buffer = <int>[];
|
||||
await for (var data in stream) {
|
||||
buffer.addAll(data);
|
||||
if (expectedBytes != null) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: buffer.length,
|
||||
expectedTotalBytes: expectedBytes,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if(configs['onResponse'] != null) {
|
||||
buffer = configs['onResponse'](buffer);
|
||||
}
|
||||
|
||||
await CacheManager().writeCache(cacheKey, buffer);
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => url;
|
||||
}
|
425
lib/foundation/js_engine.dart
Normal file
425
lib/foundation/js_engine.dart
Normal file
@@ -0,0 +1,425 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asn1/asn1_parser.dart';
|
||||
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
|
||||
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/block/aes.dart';
|
||||
import 'package:pointycastle/block/modes/cbc.dart';
|
||||
import 'package:pointycastle/block/modes/cfb.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
import 'comic_source/comic_source.dart';
|
||||
import 'consts.dart';
|
||||
import 'log.dart';
|
||||
|
||||
|
||||
class JavaScriptRuntimeException implements Exception {
|
||||
final String message;
|
||||
|
||||
JavaScriptRuntimeException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "JSException: $message";
|
||||
}
|
||||
}
|
||||
|
||||
class JsEngine with _JSEngineApi{
|
||||
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||
|
||||
static JsEngine? _cache;
|
||||
|
||||
JsEngine._create();
|
||||
|
||||
FlutterQjs? _engine;
|
||||
|
||||
bool _closed = true;
|
||||
|
||||
Dio? _dio;
|
||||
|
||||
static void reset(){
|
||||
_cache = null;
|
||||
_cache?.dispose();
|
||||
JsEngine().init();
|
||||
}
|
||||
|
||||
Future<void> init() async{
|
||||
if (!_closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_dio ??= AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
||||
_dio!.interceptors.add(CookieManagerSql(_cookieJar!));
|
||||
// TODO: Cloudflare Interceptor
|
||||
// _dio!.interceptors.add(CloudflareInterceptor());
|
||||
_closed = false;
|
||||
_engine = FlutterQjs();
|
||||
_engine!.dispatch();
|
||||
var setGlobalFunc = _engine!.evaluate(
|
||||
"(key, value) => { this[key] = value; }");
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
_engine!.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
||||
}
|
||||
catch(e, s){
|
||||
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _messageReceiver(dynamic message) {
|
||||
try {
|
||||
if (message is Map<dynamic, dynamic>) {
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
{
|
||||
String level = message["level"];
|
||||
Log.addLog(
|
||||
switch (level) {
|
||||
"error" => LogLevel.error,
|
||||
"warning" => LogLevel.warning,
|
||||
"info" => LogLevel.info,
|
||||
_ => LogLevel.warning
|
||||
},
|
||||
message["title"],
|
||||
message["content"].toString());
|
||||
}
|
||||
case 'load_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
return ComicSource.sources
|
||||
.firstWhereOrNull((element) => element.key == key)
|
||||
?.data[dataKey];
|
||||
}
|
||||
case 'save_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var data = message["data"];
|
||||
var source = ComicSource.sources
|
||||
.firstWhere((element) => element.key == key);
|
||||
source.data[dataKey] = data;
|
||||
source.saveData();
|
||||
}
|
||||
case 'delete_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var source = ComicSource.sources
|
||||
.firstWhereOrNull((element) => element.key == key);
|
||||
source?.data.remove(dataKey);
|
||||
source?.saveData();
|
||||
}
|
||||
case 'http':
|
||||
{
|
||||
return _http(Map.from(message));
|
||||
}
|
||||
case 'html':
|
||||
{
|
||||
return handleHtmlCallback(Map.from(message));
|
||||
}
|
||||
case 'convert':
|
||||
{
|
||||
return _convert(Map.from(message));
|
||||
}
|
||||
case "random":
|
||||
{
|
||||
return _randomInt(message["min"], message["max"]);
|
||||
}
|
||||
case "cookie":
|
||||
{
|
||||
return handleCookieCallback(Map.from(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e, s){
|
||||
Log.error("Failed to handle message: $message\n$e\n$s", "JsEngine");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _http(Map<String, dynamic> req) async{
|
||||
Response? response;
|
||||
String? error;
|
||||
|
||||
try {
|
||||
var headers = Map<String, dynamic>.from(req["headers"] ?? {});
|
||||
if(headers["user-agent"] == null && headers["User-Agent"] == null){
|
||||
headers["User-Agent"] = webUA;
|
||||
}
|
||||
response = await _dio!.request(req["url"], data: req["data"], options: Options(
|
||||
method: req['http_method'],
|
||||
responseType: req["bytes"] == true ? ResponseType.bytes : ResponseType.plain,
|
||||
headers: headers
|
||||
));
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
|
||||
Map<String, String> headers = {};
|
||||
|
||||
response?.headers.forEach((name, values) => headers[name] = values.join(','));
|
||||
|
||||
dynamic body = response?.data;
|
||||
if(body is! Uint8List && body is List<int>) {
|
||||
body = Uint8List.fromList(body);
|
||||
}
|
||||
|
||||
return {
|
||||
"status": response?.statusCode,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"error": error,
|
||||
};
|
||||
}
|
||||
|
||||
dynamic runCode(String js, [String? name]) {
|
||||
return _engine!.evaluate(js, name: name);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cache = null;
|
||||
_closed = true;
|
||||
_engine?.close();
|
||||
_engine?.port.close();
|
||||
}
|
||||
}
|
||||
|
||||
mixin class _JSEngineApi{
|
||||
final Map<int, dom.Document> _documents = {};
|
||||
final Map<int, dom.Element> _elements = {};
|
||||
CookieJarSql? _cookieJar;
|
||||
|
||||
dynamic handleHtmlCallback(Map<String, dynamic> data) {
|
||||
switch (data["function"]) {
|
||||
case "parse":
|
||||
_documents[data["key"]] = html.parse(data["data"]);
|
||||
return null;
|
||||
case "querySelector":
|
||||
var res = _documents[data["key"]]!.querySelector(data["query"]);
|
||||
if(res == null) return null;
|
||||
_elements[_elements.length] = res;
|
||||
return _elements.length - 1;
|
||||
case "querySelectorAll":
|
||||
var res = _documents[data["key"]]!.querySelectorAll(data["query"]);
|
||||
var keys = <int>[];
|
||||
for(var element in res){
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
case "getText":
|
||||
return _elements[data["key"]]!.text;
|
||||
case "getAttributes":
|
||||
return _elements[data["key"]]!.attributes;
|
||||
case "dom_querySelector":
|
||||
var res = _elements[data["key"]]!.querySelector(data["query"]);
|
||||
if(res == null) return null;
|
||||
_elements[_elements.length] = res;
|
||||
return _elements.length - 1;
|
||||
case "dom_querySelectorAll":
|
||||
var res = _elements[data["key"]]!.querySelectorAll(data["query"]);
|
||||
var keys = <int>[];
|
||||
for(var element in res){
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
case "getChildren":
|
||||
var res = _elements[data["key"]]!.children;
|
||||
var keys = <int>[];
|
||||
for (var element in res) {
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic handleCookieCallback(Map<String, dynamic> data) {
|
||||
switch (data["function"]) {
|
||||
case "set":
|
||||
_cookieJar!.saveFromResponse(
|
||||
Uri.parse(data["url"]),
|
||||
(data["cookies"] as List).map((e) {
|
||||
var c = Cookie(e["name"], e["value"]);
|
||||
if(e['domain'] != null){
|
||||
c.domain = e['domain'];
|
||||
}
|
||||
return c;
|
||||
}).toList());
|
||||
return null;
|
||||
case "get":
|
||||
var cookies = _cookieJar!.loadForRequest(Uri.parse(data["url"]));
|
||||
return cookies.map((e) => {
|
||||
"name": e.name,
|
||||
"value": e.value,
|
||||
"domain": e.domain,
|
||||
"path": e.path,
|
||||
"expires": e.expires,
|
||||
"max-age": e.maxAge,
|
||||
"secure": e.secure,
|
||||
"httpOnly": e.httpOnly,
|
||||
"session": e.expires == null,
|
||||
}).toList();
|
||||
case "delete":
|
||||
clearCookies([data["url"]]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void clear(){
|
||||
_documents.clear();
|
||||
_elements.clear();
|
||||
}
|
||||
|
||||
void clearCookies(List<String> domains) async{
|
||||
for(var domain in domains){
|
||||
var uri = Uri.tryParse(domain);
|
||||
if(uri == null) continue;
|
||||
_cookieJar!.deleteUri(uri);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _convert(Map<String, dynamic> data) {
|
||||
String type = data["type"];
|
||||
var value = data["value"];
|
||||
bool isEncode = data["isEncode"];
|
||||
try {
|
||||
switch (type) {
|
||||
case "base64":
|
||||
if(value is String){
|
||||
value = utf8.encode(value);
|
||||
}
|
||||
return isEncode
|
||||
? base64Encode(value)
|
||||
: base64Decode(value);
|
||||
case "md5":
|
||||
return Uint8List.fromList(md5.convert(value).bytes);
|
||||
case "sha1":
|
||||
return Uint8List.fromList(sha1.convert(value).bytes);
|
||||
case "sha256":
|
||||
return Uint8List.fromList(sha256.convert(value).bytes);
|
||||
case "sha512":
|
||||
return Uint8List.fromList(sha512.convert(value).bytes);
|
||||
case "aes-ecb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var cipher = ECBBlockCipher(AESEngine());
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-cbc":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var iv = data["iv"];
|
||||
var cipher = CBCBlockCipher(AESEngine());
|
||||
cipher.init(false, ParametersWithIV(KeyParameter(key), iv));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-cfb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = CFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-ofb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = OFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "rsa":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(
|
||||
false, PrivateKeyParameter<RSAPrivateKey>(_parsePrivateKey(key)));
|
||||
return _processInBlocks(cipher, value);
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("JS Engine", "Failed to convert $type: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
RSAPrivateKey _parsePrivateKey(String privateKeyString) {
|
||||
List<int> privateKeyDER = base64Decode(privateKeyString);
|
||||
var asn1Parser = ASN1Parser(privateKeyDER as Uint8List);
|
||||
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
final privateKey = topLevelSeq.elements![2];
|
||||
|
||||
asn1Parser = ASN1Parser(privateKey.valueBytes!);
|
||||
final pkSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
final modulus = pkSeq.elements![1] as ASN1Integer;
|
||||
final privateExponent = pkSeq.elements![3] as ASN1Integer;
|
||||
final p = pkSeq.elements![4] as ASN1Integer;
|
||||
final q = pkSeq.elements![5] as ASN1Integer;
|
||||
|
||||
return RSAPrivateKey(modulus.integer!, privateExponent.integer!, p.integer!, q.integer!);
|
||||
}
|
||||
|
||||
Uint8List _processInBlocks(
|
||||
AsymmetricBlockCipher engine, Uint8List input) {
|
||||
final numBlocks = input.length ~/ engine.inputBlockSize +
|
||||
((input.length % engine.inputBlockSize != 0) ? 1 : 0);
|
||||
|
||||
final output = Uint8List(numBlocks * engine.outputBlockSize);
|
||||
|
||||
var inputOffset = 0;
|
||||
var outputOffset = 0;
|
||||
while (inputOffset < input.length) {
|
||||
final chunkSize = (inputOffset + engine.inputBlockSize <= input.length)
|
||||
? engine.inputBlockSize
|
||||
: input.length - inputOffset;
|
||||
|
||||
outputOffset += engine.processBlock(
|
||||
input, inputOffset, chunkSize, output, outputOffset);
|
||||
|
||||
inputOffset += chunkSize;
|
||||
}
|
||||
|
||||
return (output.length == outputOffset)
|
||||
? output
|
||||
: output.sublist(0, outputOffset);
|
||||
}
|
||||
|
||||
int _randomInt(int min, int max) {
|
||||
return (min + (max - min) * math.Random().nextDouble()).toInt();
|
||||
}
|
||||
}
|
201
lib/foundation/local.dart
Normal file
201
lib/foundation/local.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class LocalComic {
|
||||
final int id;
|
||||
|
||||
final String title;
|
||||
|
||||
final String subtitle;
|
||||
|
||||
final List<String> tags;
|
||||
|
||||
/// name of the directory, which is in `LocalManager.path`
|
||||
final String directory;
|
||||
|
||||
/// key: chapter id, value: chapter title
|
||||
///
|
||||
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
/// relative path to the cover image
|
||||
final String cover;
|
||||
|
||||
final ComicType comicType;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
const LocalComic({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.tags,
|
||||
required this.directory,
|
||||
required this.chapters,
|
||||
required this.cover,
|
||||
required this.comicType,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
LocalComic.fromRow(Row row)
|
||||
: id = row[0] as int,
|
||||
title = row[1] as String,
|
||||
subtitle = row[2] as String,
|
||||
tags = List.from(jsonDecode(row[3] as String)),
|
||||
directory = row[4] as String,
|
||||
chapters = Map.from(jsonDecode(row[5] as String)),
|
||||
cover = row[6] as String,
|
||||
comicType = ComicType(row[7] as int),
|
||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int);
|
||||
|
||||
File get coverFile => File('${LocalManager().path}/$directory/$cover');
|
||||
}
|
||||
|
||||
class LocalManager with ChangeNotifier {
|
||||
static LocalManager? _instance;
|
||||
|
||||
LocalManager._();
|
||||
|
||||
factory LocalManager() {
|
||||
return _instance ??= LocalManager._();
|
||||
}
|
||||
|
||||
late Database _db;
|
||||
|
||||
late String path;
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
);
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS comics (
|
||||
id INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
chapters TEXT NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
comic_type INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
PRIMARY KEY (id, comic_type)
|
||||
);
|
||||
''');
|
||||
if(File('${App.dataPath}/local_path').existsSync()){
|
||||
path = File('${App.dataPath}/local_path').readAsStringSync();
|
||||
} else {
|
||||
if(App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if(external != null && external.isNotEmpty){
|
||||
path = '${external.first.path}/local';
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
}
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
}
|
||||
}
|
||||
if(!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
}
|
||||
}
|
||||
|
||||
int findValidId(ComicType type) {
|
||||
final res = _db.select(
|
||||
'SELECT id FROM comics WHERE comic_type = ? ORDER BY id DESC LIMIT 1;',
|
||||
[type.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return 1;
|
||||
}
|
||||
return (res.first[0] as int) + 1;
|
||||
}
|
||||
|
||||
Future<void> add(LocalComic comic, [int? id]) async {
|
||||
_db.execute(
|
||||
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
id ?? comic.id,
|
||||
comic.title,
|
||||
comic.subtitle,
|
||||
jsonEncode(comic.tags),
|
||||
comic.directory,
|
||||
jsonEncode(comic.chapters),
|
||||
comic.cover,
|
||||
comic.comicType.value,
|
||||
comic.createdAt.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(int id, ComicType comicType) async {
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeComic(LocalComic comic) {
|
||||
remove(comic.id, comic.comicType);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<LocalComic> getComics() {
|
||||
final res = _db.select('SELECT * FROM comics;');
|
||||
return res.map((row) => LocalComic.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
LocalComic? find(int id, ComicType comicType) {
|
||||
final res = _db.select(
|
||||
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return LocalComic.fromRow(res.first);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_db.dispose();
|
||||
}
|
||||
|
||||
List<LocalComic> getRecent() {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
''');
|
||||
return res.map((row) => LocalComic.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
int get count {
|
||||
final res = _db.select('''
|
||||
SELECT COUNT(*) FROM comics;
|
||||
''');
|
||||
return res.first[0] as int;
|
||||
}
|
||||
|
||||
LocalComic? findByName(String name) {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
WHERE title = ? OR directory = ?;
|
||||
''', [name, name]);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return LocalComic.fromRow(res.first);
|
||||
}
|
||||
}
|
99
lib/foundation/log.dart
Normal file
99
lib/foundation/log.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
class LogItem {
|
||||
final LogLevel level;
|
||||
final String title;
|
||||
final String content;
|
||||
final DateTime time = DateTime.now();
|
||||
|
||||
@override
|
||||
toString() => "${level.name} $title $time \n$content\n\n";
|
||||
|
||||
LogItem(this.level, this.title, this.content);
|
||||
}
|
||||
|
||||
enum LogLevel { error, warning, info }
|
||||
|
||||
class Log {
|
||||
static final List<LogItem> _logs = <LogItem>[];
|
||||
|
||||
static List<LogItem> get logs => _logs;
|
||||
|
||||
static const maxLogLength = 3000;
|
||||
|
||||
static const maxLogNumber = 500;
|
||||
|
||||
static bool ignoreLimitation = false;
|
||||
|
||||
/// only for debug
|
||||
static const String? logFile = null;
|
||||
|
||||
static void printWarning(String text) {
|
||||
print('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void printError(String text) {
|
||||
print('\x1B[31m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (!ignoreLimitation && content.length > maxLogLength) {
|
||||
content = "${content.substring(0, maxLogLength)}...";
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
switch (level) {
|
||||
case LogLevel.error:
|
||||
printError(content);
|
||||
case LogLevel.warning:
|
||||
printWarning(content);
|
||||
case LogLevel.info:
|
||||
print(content);
|
||||
}
|
||||
}
|
||||
|
||||
var newLog = LogItem(level, title, content);
|
||||
|
||||
if (newLog == _logs.lastOrNull) {
|
||||
return;
|
||||
}
|
||||
|
||||
_logs.add(newLog);
|
||||
if(logFile != null) {
|
||||
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append);
|
||||
}
|
||||
if (_logs.length > maxLogNumber) {
|
||||
var res = _logs.remove(
|
||||
_logs.firstWhereOrNull((element) => element.level == LogLevel.info));
|
||||
if (!res) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static info(String title, String content) {
|
||||
addLog(LogLevel.info, title, content);
|
||||
}
|
||||
|
||||
static warning(String title, String content) {
|
||||
addLog(LogLevel.warning, title, content);
|
||||
}
|
||||
|
||||
static error(String title, String content) {
|
||||
addLog(LogLevel.error, title, content);
|
||||
}
|
||||
|
||||
static void clear() => _logs.clear();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var res = "Logs\n\n";
|
||||
for (var log in _logs) {
|
||||
res += log.toString();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
36
lib/foundation/res.dart
Normal file
36
lib/foundation/res.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
class Res<T> {
|
||||
/// error info
|
||||
final String? errorMessage;
|
||||
|
||||
/// data
|
||||
final T? _data;
|
||||
|
||||
/// is there an error
|
||||
bool get error => errorMessage != null;
|
||||
|
||||
/// whether succeed
|
||||
bool get success => !error;
|
||||
|
||||
/// data
|
||||
T get data => _data ?? (throw Exception(errorMessage));
|
||||
|
||||
/// get data, or null if there is an error
|
||||
T? get dataOrNull => _data;
|
||||
|
||||
final dynamic subData;
|
||||
|
||||
@override
|
||||
String toString() => _data.toString();
|
||||
|
||||
Res.fromErrorRes(Res another, {this.subData})
|
||||
: _data = null,
|
||||
errorMessage = another.errorMessage;
|
||||
|
||||
/// network result
|
||||
const Res(this._data, {this.errorMessage, this.subData});
|
||||
|
||||
const Res.error(String err)
|
||||
: _data = null,
|
||||
subData = null,
|
||||
errorMessage = err;
|
||||
}
|
231
lib/foundation/state_controller.dart
Normal file
231
lib/foundation/state_controller.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SimpleController extends StateController {
|
||||
final void Function()? refresh_;
|
||||
|
||||
SimpleController({this.refresh_});
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
(refresh_ ?? super.refresh)();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StateController {
|
||||
static final _controllers = <StateControllerWrapped>[];
|
||||
|
||||
static T put<T extends StateController>(T controller,
|
||||
{Object? tag, bool autoRemove = false}) {
|
||||
_controllers.add(StateControllerWrapped(controller, autoRemove, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
static T putIfNotExists<T extends StateController>(T controller,
|
||||
{Object? tag, bool autoRemove = false}) {
|
||||
return findOrNull<T>(tag: tag) ??
|
||||
put(controller, tag: tag, autoRemove: autoRemove);
|
||||
}
|
||||
|
||||
static T find<T extends StateController>({Object? tag}) {
|
||||
try {
|
||||
return _controllers
|
||||
.lastWhere((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.controller as T;
|
||||
} catch (e) {
|
||||
throw StateError("$T with tag $tag Not Found");
|
||||
}
|
||||
}
|
||||
|
||||
static List<T> findAll<T extends StateController>({Object? tag}) {
|
||||
return _controllers
|
||||
.where((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.map((e) => e.controller as T)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static T? findOrNull<T extends StateController>({Object? tag}) {
|
||||
try {
|
||||
return _controllers
|
||||
.lastWhere((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.controller as T;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void remove<T>([Object? tag, bool check = false]) {
|
||||
for (int i = _controllers.length - 1; i >= 0; i--) {
|
||||
var element = _controllers[i];
|
||||
if (element.controller is T && (tag == null || tag == element.tag)) {
|
||||
if (check && !element.autoRemove) {
|
||||
continue;
|
||||
}
|
||||
_controllers.removeAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SimpleController putSimpleController(
|
||||
void Function() onUpdate, Object? tag,
|
||||
{void Function()? refresh}) {
|
||||
var controller = SimpleController(refresh_: refresh);
|
||||
controller.stateUpdaters.add(Pair(null, onUpdate));
|
||||
_controllers.add(StateControllerWrapped(controller, false, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
List<Pair<Object?, void Function()>> stateUpdaters = [];
|
||||
|
||||
void update([List<Object>? ids]) {
|
||||
if (ids == null) {
|
||||
for (var element in stateUpdaters) {
|
||||
element.right();
|
||||
}
|
||||
} else {
|
||||
for (var element in stateUpdaters) {
|
||||
if (ids.contains(element.left)) {
|
||||
element.right();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controllers.removeWhere((element) => element.controller == this);
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class StateControllerWrapped {
|
||||
StateController controller;
|
||||
bool autoRemove;
|
||||
Object? tag;
|
||||
|
||||
StateControllerWrapped(this.controller, this.autoRemove, this.tag);
|
||||
}
|
||||
|
||||
class StateBuilder<T extends StateController> extends StatefulWidget {
|
||||
const StateBuilder({
|
||||
super.key,
|
||||
this.init,
|
||||
this.dispose,
|
||||
this.initState,
|
||||
this.tag,
|
||||
required this.builder,
|
||||
this.id,
|
||||
});
|
||||
|
||||
final T? init;
|
||||
|
||||
final void Function(T controller)? dispose;
|
||||
|
||||
final void Function(T controller)? initState;
|
||||
|
||||
final Object? tag;
|
||||
|
||||
final Widget Function(T controller) builder;
|
||||
|
||||
Widget builderWrapped(StateController controller) {
|
||||
return builder(controller as T);
|
||||
}
|
||||
|
||||
void initStateWrapped(StateController controller) {
|
||||
return initState?.call(controller as T);
|
||||
}
|
||||
|
||||
void disposeWrapped(StateController controller) {
|
||||
return dispose?.call(controller as T);
|
||||
}
|
||||
|
||||
final Object? id;
|
||||
|
||||
@override
|
||||
State<StateBuilder> createState() => _StateBuilderState<T>();
|
||||
}
|
||||
|
||||
class _StateBuilderState<T extends StateController>
|
||||
extends State<StateBuilder> {
|
||||
late T controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.init != null) {
|
||||
StateController.put(widget.init!, tag: widget.tag, autoRemove: true);
|
||||
}
|
||||
try {
|
||||
controller = StateController.find<T>(tag: widget.tag);
|
||||
} catch (e) {
|
||||
throw "Controller Not Found";
|
||||
}
|
||||
controller.stateUpdaters.add(Pair(widget.id, () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}));
|
||||
widget.initStateWrapped(controller);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.disposeWrapped(controller);
|
||||
StateController.remove<T>(widget.tag, true);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builderWrapped(controller);
|
||||
}
|
||||
|
||||
abstract class StateWithController<T extends StatefulWidget> extends State<T> {
|
||||
late final SimpleController _controller;
|
||||
|
||||
void refresh() {
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
_controller = StateController.putSimpleController(
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
tag,
|
||||
refresh: refresh,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update() {
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
Object? get tag;
|
||||
}
|
||||
|
||||
class Pair<M, V>{
|
||||
M left;
|
||||
V right;
|
||||
|
||||
Pair(this.left, this.right);
|
||||
|
||||
Pair.fromMap(Map<M, V> map, M key): left = key, right = map[key]
|
||||
?? (throw Exception("Pair not found"));
|
||||
}
|
110
lib/foundation/widget_utils.dart
Normal file
110
lib/foundation/widget_utils.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension WidgetExtension on Widget{
|
||||
Widget padding(EdgeInsetsGeometry padding){
|
||||
return Padding(padding: padding, child: this);
|
||||
}
|
||||
|
||||
Widget paddingLeft(double padding){
|
||||
return Padding(padding: EdgeInsets.only(left: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingRight(double padding){
|
||||
return Padding(padding: EdgeInsets.only(right: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingTop(double padding){
|
||||
return Padding(padding: EdgeInsets.only(top: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingBottom(double padding){
|
||||
return Padding(padding: EdgeInsets.only(bottom: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingVertical(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(vertical: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingHorizontal(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(horizontal: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingAll(double padding){
|
||||
return Padding(padding: EdgeInsets.all(padding), child: this);
|
||||
}
|
||||
|
||||
Widget toCenter(){
|
||||
return Center(child: this);
|
||||
}
|
||||
|
||||
Widget toAlign(AlignmentGeometry alignment){
|
||||
return Align(alignment: alignment, child: this);
|
||||
}
|
||||
|
||||
Widget sliverPadding(EdgeInsetsGeometry padding){
|
||||
return SliverPadding(padding: padding, sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingAll(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.all(padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingVertical(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(vertical: padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingHorizontal(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(horizontal: padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget fixWidth(double width){
|
||||
return SizedBox(width: width, child: this);
|
||||
}
|
||||
|
||||
Widget fixHeight(double height){
|
||||
return SizedBox(height: height, child: this);
|
||||
}
|
||||
}
|
||||
|
||||
/// create default text style
|
||||
TextStyle get ts => const TextStyle();
|
||||
|
||||
extension StyledText on TextStyle {
|
||||
TextStyle get bold => copyWith(fontWeight: FontWeight.bold);
|
||||
|
||||
TextStyle get light => copyWith(fontWeight: FontWeight.w300);
|
||||
|
||||
TextStyle get italic => copyWith(fontStyle: FontStyle.italic);
|
||||
|
||||
TextStyle get underline => copyWith(decoration: TextDecoration.underline);
|
||||
|
||||
TextStyle get lineThrough => copyWith(decoration: TextDecoration.lineThrough);
|
||||
|
||||
TextStyle get overline => copyWith(decoration: TextDecoration.overline);
|
||||
|
||||
TextStyle get s8 => copyWith(fontSize: 8);
|
||||
|
||||
TextStyle get s10 => copyWith(fontSize: 10);
|
||||
|
||||
TextStyle get s12 => copyWith(fontSize: 12);
|
||||
|
||||
TextStyle get s14 => copyWith(fontSize: 14);
|
||||
|
||||
TextStyle get s16 => copyWith(fontSize: 16);
|
||||
|
||||
TextStyle get s18 => copyWith(fontSize: 18);
|
||||
|
||||
TextStyle get s20 => copyWith(fontSize: 20);
|
||||
|
||||
TextStyle get s24 => copyWith(fontSize: 24);
|
||||
|
||||
TextStyle get s28 => copyWith(fontSize: 28);
|
||||
|
||||
TextStyle get s32 => copyWith(fontSize: 32);
|
||||
|
||||
TextStyle get s36 => copyWith(fontSize: 36);
|
||||
|
||||
TextStyle get s40 => copyWith(fontSize: 40);
|
||||
|
||||
TextStyle withColor(Color? color) => copyWith(color: color);
|
||||
}
|
19
lib/init.dart
Normal file
19
lib/init.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
Future<void> init() async {
|
||||
await AppTranslation.init();
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
await LocalManager().init();
|
||||
await LocalFavoritesManager().init();
|
||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||
await JsEngine().init();
|
||||
CacheManager();
|
||||
}
|
153
lib/main.dart
Normal file
153
lib/main.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/main_page.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'components/components.dart';
|
||||
import 'components/window_frame.dart';
|
||||
import 'foundation/app.dart';
|
||||
import 'foundation/appdata.dart';
|
||||
import 'init.dart';
|
||||
|
||||
void main() {
|
||||
runZonedGuarded(() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await init();
|
||||
FlutterError.onError = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
runApp(const MyApp());
|
||||
if (App.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
windowManager.waitUntilReadyToShow().then((_) async {
|
||||
await windowManager.setTitleBarStyle(
|
||||
TitleBarStyle.hidden,
|
||||
windowButtonVisibility: App.isMacOS,
|
||||
);
|
||||
if (App.isLinux) {
|
||||
await windowManager.setBackgroundColor(Colors.transparent);
|
||||
}
|
||||
await windowManager.setMinimumSize(const Size(500, 600));
|
||||
if (!App.isLinux) {
|
||||
// https://github.com/leanflutter/window_manager/issues/460
|
||||
var placement = await WindowPlacement.loadFromFile();
|
||||
await placement.applyToWindow();
|
||||
await windowManager.show();
|
||||
WindowPlacement.loop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, (error, stack) {
|
||||
Log.error("Unhandled Exception", "$error\n$stack");
|
||||
});
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: const MainPage(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: App.mainColor,
|
||||
surface: Colors.white,
|
||||
primary: App.mainColor.shade600,
|
||||
background: Colors.white,
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
),
|
||||
navigatorKey: App.rootNavigatorKey,
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: App.mainColor,
|
||||
brightness: Brightness.dark,
|
||||
surface: Colors.black,
|
||||
primary: App.mainColor.shade400,
|
||||
background: Colors.black,
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
),
|
||||
themeMode: switch (appdata.settings['theme_mode']) {
|
||||
'light' => ThemeMode.light,
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
],
|
||||
builder: (context, widget) {
|
||||
ErrorWidget.builder = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
return Material(
|
||||
child: Center(
|
||||
child: Text(details.exception.toString()),
|
||||
),
|
||||
);
|
||||
};
|
||||
if (widget != null) {
|
||||
widget = OverlayWidget(widget);
|
||||
if (App.isDesktop) {
|
||||
widget = Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): VoidCallbackIntent(
|
||||
App.pop,
|
||||
),
|
||||
},
|
||||
child: WindowFrame(widget),
|
||||
);
|
||||
}
|
||||
return _SystemUiProvider(Material(
|
||||
child: widget,
|
||||
));
|
||||
}
|
||||
throw ('widget is null');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SystemUiProvider extends StatelessWidget {
|
||||
const _SystemUiProvider(this.child);
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var brightness = Theme.of(context).brightness;
|
||||
SystemUiOverlayStyle systemUiStyle;
|
||||
if (brightness == Brightness.light) {
|
||||
systemUiStyle = SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
);
|
||||
} else {
|
||||
systemUiStyle = SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: systemUiStyle,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
194
lib/network/app_dio.dart
Normal file
194
lib/network/app_dio.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
import '../foundation/app.dart';
|
||||
|
||||
class MyLogInterceptor implements Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
Log.error("Network",
|
||||
"${err.requestOptions.method} ${err.requestOptions.path}\n$err\n${err.response?.data.toString()}");
|
||||
switch (err.type) {
|
||||
case DioExceptionType.badResponse:
|
||||
var statusCode = err.response?.statusCode;
|
||||
if (statusCode != null) {
|
||||
err = err.copyWith(
|
||||
message: "Invalid Status Code: $statusCode. "
|
||||
"${_getStatusCodeInfo(statusCode)}");
|
||||
}
|
||||
case DioExceptionType.connectionTimeout:
|
||||
err = err.copyWith(message: "Connection Timeout");
|
||||
case DioExceptionType.receiveTimeout:
|
||||
err = err.copyWith(
|
||||
message: "Receive Timeout: "
|
||||
"This indicates that the server is too busy to respond");
|
||||
case DioExceptionType.unknown:
|
||||
if (err.toString().contains("Connection terminated during handshake")) {
|
||||
err = err.copyWith(
|
||||
message: "Connection terminated during handshake: "
|
||||
"This may be caused by the firewall blocking the connection "
|
||||
"or your requests are too frequent.");
|
||||
} else if (err.toString().contains("Connection reset by peer")) {
|
||||
err = err.copyWith(
|
||||
message: "Connection reset by peer: "
|
||||
"The error is unrelated to app, please check your network.");
|
||||
}
|
||||
default:
|
||||
{}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
static const errorMessages = <int, String>{
|
||||
400: "The Request is invalid.",
|
||||
401: "The Request is unauthorized.",
|
||||
403: "No permission to access the resource. Check your account or network.",
|
||||
404: "Not found.",
|
||||
429: "Too many requests. Please try again later.",
|
||||
};
|
||||
|
||||
String _getStatusCodeInfo(int? statusCode) {
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return "This is server-side error, please try again later. "
|
||||
"Do not report this issue.";
|
||||
} else {
|
||||
return errorMessages[statusCode] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response, ResponseInterceptorHandler handler) {
|
||||
var headers = response.headers.map.map((key, value) => MapEntry(
|
||||
key.toLowerCase(), value.length == 1 ? value.first : value.toString()));
|
||||
headers.remove("cookie");
|
||||
String content;
|
||||
if (response.data is List<int>) {
|
||||
try {
|
||||
content = utf8.decode(response.data, allowMalformed: false);
|
||||
} catch (e) {
|
||||
content = "<Bytes>\nlength:${response.data.length}";
|
||||
}
|
||||
} else {
|
||||
content = response.data.toString();
|
||||
}
|
||||
Log.addLog(
|
||||
(response.statusCode != null && response.statusCode! < 400)
|
||||
? LogLevel.info
|
||||
: LogLevel.error,
|
||||
"Network",
|
||||
"Response ${response.realUri.toString()} ${response.statusCode}\n"
|
||||
"headers:\n$headers\n$content");
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.connectTimeout = const Duration(seconds: 15);
|
||||
options.receiveTimeout = const Duration(seconds: 15);
|
||||
options.sendTimeout = const Duration(seconds: 15);
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
class AppDio with DioMixin {
|
||||
String? _proxy = proxy;
|
||||
|
||||
AppDio(BaseOptions options) {
|
||||
this.options = options;
|
||||
interceptors.add(MyLogInterceptor());
|
||||
httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient);
|
||||
}
|
||||
|
||||
static HttpClient createHttpClient() {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
client.findProxy = (uri) => proxy ?? "DIRECT";
|
||||
client.idleTimeout = const Duration(seconds: 100);
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||
if (host.contains("cdn")) return true;
|
||||
final ipv4RegExp = RegExp(
|
||||
r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$');
|
||||
if (ipv4RegExp.hasMatch(host)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
static String? proxy;
|
||||
|
||||
static Future<String?> getProxy() async {
|
||||
if (appdata.settings['proxy'].removeAllBlank == "direct") return null;
|
||||
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
|
||||
|
||||
String res;
|
||||
if (!App.isLinux) {
|
||||
const channel = MethodChannel("venera/method_channel");
|
||||
try {
|
||||
res = await channel.invokeMethod("getProxy");
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
res = "No Proxy";
|
||||
}
|
||||
if (res == "No Proxy") return null;
|
||||
|
||||
if (res.contains(";")) {
|
||||
var proxies = res.split(";");
|
||||
for (String proxy in proxies) {
|
||||
proxy = proxy.removeAllBlank;
|
||||
if (proxy.startsWith('https=')) {
|
||||
return proxy.substring(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp regex = RegExp(
|
||||
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
|
||||
caseSensitive: false,
|
||||
multiLine: false,
|
||||
);
|
||||
if (!regex.hasMatch(res)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> request<T> (
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
proxy = await getProxy();
|
||||
if(_proxy != proxy) {
|
||||
_proxy = proxy;
|
||||
(httpClientAdapter as IOHttpClientAdapter).close();
|
||||
httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient);
|
||||
}
|
||||
return super.request(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
}
|
221
lib/network/cookie_jar.dart
Normal file
221
lib/network/cookie_jar.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
class CookieJarSql {
|
||||
late Database _db;
|
||||
|
||||
final String path;
|
||||
|
||||
CookieJarSql(this.path){
|
||||
init();
|
||||
}
|
||||
|
||||
void init() {
|
||||
_db = sqlite3.open(path);
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS cookies (
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
path TEXT,
|
||||
expires INTEGER,
|
||||
secure INTEGER,
|
||||
httpOnly INTEGER,
|
||||
PRIMARY KEY (name, domain, path)
|
||||
);
|
||||
''');
|
||||
}
|
||||
|
||||
void saveFromResponse(Uri uri, List<Cookie> cookies) {
|
||||
var current = loadForRequest(uri);
|
||||
for (var cookie in cookies) {
|
||||
var currentCookie = current.firstWhereOrNull((element) =>
|
||||
element.name == cookie.name &&
|
||||
(cookie.path == null || cookie.path!.startsWith(element.path!)));
|
||||
if (currentCookie != null) {
|
||||
cookie.domain = currentCookie.domain;
|
||||
}
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cookies (name, value, domain, path, expires, secure, httpOnly)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
''', [
|
||||
cookie.name,
|
||||
cookie.value,
|
||||
cookie.domain ?? uri.host,
|
||||
cookie.path ?? "/",
|
||||
cookie.expires?.millisecondsSinceEpoch,
|
||||
cookie.secure ? 1 : 0,
|
||||
cookie.httpOnly ? 1 : 0
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
List<Cookie> _loadWithDomain(String domain) {
|
||||
var rows = _db.select('''
|
||||
SELECT name, value, domain, path, expires, secure, httpOnly
|
||||
FROM cookies
|
||||
WHERE domain = ?;
|
||||
''', [domain]);
|
||||
|
||||
return rows
|
||||
.map((row) => Cookie(
|
||||
row["name"] as String,
|
||||
row["value"] as String,
|
||||
)
|
||||
..domain = row["domain"] as String
|
||||
..path = row["path"] as String
|
||||
..expires = row["expires"] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(row["expires"] as int)
|
||||
..secure = row["secure"] == 1
|
||||
..httpOnly = row["httpOnly"] == 1)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _getAcceptedDomains(String host) {
|
||||
var acceptedDomains = <String>[host];
|
||||
var hostParts = host.split(".");
|
||||
for (var i = 0; i < hostParts.length - 1; i++) {
|
||||
acceptedDomains.add(".${hostParts.sublist(i).join(".")}");
|
||||
}
|
||||
return acceptedDomains;
|
||||
}
|
||||
|
||||
List<Cookie> loadForRequest(Uri uri) {
|
||||
// if uri.host is example.example.com, acceptedDomains will be [".example.example.com", ".example.com", "example.com"]
|
||||
var acceptedDomains = _getAcceptedDomains(uri.host);
|
||||
|
||||
var cookies = <Cookie>[];
|
||||
for (var domain in acceptedDomains) {
|
||||
cookies.addAll(_loadWithDomain(domain));
|
||||
}
|
||||
|
||||
// check expires
|
||||
var expires = cookies.where((cookie) =>
|
||||
cookie.expires != null && cookie.expires!.isBefore(DateTime.now()));
|
||||
for (var cookie in expires) {
|
||||
_db.execute('''
|
||||
DELETE FROM cookies
|
||||
WHERE name = ? AND domain = ? AND path = ?;
|
||||
''', [cookie.name, cookie.domain, cookie.path]);
|
||||
}
|
||||
|
||||
return cookies
|
||||
.where((element) =>
|
||||
!expires.contains(element) && _checkPathMatch(uri, element.path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
bool _checkPathMatch(Uri uri, String? cookiePath) {
|
||||
if (cookiePath == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cookiePath == uri.path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cookiePath == "/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cookiePath.endsWith("/")) {
|
||||
return uri.path.startsWith(cookiePath);
|
||||
}
|
||||
|
||||
return uri.path.startsWith(cookiePath);
|
||||
}
|
||||
|
||||
void saveFromResponseCookieHeader(Uri uri, List<String> cookieHeader) {
|
||||
var cookies = cookieHeader
|
||||
.map((header) => Cookie.fromSetCookieValue(header))
|
||||
.toList();
|
||||
saveFromResponse(uri, cookies);
|
||||
}
|
||||
|
||||
String loadForRequestCookieHeader(Uri uri) {
|
||||
var cookies = loadForRequest(uri);
|
||||
var map = <String, Cookie>{};
|
||||
for (var cookie in cookies) {
|
||||
if(map.containsKey(cookie.name)) {
|
||||
if(cookie.domain![0] != '.' && map[cookie.name]!.domain![0] == '.') {
|
||||
map[cookie.name] = cookie;
|
||||
} else if(cookie.domain!.length > map[cookie.name]!.domain!.length) {
|
||||
map[cookie.name] = cookie;
|
||||
}
|
||||
} else {
|
||||
map[cookie.name] = cookie;
|
||||
}
|
||||
}
|
||||
return map.entries.map((cookie) => "${cookie.value.name}=${cookie.value.value}").join("; ");
|
||||
}
|
||||
|
||||
void delete(Uri uri, String name) {
|
||||
var acceptedDomains = _getAcceptedDomains(uri.host);
|
||||
for (var domain in acceptedDomains) {
|
||||
_db.execute('''
|
||||
DELETE FROM cookies
|
||||
WHERE name = ? AND domain = ? AND path = ?;
|
||||
''', [name, domain, uri.path]);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteUri(Uri uri) {
|
||||
var acceptedDomains = _getAcceptedDomains(uri.host);
|
||||
for (var domain in acceptedDomains) {
|
||||
_db.execute('''
|
||||
DELETE FROM cookies
|
||||
WHERE domain = ?;
|
||||
''', [domain]);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteAll() {
|
||||
_db.execute('''
|
||||
DELETE FROM cookies;
|
||||
''');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_db.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class SingleInstanceCookieJar extends CookieJarSql {
|
||||
factory SingleInstanceCookieJar(String path) =>
|
||||
instance ??= SingleInstanceCookieJar._create(path);
|
||||
|
||||
SingleInstanceCookieJar._create(super.path);
|
||||
|
||||
static SingleInstanceCookieJar? instance;
|
||||
}
|
||||
|
||||
class CookieManagerSql extends Interceptor {
|
||||
final CookieJarSql cookieJar;
|
||||
|
||||
CookieManagerSql(this.cookieJar);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
var cookies = cookieJar.loadForRequestCookieHeader(options.uri);
|
||||
if (cookies.isNotEmpty) {
|
||||
options.headers["cookie"] = cookies;
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
cookieJar.saveFromResponseCookieHeader(
|
||||
response.requestOptions.uri, response.headers["set-cookie"] ?? []);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
23
lib/network/download.dart
Normal file
23
lib/network/download.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
|
||||
abstract class DownloadTask with ChangeNotifier {
|
||||
int get current;
|
||||
|
||||
int get total;
|
||||
|
||||
double get progress => current / total;
|
||||
|
||||
bool get isComplete => current == total;
|
||||
|
||||
int get speed;
|
||||
|
||||
void cancel();
|
||||
|
||||
void pause();
|
||||
|
||||
void resume();
|
||||
|
||||
String get title;
|
||||
|
||||
String? get cover;
|
||||
}
|
10
lib/pages/categories_page.dart
Normal file
10
lib/pages/categories_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoriesPage extends StatelessWidget {
|
||||
const CategoriesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
10
lib/pages/explore_page.dart
Normal file
10
lib/pages/explore_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExplorePage extends StatelessWidget {
|
||||
const ExplorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
10
lib/pages/favorites_page.dart
Normal file
10
lib/pages/favorites_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FavoritesPage extends StatelessWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
498
lib/pages/home_page.dart
Normal file
498
lib/pages/home_page.dart
Normal file
@@ -0,0 +1,498 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_type.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/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SmoothCustomScrollView(
|
||||
slivers: [
|
||||
_History(),
|
||||
_Local(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _History extends StatelessWidget {
|
||||
const _History();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final history = HistoryManager().getRecent();
|
||||
final count = HistoryManager().count();
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Center(
|
||||
child: Text('History'.tl, style: ts.s18),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(count.toString(), style: ts.s12),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
if (history.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// TODO: toComicPageWithHistory(context, history[index]);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 128,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
history[index].cover,
|
||||
sourceKey: history[index].type.comicSource?.key,
|
||||
),
|
||||
width: 96,
|
||||
height: 128,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Local extends StatelessWidget {
|
||||
const _Local();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final local = LocalManager().getRecent();
|
||||
final count = LocalManager().count;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Center(
|
||||
child: Text('Local'.tl, style: ts.s18),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(count.toString(), style: ts.s12),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
if (local.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: local.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// TODO: view local comic
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 128,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: FileImage(
|
||||
local[index].coverFile,
|
||||
),
|
||||
width: 96,
|
||||
height: 128,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Button.filled(
|
||||
onPressed: import,
|
||||
child: Text("Import".tl),
|
||||
),
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void import() {
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return const _ImportComicsWidget();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportComicsWidget extends StatefulWidget {
|
||||
const _ImportComicsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<_ImportComicsWidget> createState() => _ImportComicsWidgetState();
|
||||
}
|
||||
|
||||
class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
int type = 0;
|
||||
|
||||
bool loading = false;
|
||||
|
||||
var key = GlobalKey();
|
||||
|
||||
var height = 200.0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
loading = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String info = type == 0
|
||||
? "Select a directory which contains the comic files.".tl
|
||||
: "Select a directory which contains the comic directories.".tl;
|
||||
|
||||
return ContentDialog(
|
||||
dismissible: !loading,
|
||||
title: "Import Comics".tl,
|
||||
content: loading
|
||||
? SizedBox(
|
||||
width: 600,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
RadioListTile(
|
||||
title: Text("Single Comic".tl),
|
||||
value: 0,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Multiple Comics".tl),
|
||||
value: 1,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
size: 18,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text("help".tl),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) {
|
||||
var help = '';
|
||||
help +=
|
||||
'${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n';
|
||||
help += '${'1. The directory only contains image files.'.tl}\n';
|
||||
help +=
|
||||
'${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n';
|
||||
help +=
|
||||
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
|
||||
help +=
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles."
|
||||
.tl;
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(help).paddingHorizontal(16),
|
||||
actions: [
|
||||
Button.filled(
|
||||
child: Text("OK".tl),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
).fixWidth(90).paddingRight(8),
|
||||
Button.filled(
|
||||
isLoading: loading,
|
||||
onPressed: selectAndImport,
|
||||
child: Text("Select".tl),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void selectAndImport() async {
|
||||
height = key.currentContext!.size!.height;
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
final picker = DirectoryPicker();
|
||||
final path = await picker.pickDirectory();
|
||||
if(!loading) {
|
||||
picker.dispose();
|
||||
return;
|
||||
}
|
||||
if(path == null) {
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Map<Directory, LocalComic> comics = {};
|
||||
if(type == 0) {
|
||||
var result = await checkSingleComic(path);
|
||||
if(result != null) {
|
||||
comics[path] = result;
|
||||
} else {
|
||||
context.showMessage(message: "Invalid Comic".tl);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await for(var entry in path.list()) {
|
||||
if(entry is Directory) {
|
||||
var result = await checkSingleComic(entry);
|
||||
if(result != null) {
|
||||
comics[entry] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bool shouldCopy = true;
|
||||
for(var comic in comics.keys) {
|
||||
if(comic.parent.path == LocalManager().path) {
|
||||
shouldCopy = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(shouldCopy && comics.isNotEmpty) {
|
||||
try {
|
||||
// copy the comics to the local directory
|
||||
await compute<Map<String, dynamic>, void>(_copyDirectories, {
|
||||
'toBeCopied': comics.keys.map((e) => e.path).toList(),
|
||||
'destination': LocalManager().path,
|
||||
});
|
||||
}
|
||||
catch(e) {
|
||||
context.showMessage(message: "Failed to import comics".tl);
|
||||
Log.error("Import Comic", e.toString());
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for(var comic in comics.values) {
|
||||
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
|
||||
}
|
||||
context.pop();
|
||||
context.showMessage(message: "Imported @a comics".tlParams({
|
||||
'a': comics.length,
|
||||
}));
|
||||
}
|
||||
|
||||
static _copyDirectories(Map<String, dynamic> data) {
|
||||
var toBeCopied = data['toBeCopied'] as List<String>;
|
||||
var destination = data['destination'] as String;
|
||||
for(var dir in toBeCopied) {
|
||||
var source = Directory(dir);
|
||||
var dest = Directory("$destination/${source.name}");
|
||||
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"));
|
||||
}
|
||||
dest.createSync();
|
||||
copyDirectory(source, dest);
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalComic?> checkSingleComic(Directory directory) async {
|
||||
if(!(await directory.exists())) return null;
|
||||
var name = directory.name;
|
||||
bool hasChapters = false;
|
||||
var chapters = <String>[];
|
||||
var coverPath = ''; // relative path to the cover image
|
||||
await for(var entry in directory.list()) {
|
||||
if(entry is Directory) {
|
||||
hasChapters = true;
|
||||
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.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} 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)) {
|
||||
coverPath = entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
chapters.sort();
|
||||
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) {
|
||||
coverPath = entry.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(coverPath == '') {
|
||||
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
|
||||
return null;
|
||||
}
|
||||
return LocalComic(
|
||||
id: 0,
|
||||
title: name,
|
||||
subtitle: '',
|
||||
tags: [],
|
||||
directory: directory.name,
|
||||
chapters: Map.fromIterables(chapters, chapters),
|
||||
cover: coverPath,
|
||||
comicType: ComicType.local,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
116
lib/pages/main_page.dart
Normal file
116
lib/pages/main_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/pages/categories_page.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import '../components/components.dart';
|
||||
import '../foundation/app.dart';
|
||||
import '../foundation/app_page_route.dart';
|
||||
import 'explore_page.dart';
|
||||
import 'favorites_page.dart';
|
||||
import 'home_page.dart';
|
||||
|
||||
class MainPage extends StatefulWidget {
|
||||
const MainPage({super.key});
|
||||
|
||||
@override
|
||||
State<MainPage> createState() => _MainPageState();
|
||||
}
|
||||
|
||||
class _MainPageState extends State<MainPage> {
|
||||
late final NaviObserver _observer;
|
||||
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
void to(Widget Function() widget, {bool preventDuplicate = false}) async {
|
||||
if (preventDuplicate) {
|
||||
var page = widget();
|
||||
if ("/${page.runtimeType}" == _observer.routes.last.toString()) return;
|
||||
}
|
||||
_navigatorKey!.currentContext!.to(widget);
|
||||
}
|
||||
|
||||
void back() {
|
||||
_navigatorKey!.currentContext!.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_observer = NaviObserver();
|
||||
_navigatorKey = GlobalKey();
|
||||
App.mainNavigatorKey = _navigatorKey;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
final _pages = [
|
||||
const HomePage(),
|
||||
const FavoritesPage(),
|
||||
const ExplorePage(),
|
||||
const CategoriesPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NaviPane(
|
||||
observer: _observer,
|
||||
paneItems: [
|
||||
PaneItemEntry(
|
||||
label: 'Home'.tl,
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Favorites'.tl,
|
||||
icon: Icons.local_activity_outlined,
|
||||
activeIcon: Icons.local_activity,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Explore'.tl,
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Categories'.tl,
|
||||
icon: Icons.category_outlined,
|
||||
activeIcon: Icons.category,
|
||||
),
|
||||
],
|
||||
paneActions: [
|
||||
PaneActionEntry(
|
||||
icon: Icons.search,
|
||||
label: "Search".tl,
|
||||
onTap: () {},
|
||||
),
|
||||
PaneActionEntry(
|
||||
icon: Icons.settings,
|
||||
label: "Settings".tl,
|
||||
onTap: () {},
|
||||
)
|
||||
],
|
||||
pageBuilder: (index) {
|
||||
return Navigator(
|
||||
observers: [_observer],
|
||||
key: _navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return NaviPaddingWidget(child: _pages[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onPageChange: (index) {
|
||||
_navigatorKey!.currentState?.pushAndRemoveUntil(
|
||||
AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return NaviPaddingWidget(child: _pages[index]);
|
||||
},
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
92
lib/utils/ext.dart
Normal file
92
lib/utils/ext.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
extension ListExt<T> on List<T>{
|
||||
/// Remove all blank value and return the list.
|
||||
List<T> getNoBlankList(){
|
||||
List<T> newList = [];
|
||||
for(var value in this){
|
||||
if(value.toString() != ""){
|
||||
newList.add(value);
|
||||
}
|
||||
}
|
||||
return newList;
|
||||
}
|
||||
|
||||
T? firstWhereOrNull(bool Function(T element) test){
|
||||
for(var element in this){
|
||||
if(test(element)){
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void addIfNotNull(T? value){
|
||||
if(value != null){
|
||||
add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExt on String{
|
||||
///Remove all value that would display blank on the screen.
|
||||
String get removeAllBlank => replaceAll("\n", "").replaceAll(" ", "").replaceAll("\t", "");
|
||||
|
||||
/// convert this to a one-element list.
|
||||
List<String> toList() => [this];
|
||||
|
||||
String _nums(){
|
||||
String res = "";
|
||||
for(int i=0; i<length; i++){
|
||||
res += this[i].isNum?this[i]:"";
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
String get nums => _nums();
|
||||
|
||||
String setValueAt(String value, int index){
|
||||
return replaceRange(index, index+1, value);
|
||||
}
|
||||
|
||||
String? subStringOrNull(int start, [int? end]){
|
||||
if(start < 0 || (end != null && end > length)){
|
||||
return null;
|
||||
}
|
||||
return substring(start, end);
|
||||
}
|
||||
|
||||
String replaceLast(String from, String to) {
|
||||
if (isEmpty || from.isEmpty) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final lastIndex = lastIndexOf(from);
|
||||
if (lastIndex == -1) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final before = substring(0, lastIndex);
|
||||
final after = substring(lastIndex + from.length);
|
||||
return '$before$to$after';
|
||||
}
|
||||
|
||||
static bool hasMatch(String? value, String pattern) {
|
||||
return (value == null) ? false : RegExp(pattern).hasMatch(value);
|
||||
}
|
||||
|
||||
bool _isURL(){
|
||||
final regex = RegExp(
|
||||
r'^((http|https|ftp)://)?[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-|]*[\w@?^=%&/~+#-])?$',
|
||||
caseSensitive: false);
|
||||
return regex.hasMatch(this);
|
||||
}
|
||||
|
||||
bool get isURL => _isURL();
|
||||
|
||||
bool get isNum => double.tryParse(this) != null;
|
||||
}
|
||||
|
||||
class ListOrNull{
|
||||
static List<T>? from<T>(Iterable<dynamic>? i){
|
||||
return i == null ? null : List.from(i);
|
||||
}
|
||||
}
|
140
lib/utils/io.dart
Normal file
140
lib/utils/io.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
extension FileSystemEntityExt on FileSystemEntity {
|
||||
String get name {
|
||||
var path = this.path;
|
||||
if (path.endsWith('/') || path.endsWith('\\')) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
|
||||
int i = path.length - 1;
|
||||
|
||||
while (i >= 0 && path[i] != '\\' && path[i] != '/') {
|
||||
i--;
|
||||
}
|
||||
|
||||
return path.substring(i + 1);
|
||||
}
|
||||
|
||||
Future<void> deleteIgnoreError({bool recursive = false}) async {
|
||||
try {
|
||||
await delete(recursive: recursive);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileExtension on File {
|
||||
String get extension => path.split('.').last;
|
||||
}
|
||||
|
||||
extension DirectoryExtension on Directory {
|
||||
Future<int> get size async {
|
||||
if (!existsSync()) return 0;
|
||||
int total = 0;
|
||||
for (var f in listSync(recursive: true)) {
|
||||
if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) {
|
||||
total += await File(f.path).length();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Directory renameX(String newName) {
|
||||
newName = sanitizeFileName(newName);
|
||||
return renameSync(path.replaceLast(name, newName));
|
||||
}
|
||||
}
|
||||
|
||||
String sanitizeFileName(String fileName) {
|
||||
const maxLength = 255;
|
||||
final invalidChars = RegExp(r'[<>:"/\\|?*]');
|
||||
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
|
||||
var trimmedFileName = sanitizedFileName.trim();
|
||||
if (trimmedFileName.isEmpty) {
|
||||
throw Exception('Invalid File Name: Empty length.');
|
||||
}
|
||||
while (true) {
|
||||
final bytes = utf8.encode(trimmedFileName);
|
||||
if (bytes.length > maxLength) {
|
||||
trimmedFileName =
|
||||
trimmedFileName.substring(0, trimmedFileName.length - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return trimmedFileName;
|
||||
}
|
||||
|
||||
/// Copy the **contents** of the source directory to the destination directory.
|
||||
Future<void> copyDirectory(Directory source, Directory destination) async {
|
||||
List<FileSystemEntity> contents = source.listSync();
|
||||
for (FileSystemEntity content in contents) {
|
||||
String newPath = destination.path +
|
||||
Platform.pathSeparator +
|
||||
content.path.split(Platform.pathSeparator).last;
|
||||
|
||||
if (content is File) {
|
||||
content.copySync(newPath);
|
||||
} else if (content is Directory) {
|
||||
Directory newDirectory = Directory(newPath);
|
||||
newDirectory.createSync();
|
||||
copyDirectory(content.absolute, newDirectory.absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String findValidDirectoryName(String path, String directory) {
|
||||
var name = sanitizeFileName(directory);
|
||||
var dir = Directory("$path/$name");
|
||||
var i = 1;
|
||||
while (dir.existsSync()) {
|
||||
name = sanitizeFileName("$directory($i)");
|
||||
dir = Directory("$path/$name");
|
||||
i++;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
class DirectoryPicker {
|
||||
String? _directory;
|
||||
|
||||
final _methodChannel = const MethodChannel("venera/method_channel");
|
||||
|
||||
Future<Directory?> pickDirectory() async {
|
||||
if(App.isWindows || App.isLinux) {
|
||||
var d = await FilePicker.platform.getDirectoryPath();
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
} else if (App.isAndroid) {
|
||||
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
} else {
|
||||
// ios, macos
|
||||
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
if(_directory == null) {
|
||||
return;
|
||||
}
|
||||
if(App.isAndroid && _directory != null) {
|
||||
return Directory(_directory!).deleteIgnoreError(recursive: true);
|
||||
}
|
||||
if(App.isIOS || App.isMacOS) {
|
||||
await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
43
lib/utils/translations.dart
Normal file
43
lib/utils/translations.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import '../foundation/app.dart';
|
||||
|
||||
extension AppTranslation on String {
|
||||
String _translate() {
|
||||
var locale = App.locale;
|
||||
var key = "${locale.languageCode}_${locale.countryCode}";
|
||||
if (locale.languageCode == "en") {
|
||||
key = "en_US";
|
||||
}
|
||||
return (translations[key]?[this]) ?? this;
|
||||
}
|
||||
|
||||
String get tl => _translate();
|
||||
|
||||
String get tlEN => translations["en_US"]![this] ?? this;
|
||||
|
||||
String tlParams(Map<String, Object> values) {
|
||||
var res = _translate();
|
||||
for (var entry in values.entries) {
|
||||
res = res.replaceFirst("@${entry.key}", entry.value.toString());
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
static late final Map<String, Map<String, String>> translations;
|
||||
|
||||
static Future<void> init() async{
|
||||
var data = await rootBundle.load("assets/translation.json");
|
||||
var json = jsonDecode(utf8.decode(data.buffer.asUint8List()));
|
||||
translations = { for (var e in json.entries) e.key : Map<String, String>.from(e.value) };
|
||||
}
|
||||
}
|
||||
|
||||
extension ListTranslation on List<String> {
|
||||
List<String> _translate() {
|
||||
return List.generate(length, (index) => this[index].tl);
|
||||
}
|
||||
|
||||
List<String> get tl => _translate();
|
||||
}
|
Reference in New Issue
Block a user