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