initial commit

This commit is contained in:
nyne
2024-09-29 16:17:03 +08:00
commit f08c5cccb9
196 changed files with 16761 additions and 0 deletions

623
lib/components/appbar.dart Normal file
View 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
View 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,
),
),
),
);
}
}

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

View File

@@ -0,0 +1,3 @@
part of 'components.dart';
const _fastAnimationDuration = Duration(milliseconds: 160);

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

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

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

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

View 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
View 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();

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

View 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];
}
}

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

View 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";
}

View 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);
}

View 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;
}

View 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>;
};
}
}

View 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);
}

View 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";

View 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
}
}

View 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
View 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;
}
}

View 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);

View 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;
}

View 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
View 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
View 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
View 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;
}

View 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"));
}

View 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
View 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
View 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
View 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
View 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
View 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;
}

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

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

View 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
View 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
View 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
View 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
View 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");
}
}
}

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