mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
initial commit
This commit is contained in:
623
lib/components/appbar.dart
Normal file
623
lib/components/appbar.dart
Normal file
@@ -0,0 +1,623 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const Appbar(
|
||||
{required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
super.key});
|
||||
|
||||
final Widget title;
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
State<Appbar> createState() => _AppbarState();
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(56);
|
||||
}
|
||||
|
||||
class _AppbarState extends State<Appbar> {
|
||||
ScrollNotificationObserverState? _scrollNotificationObserver;
|
||||
bool _scrolledUnder = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scrollNotificationObserver?.removeListener(_handleScrollNotification);
|
||||
_scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context);
|
||||
_scrollNotificationObserver?.addListener(_handleScrollNotification);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scrollNotificationObserver != null) {
|
||||
_scrollNotificationObserver!.removeListener(_handleScrollNotification);
|
||||
_scrollNotificationObserver = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
defaultScrollNotificationPredicate(notification)) {
|
||||
final bool oldScrolledUnder = _scrolledUnder;
|
||||
final ScrollMetrics metrics = notification.metrics;
|
||||
switch (metrics.axisDirection) {
|
||||
case AxisDirection.up:
|
||||
// Scroll view is reversed
|
||||
_scrolledUnder = metrics.extentAfter > 0;
|
||||
case AxisDirection.down:
|
||||
_scrolledUnder = metrics.extentBefore > 0;
|
||||
case AxisDirection.right:
|
||||
case AxisDirection.left:
|
||||
// Scrolled under is only supported in the vertical axis, and should
|
||||
// not be altered based on horizontal notifications of the same
|
||||
// predicate since it could be a 2D scroller.
|
||||
break;
|
||||
}
|
||||
|
||||
if (_scrolledUnder != oldScrolledUnder) {
|
||||
setState(() {
|
||||
// React to a change in MaterialState.scrolledUnder
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = SizedBox(
|
||||
height: _kAppBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
widget.leading ??
|
||||
Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: widget.title,
|
||||
),
|
||||
),
|
||||
...?widget.actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
if (widget.backgroundColor != Colors.transparent) {
|
||||
return Material(
|
||||
elevation: (_scrolledUnder && context.width < changePoint) ? 1 : 0,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
color: widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverAppbar extends StatelessWidget {
|
||||
const SliverAppbar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.color,
|
||||
this.radius = 0,
|
||||
});
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
final Widget title;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _MySliverAppBarDelegate(
|
||||
leading: leading,
|
||||
title: title,
|
||||
actions: actions,
|
||||
topPadding: MediaQuery.of(context).padding.top,
|
||||
color: color,
|
||||
radius: radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _kAppBarHeight = 58.0;
|
||||
|
||||
class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final Widget? leading;
|
||||
|
||||
final Widget title;
|
||||
|
||||
final List<Widget>? actions;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final double radius;
|
||||
|
||||
_MySliverAppBarDelegate(
|
||||
{this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.color,
|
||||
required this.topPadding,
|
||||
this.radius = 0});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
color: color,
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "返回".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxExtent => _kAppBarHeight + topPadding;
|
||||
|
||||
@override
|
||||
double get minExtent => _kAppBarHeight + topPadding;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
|
||||
return oldDelegate is! _MySliverAppBarDelegate ||
|
||||
leading != oldDelegate.leading ||
|
||||
title != oldDelegate.title ||
|
||||
actions != oldDelegate.actions;
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingSearchBar extends StatefulWidget {
|
||||
const FloatingSearchBar({
|
||||
super.key,
|
||||
this.height = 56,
|
||||
this.trailing,
|
||||
required this.onSearch,
|
||||
required this.controller,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
/// height of search bar
|
||||
final double height;
|
||||
|
||||
/// end of search bar
|
||||
final Widget? trailing;
|
||||
|
||||
/// callback when user do search
|
||||
final void Function(String) onSearch;
|
||||
|
||||
/// controller of [TextField]
|
||||
final TextEditingController controller;
|
||||
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
@override
|
||||
State<FloatingSearchBar> createState() => _FloatingSearchBarState();
|
||||
}
|
||||
|
||||
class _FloatingSearchBarState extends State<FloatingSearchBar> {
|
||||
double get effectiveHeight {
|
||||
return math.max(widget.height, 53);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
var text = widget.controller.text;
|
||||
if (text.isEmpty) {
|
||||
text = "Search";
|
||||
}
|
||||
var padding = 12.0;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(padding, 9, padding, 0),
|
||||
width: double.infinity,
|
||||
height: effectiveHeight,
|
||||
child: Material(
|
||||
elevation: 0,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(effectiveHeight / 2),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(children: [
|
||||
Tooltip(
|
||||
message: "返回".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (s) {
|
||||
widget.onSearch(s);
|
||||
},
|
||||
onChanged: widget.onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.trailing != null) widget.trailing!
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilledTabBar extends StatefulWidget {
|
||||
const FilledTabBar({super.key, this.controller, required this.tabs});
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
final List<Tab> tabs;
|
||||
|
||||
@override
|
||||
State<FilledTabBar> createState() => _FilledTabBarState();
|
||||
}
|
||||
|
||||
class _FilledTabBarState extends State<FilledTabBar> {
|
||||
late TabController _controller;
|
||||
|
||||
late List<GlobalKey> keys;
|
||||
|
||||
static const _kTabHeight = 48.0;
|
||||
|
||||
static const tabPadding = EdgeInsets.symmetric(horizontal: 6, vertical: 6);
|
||||
|
||||
static const tabRadius = 12.0;
|
||||
|
||||
_IndicatorPainter? painter;
|
||||
|
||||
var scrollController = ScrollController();
|
||||
|
||||
var tabBarKey = GlobalKey();
|
||||
|
||||
var offsets = <double>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
keys = widget.tabs.map((e) => GlobalKey()).toList();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
initPainter();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilledTabBar oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
initPainter();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
void initPainter() {
|
||||
var old = painter;
|
||||
painter = _IndicatorPainter(
|
||||
controller: _controller,
|
||||
color: context.colorScheme.primary,
|
||||
padding: tabPadding,
|
||||
radius: tabRadius,
|
||||
);
|
||||
if (old != null) {
|
||||
painter!.update(old.offsets!, old.itemHeight!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: buildTabBar,
|
||||
);
|
||||
}
|
||||
|
||||
void _tabLayoutCallback(List<double> offsets, double itemHeight) {
|
||||
painter!.update(offsets, itemHeight);
|
||||
this.offsets = offsets;
|
||||
}
|
||||
|
||||
Widget buildTabBar(BuildContext context, Widget? _) {
|
||||
var child = SmoothScrollProvider(
|
||||
controller: scrollController,
|
||||
builder: (context, controller, physics) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
child: CustomPaint(
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
callback: _tabLayoutCallback,
|
||||
children: List.generate(widget.tabs.length, buildTab),
|
||||
),
|
||||
).paddingHorizontal(4),
|
||||
);
|
||||
},
|
||||
);
|
||||
return Container(
|
||||
key: tabBarKey,
|
||||
height: _kTabHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.tabs.isEmpty
|
||||
? const SizedBox()
|
||||
: child
|
||||
);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
|
||||
void onTabChanged() {
|
||||
final int i = _controller.index;
|
||||
if (i == previousIndex) {
|
||||
return;
|
||||
}
|
||||
updateScrollOffset(i);
|
||||
previousIndex = i;
|
||||
}
|
||||
|
||||
void updateScrollOffset(int i) {
|
||||
// try to scroll to center the tab
|
||||
final RenderBox tabBarBox =
|
||||
tabBarKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final double tabLeft = offsets[i];
|
||||
final double tabRight = offsets[i + 1];
|
||||
final double tabWidth = tabRight - tabLeft;
|
||||
final double tabCenter = tabLeft + tabWidth / 2;
|
||||
final double tabBarWidth = tabBarBox.size.width;
|
||||
final double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
if (scrollOffset == scrollController.offset) {
|
||||
return;
|
||||
}
|
||||
scrollController.animateTo(
|
||||
scrollOffset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void onTabClicked(int i) {
|
||||
_controller.animateTo(i);
|
||||
}
|
||||
|
||||
Widget buildTab(int i) {
|
||||
return InkWell(
|
||||
onTap: () => onTabClicked(i),
|
||||
borderRadius: BorderRadius.circular(tabRadius),
|
||||
child: KeyedSubtree(
|
||||
key: keys[i],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: i == _controller.index
|
||||
? context.colorScheme.primary
|
||||
: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: widget.tabs[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(tabPadding);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _TabRenderCallback = void Function(
|
||||
List<double> offsets,
|
||||
double itemHeight,
|
||||
);
|
||||
|
||||
class _TabRow extends Row {
|
||||
const _TabRow({required this.callback, required super.children});
|
||||
|
||||
final _TabRenderCallback callback;
|
||||
|
||||
@override
|
||||
RenderFlex createRenderObject(BuildContext context) {
|
||||
return _RenderTabFlex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
textDirection: Directionality.of(context),
|
||||
verticalDirection: VerticalDirection.down,
|
||||
callback: callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderTabFlex renderObject) {
|
||||
super.updateRenderObject(context, renderObject);
|
||||
renderObject.callback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderTabFlex extends RenderFlex {
|
||||
_RenderTabFlex({
|
||||
required super.direction,
|
||||
required super.mainAxisSize,
|
||||
required super.mainAxisAlignment,
|
||||
required super.crossAxisAlignment,
|
||||
required TextDirection super.textDirection,
|
||||
required super.verticalDirection,
|
||||
required this.callback,
|
||||
});
|
||||
|
||||
_TabRenderCallback callback;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
RenderBox? child = firstChild;
|
||||
final List<double> xOffsets = <double>[];
|
||||
while (child != null) {
|
||||
final FlexParentData childParentData =
|
||||
child.parentData! as FlexParentData;
|
||||
xOffsets.add(childParentData.offset.dx);
|
||||
assert(child.parentData == childParentData);
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
xOffsets.add(size.width);
|
||||
callback(xOffsets, firstChild!.size.height);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorPainter extends CustomPainter {
|
||||
_IndicatorPainter({
|
||||
required this.controller,
|
||||
required this.color,
|
||||
required this.padding,
|
||||
this.radius = 4.0,
|
||||
}) : super(repaint: controller.animation);
|
||||
|
||||
final TabController controller;
|
||||
final Color color;
|
||||
final EdgeInsets padding;
|
||||
final double radius;
|
||||
|
||||
List<double>? offsets;
|
||||
double? itemHeight;
|
||||
Rect? _currentRect;
|
||||
|
||||
void update(List<double> offsets, double itemHeight) {
|
||||
this.offsets = offsets;
|
||||
this.itemHeight = itemHeight;
|
||||
}
|
||||
|
||||
int get maxTabIndex => offsets!.length - 2;
|
||||
|
||||
Rect indicatorRect(Size tabBarSize, int tabIndex) {
|
||||
assert(offsets != null);
|
||||
assert(offsets!.isNotEmpty);
|
||||
assert(tabIndex >= 0);
|
||||
assert(tabIndex <= maxTabIndex);
|
||||
var (tabLeft, tabRight) = (offsets![tabIndex], offsets![tabIndex + 1]);
|
||||
|
||||
const horizontalPadding = 12.0;
|
||||
|
||||
var rect = Rect.fromLTWH(
|
||||
tabLeft + padding.left + horizontalPadding,
|
||||
_FilledTabBarState._kTabHeight - 3.6,
|
||||
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
|
||||
3,
|
||||
);
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (offsets == null || itemHeight == null) {
|
||||
return;
|
||||
}
|
||||
final double index = controller.index.toDouble();
|
||||
final double value = controller.animation!.value;
|
||||
final bool ltr = index > value;
|
||||
final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex);
|
||||
final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex);
|
||||
final Rect fromRect = indicatorRect(size, from);
|
||||
final Rect toRect = indicatorRect(size, to);
|
||||
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
|
||||
final Paint paint = Paint()..color = color;
|
||||
final RRect rrect =
|
||||
RRect.fromRectAndCorners(_currentRect!, topLeft: Radius.circular(radius), topRight: Radius.circular(radius));
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
309
lib/components/button.dart
Normal file
309
lib/components/button.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class HoverBox extends StatefulWidget {
|
||||
const HoverBox(
|
||||
{super.key, required this.child, this.borderRadius = BorderRadius.zero});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
State<HoverBox> createState() => _HoverBoxState();
|
||||
}
|
||||
|
||||
class _HoverBoxState extends State<HoverBox> {
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: isHover
|
||||
? Theme.of(context).colorScheme.surfaceContainerLow
|
||||
: null,
|
||||
borderRadius: widget.borderRadius),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ButtonType { filled, outlined, text, normal }
|
||||
|
||||
class Button extends StatefulWidget {
|
||||
const Button(
|
||||
{super.key,
|
||||
required this.type,
|
||||
required this.child,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
required this.onPressed});
|
||||
|
||||
const Button.filled(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.filled;
|
||||
|
||||
const Button.outlined(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.outlined;
|
||||
|
||||
const Button.text(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.text;
|
||||
|
||||
const Button.normal(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.onPressedAt,
|
||||
this.isLoading = false})
|
||||
: type = ButtonType.normal;
|
||||
|
||||
static Widget icon(
|
||||
{Key? key,
|
||||
required Widget icon,
|
||||
required VoidCallback onPressed,
|
||||
double? size,
|
||||
Color? color,
|
||||
String? tooltip}) {
|
||||
return _IconButton(
|
||||
key: key,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
size: size,
|
||||
color: color,
|
||||
tooltip: tooltip,
|
||||
);
|
||||
}
|
||||
|
||||
final ButtonType type;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
final void Function(Offset location)? onPressedAt;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<Button> createState() => _ButtonState();
|
||||
}
|
||||
|
||||
class _ButtonState extends State<Button> {
|
||||
bool isHover = false;
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Button oldWidget) {
|
||||
if (oldWidget.isLoading != widget.isLoading) {
|
||||
setState(() => isLoading = widget.isLoading);
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var padding = widget.padding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
var width = widget.width;
|
||||
if (width != null) {
|
||||
width = width - padding.horizontal;
|
||||
}
|
||||
var height = widget.height;
|
||||
if (height != null) {
|
||||
height = height - padding.vertical;
|
||||
}
|
||||
Widget child = DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(
|
||||
color: widget.type == ButtonType.filled
|
||||
? context.colorScheme.inversePrimary
|
||||
: context.colorScheme.primary,
|
||||
strokeWidth: 1.8,
|
||||
).fixWidth(16).fixHeight(16)
|
||||
: widget.child,
|
||||
);
|
||||
if (width != null || height != null) {
|
||||
child = child.toCenter();
|
||||
}
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (isLoading) return;
|
||||
widget.onPressed();
|
||||
if (widget.onPressedAt != null) {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
widget.onPressedAt!(offset);
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: (isHover && !isLoading && widget.type == ButtonType.filled)
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
border: widget.type == ButtonType.outlined
|
||||
? Border.all(
|
||||
color: widget.color ??
|
||||
Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color get buttonColor {
|
||||
if (widget.type == ButtonType.filled) {
|
||||
var color = widget.color ?? context.colorScheme.primary;
|
||||
if (isHover) {
|
||||
return color.withOpacity(0.9);
|
||||
} else {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
if (isHover) {
|
||||
return context.colorScheme.outline.withOpacity(0.2);
|
||||
}
|
||||
return Colors.transparent;
|
||||
}
|
||||
|
||||
Color get textColor {
|
||||
if (widget.type == ButtonType.outlined) {
|
||||
return widget.color ?? context.colorScheme.onSurface;
|
||||
}
|
||||
return widget.type == ButtonType.filled
|
||||
? context.colorScheme.onPrimary
|
||||
: (widget.type == ButtonType.text
|
||||
? widget.color ?? context.colorScheme.primary
|
||||
: context.colorScheme.onSurface);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatefulWidget {
|
||||
const _IconButton(
|
||||
{super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.size,
|
||||
this.color,
|
||||
this.tooltip});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
final double? size;
|
||||
|
||||
final String? tooltip;
|
||||
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<_IconButton> createState() => _IconButtonState();
|
||||
}
|
||||
|
||||
class _IconButtonState extends State<_IconButton> {
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: widget.onPressed,
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Tooltip(
|
||||
message: widget.tooltip ?? "",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isHover ? Theme.of(context).colorScheme.surfaceContainer : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
size: widget.size ?? 24,
|
||||
color: widget.color ?? context.colorScheme.primary),
|
||||
child: widget.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
34
lib/components/components.dart
Normal file
34
lib/components/components.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
library components;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/app_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
part 'image.dart';
|
||||
part 'appbar.dart';
|
||||
part 'button.dart';
|
||||
part 'consts.dart';
|
||||
part 'flyout.dart';
|
||||
part 'layout.dart';
|
||||
part 'loading.dart';
|
||||
part 'menu.dart';
|
||||
part 'message.dart';
|
||||
part 'navigation_bar.dart';
|
||||
part 'pop_up_widget.dart';
|
||||
part 'scroll.dart';
|
||||
part 'select.dart';
|
||||
part 'side_bar.dart';
|
3
lib/components/consts.dart
Normal file
3
lib/components/consts.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
part of 'components.dart';
|
||||
|
||||
const _fastAnimationDuration = Duration(milliseconds: 160);
|
222
lib/components/custom_slider.dart
Normal file
222
lib/components/custom_slider.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// copied from flutter source
|
||||
class _SliderDefaultsM3 extends SliderThemeData {
|
||||
_SliderDefaultsM3(this.context)
|
||||
: super(trackHeight: 4.0);
|
||||
|
||||
final BuildContext context;
|
||||
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
||||
|
||||
@override
|
||||
Color? get activeTrackColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
|
||||
|
||||
@override
|
||||
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get thumbColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface);
|
||||
|
||||
@override
|
||||
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return _colors.primary.withOpacity(0.08);
|
||||
}
|
||||
if (states.contains(WidgetState.focused)) {
|
||||
return _colors.primary.withOpacity(0.12);
|
||||
}
|
||||
if (states.contains(WidgetState.dragged)) {
|
||||
return _colors.primary.withOpacity(0.12);
|
||||
}
|
||||
|
||||
return Colors.transparent;
|
||||
});
|
||||
|
||||
@override
|
||||
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
|
||||
color: _colors.onPrimary,
|
||||
);
|
||||
|
||||
@override
|
||||
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
|
||||
}
|
||||
|
||||
class CustomSlider extends StatefulWidget {
|
||||
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, this.reversed = false, super.key});
|
||||
|
||||
final double min;
|
||||
|
||||
final double max;
|
||||
|
||||
final double value;
|
||||
|
||||
final int divisions;
|
||||
|
||||
final void Function(double) onChanged;
|
||||
|
||||
final bool reversed;
|
||||
|
||||
@override
|
||||
State<CustomSlider> createState() => _CustomSliderState();
|
||||
}
|
||||
|
||||
class _CustomSliderState extends State<CustomSlider> {
|
||||
late double value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
value = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomSlider oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
setState(() {
|
||||
value = widget.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final theme = _SliderDefaultsM3(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constrains) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapDown: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(widget.reversed){
|
||||
dx = constrains.maxWidth - dx;
|
||||
}
|
||||
var gap = constrains.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
onVerticalDragUpdate: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(dx > constrains.maxWidth || dx < 0) return;
|
||||
if(widget.reversed){
|
||||
dx = constrains.maxWidth - dx;
|
||||
}
|
||||
var gap = constrains.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.inactiveTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if(constrains.maxWidth / widget.divisions > 10)
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: (){
|
||||
var res = <Widget>[];
|
||||
for(int i = 0; i<widget.divisions-1; i++){
|
||||
res.add(const Spacer());
|
||||
res.add(Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withRed(10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
));
|
||||
}
|
||||
res.add(const Spacer());
|
||||
return res;
|
||||
}.call(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : 0,
|
||||
right: widget.reversed ? 0 : null,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
right: !widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
315
lib/components/flyout.dart
Normal file
315
lib/components/flyout.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
part of "components.dart";
|
||||
|
||||
const minFlyoutWidth = 256.0;
|
||||
const minFlyoutHeight = 128.0;
|
||||
|
||||
class FlyoutController {
|
||||
Function? _show;
|
||||
|
||||
void show() {
|
||||
if (_show == null) {
|
||||
throw "FlyoutController is not attached to a Flyout";
|
||||
}
|
||||
_show!();
|
||||
}
|
||||
}
|
||||
|
||||
class Flyout extends StatefulWidget {
|
||||
const Flyout(
|
||||
{super.key,
|
||||
required this.flyoutBuilder,
|
||||
required this.child,
|
||||
this.enableTap = false,
|
||||
this.enableDoubleTap = false,
|
||||
this.enableLongPress = false,
|
||||
this.enableSecondaryTap = false,
|
||||
this.withInkWell = false,
|
||||
this.borderRadius = 0,
|
||||
this.controller,
|
||||
this.navigator});
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final bool enableTap;
|
||||
|
||||
final bool enableDoubleTap;
|
||||
|
||||
final bool enableLongPress;
|
||||
|
||||
final bool enableSecondaryTap;
|
||||
|
||||
final bool withInkWell;
|
||||
|
||||
final double borderRadius;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
final FlyoutController? controller;
|
||||
|
||||
@override
|
||||
State<Flyout> createState() => _FlyoutState();
|
||||
}
|
||||
|
||||
class _FlyoutState extends State<Flyout> {
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.controller != null) {
|
||||
widget.controller?._show = show;
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (widget.controller != null) {
|
||||
widget.controller?._show = show;
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.withInkWell) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
onTap: widget.enableTap ? show : null,
|
||||
onDoubleTap: widget.enableDoubleTap ? show : null,
|
||||
onLongPress: widget.enableLongPress ? show : null,
|
||||
onSecondaryTap: widget.enableSecondaryTap ? show : null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: widget.enableTap ? show : null,
|
||||
onDoubleTap: widget.enableDoubleTap ? show : null,
|
||||
onLongPress: widget.enableLongPress ? show : null,
|
||||
onSecondaryTap: widget.enableSecondaryTap ? show : null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void show() {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var rect = renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||
var navigator = widget.navigator ?? Navigator.of(context);
|
||||
navigator.push(PageRouteBuilder(
|
||||
fullscreenDialog: true,
|
||||
barrierDismissible: true,
|
||||
opaque: false,
|
||||
transitionDuration: _fastAnimationDuration,
|
||||
reverseTransitionDuration: _fastAnimationDuration,
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
var left = rect.left;
|
||||
var top = rect.bottom;
|
||||
|
||||
if (left + minFlyoutWidth > MediaQuery.of(context).size.width) {
|
||||
left = MediaQuery.of(context).size.width - minFlyoutWidth;
|
||||
}
|
||||
if (top + minFlyoutHeight > MediaQuery.of(context).size.height) {
|
||||
top = MediaQuery.of(context).size.height - minFlyoutHeight;
|
||||
}
|
||||
|
||||
Widget transition(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget flyout) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -0.05),
|
||||
end: const Offset(0, 0),
|
||||
).animate(animation),
|
||||
child: flyout,
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: navigator.pop,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, builder) {
|
||||
return ColoredBox(
|
||||
color: Colors.black.withOpacity(0.3 * animation.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: left,
|
||||
right: 0,
|
||||
top: top,
|
||||
bottom: 0,
|
||||
child: transition(
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: widget.flyoutBuilder(context),
|
||||
)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutContent extends StatelessWidget {
|
||||
const FlyoutContent(
|
||||
{super.key, required this.title, required this.actions, this.content});
|
||||
|
||||
final String title;
|
||||
|
||||
final String? content;
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
type: MaterialType.card,
|
||||
elevation: 1,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: minFlyoutWidth,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (content != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(content!, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [const Spacer(), ...actions],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingAll(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutTextButton extends StatefulWidget {
|
||||
const FlyoutTextButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutTextButton> createState() => _FlyoutTextButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutTextButtonState extends State<FlyoutTextButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutIconButton extends StatefulWidget {
|
||||
const FlyoutIconButton(
|
||||
{super.key,
|
||||
required this.icon,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutIconButton> createState() => _FlyoutIconButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutIconButtonState extends State<FlyoutIconButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
icon: widget.icon,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutFilledButton extends StatefulWidget {
|
||||
const FlyoutFilledButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutFilledButton> createState() => _FlyoutFilledButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutFilledButtonState extends State<FlyoutFilledButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
314
lib/components/image.dart
Normal file
314
lib/components/image.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class AnimatedImage extends StatefulWidget {
|
||||
/// show animation when loading is complete.
|
||||
AnimatedImage({
|
||||
required ImageProvider image,
|
||||
super.key,
|
||||
double scale = 1.0,
|
||||
this.semanticLabel,
|
||||
this.excludeFromSemantics = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.color,
|
||||
this.opacity,
|
||||
this.colorBlendMode,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.repeat = ImageRepeat.noRepeat,
|
||||
this.centerSlice,
|
||||
this.matchTextDirection = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.filterQuality = FilterQuality.medium,
|
||||
this.isAntiAlias = false,
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
}
|
||||
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
|
||||
assert(cacheWidth == null || cacheWidth > 0),
|
||||
assert(cacheHeight == null || cacheHeight > 0);
|
||||
|
||||
final ImageProvider image;
|
||||
|
||||
final String? semanticLabel;
|
||||
|
||||
final bool excludeFromSemantics;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final bool gaplessPlayback;
|
||||
|
||||
final bool matchTextDirection;
|
||||
|
||||
final Rect? centerSlice;
|
||||
|
||||
final ImageRepeat repeat;
|
||||
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
final BoxFit? fit;
|
||||
|
||||
final BlendMode? colorBlendMode;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
final Animation<double>? opacity;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
static void clear() => _AnimatedImageState.clear();
|
||||
|
||||
@override
|
||||
State<AnimatedImage> createState() => _AnimatedImageState();
|
||||
}
|
||||
|
||||
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
bool _isListeningToStream = false;
|
||||
late bool _invertColors;
|
||||
int? _frameNumber;
|
||||
bool _wasSynchronouslyLoaded = false;
|
||||
late DisposableBuildContext<State<AnimatedImage>> _scrollAwareContext;
|
||||
Object? _lastException;
|
||||
ImageStreamCompleterHandle? _completerHandle;
|
||||
|
||||
static final Map<int, Size> _cache = {};
|
||||
|
||||
static clear() => _cache.clear();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<AnimatedImage>>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
assert(_imageStream != null);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_stopListeningToStream();
|
||||
_completerHandle?.dispose();
|
||||
_scrollAwareContext.dispose();
|
||||
_replaceImage(info: null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_updateInvertColors();
|
||||
_resolveImage();
|
||||
|
||||
if (TickerMode.of(context)) {
|
||||
_listenToStream();
|
||||
} else {
|
||||
_stopListeningToStream(keepStreamAlive: true);
|
||||
}
|
||||
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.image != oldWidget.image) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAccessibilityFeatures() {
|
||||
super.didChangeAccessibilityFeatures();
|
||||
setState(() {
|
||||
_updateInvertColors();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
_resolveImage(); // in case the image cache was flushed
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
void _updateInvertColors() {
|
||||
_invertColors = MediaQuery.maybeInvertColorsOf(context)
|
||||
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: widget.image,
|
||||
);
|
||||
final ImageStream newStream =
|
||||
provider.resolve(createLocalImageConfiguration(
|
||||
context,
|
||||
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
|
||||
));
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStreamListener _getListener({bool recreateListener = false}) {
|
||||
if(_imageStreamListener == null || recreateListener) {
|
||||
_lastException = null;
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
_handleImageFrame,
|
||||
onChunk: _handleImageChunk,
|
||||
onError: (Object error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_lastException = error;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
|
||||
setState(() {
|
||||
_replaceImage(info: imageInfo);
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
|
||||
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _replaceImage({required ImageInfo? info}) {
|
||||
final ImageInfo? oldImageInfo = _imageInfo;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
|
||||
_imageInfo = info;
|
||||
}
|
||||
|
||||
// Updates _imageStream to newStream, and moves the stream listener
|
||||
// registration from the old stream to the new stream (if a listener was
|
||||
// registered).
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.removeListener(_getListener());
|
||||
}
|
||||
|
||||
if (!widget.gaplessPlayback) {
|
||||
setState(() { _replaceImage(info: null); });
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingProgress = null;
|
||||
_frameNumber = null;
|
||||
_wasSynchronouslyLoaded = false;
|
||||
});
|
||||
|
||||
_imageStream = newStream;
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.addListener(_getListener());
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToStream() {
|
||||
if (_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
_imageStream!.addListener(_getListener());
|
||||
_completerHandle?.dispose();
|
||||
_completerHandle = null;
|
||||
|
||||
_isListeningToStream = true;
|
||||
}
|
||||
|
||||
/// Stops listening to the image stream, if this state object has attached a
|
||||
/// listener.
|
||||
///
|
||||
/// If the listener from this state is the last listener on the stream, the
|
||||
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
|
||||
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
|
||||
/// alive and is compatible with the [TickerMode] being off.
|
||||
void _stopListeningToStream({bool keepStreamAlive = false}) {
|
||||
if (!_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
|
||||
_completerHandle = _imageStream!.completer!.keepAlive();
|
||||
}
|
||||
|
||||
_imageStream!.removeListener(_getListener());
|
||||
_isListeningToStream = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget result;
|
||||
|
||||
if(_imageInfo != null){
|
||||
result = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: BoxFit.cover,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(Icons.error),
|
||||
);
|
||||
|
||||
if (!widget.excludeFromSemantics) {
|
||||
result = Semantics(
|
||||
container: widget.semanticLabel != null,
|
||||
image: true,
|
||||
label: widget.semanticLabel ?? '',
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
} else{
|
||||
result = const Center();
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
||||
super.debugFillProperties(description);
|
||||
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
|
||||
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
|
||||
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
|
||||
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
|
||||
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
|
||||
}
|
||||
}
|
139
lib/components/layout.dart
Normal file
139
lib/components/layout.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
|
||||
const SliverGridViewWithFixedItemHeight(
|
||||
{required this.delegate,
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
super.key});
|
||||
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
final double maxCrossAxisExtent;
|
||||
|
||||
final double itemHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverLayoutBuilder(
|
||||
builder: ((context, constraints) => SliverGrid(
|
||||
delegate: delegate,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
childAspectRatio:
|
||||
calcChildAspectRatio(constraints.crossAxisExtent)),
|
||||
)));
|
||||
}
|
||||
|
||||
double calcChildAspectRatio(double width) {
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
final itemWidth = width / crossItems;
|
||||
return itemWidth / itemHeight;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithFixedHeight extends SliverGridDelegate{
|
||||
const SliverGridDelegateWithFixedHeight({
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
});
|
||||
|
||||
final double maxCrossAxisExtent;
|
||||
|
||||
final double itemHeight;
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
final width = constraints.crossAxisExtent;
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossItems,
|
||||
mainAxisStride: itemHeight,
|
||||
crossAxisStride: width / crossItems,
|
||||
childMainAxisExtent: itemHeight,
|
||||
childCrossAxisExtent: width / crossItems,
|
||||
reverseCrossAxis: false
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if(oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
|
||||
if(oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent
|
||||
|| oldDelegate.itemHeight != itemHeight){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithComics extends SliverGridDelegate{
|
||||
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
|
||||
|
||||
final bool useBriefMode;
|
||||
|
||||
final double? scale;
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
if(appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode){
|
||||
return getBriefModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
|
||||
} else {
|
||||
return getDetailedModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
|
||||
}
|
||||
}
|
||||
|
||||
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale){
|
||||
const maxCrossAxisExtent = 650;
|
||||
final itemHeight = 164 * scale;
|
||||
final width = constraints.crossAxisExtent;
|
||||
var crossItems = width ~/ maxCrossAxisExtent;
|
||||
if (width % maxCrossAxisExtent != 0) {
|
||||
crossItems += 1;
|
||||
}
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossItems,
|
||||
mainAxisStride: itemHeight,
|
||||
crossAxisStride: width / crossItems,
|
||||
childMainAxisExtent: itemHeight,
|
||||
childCrossAxisExtent: width / crossItems,
|
||||
reverseCrossAxis: false
|
||||
);
|
||||
}
|
||||
|
||||
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale){
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.72;
|
||||
const crossAxisSpacing = 0.0;
|
||||
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
// Ensure a minimum count of 1, can be zero and result in an infinite extent
|
||||
// below when the window size is 0.
|
||||
crossAxisCount = math.max(1, crossAxisCount);
|
||||
final double usableCrossAxisExtent = math.max(
|
||||
0.0,
|
||||
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
|
||||
);
|
||||
final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
|
||||
final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisStride: childMainAxisExtent,
|
||||
crossAxisStride: childCrossAxisExtent + crossAxisSpacing,
|
||||
childMainAxisExtent: childMainAxisExtent,
|
||||
childCrossAxisExtent: childCrossAxisExtent,
|
||||
reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
391
lib/components/loading.dart
Normal file
391
lib/components/loading.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class NetworkError extends StatelessWidget {
|
||||
const NetworkError({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
});
|
||||
|
||||
final String message;
|
||||
|
||||
final void Function()? retry;
|
||||
|
||||
final bool withAppbar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 60,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
),
|
||||
if (retry != null)
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
if (retry != null)
|
||||
FilledButton(onPressed: retry, child: Text('重试'.tl))
|
||||
],
|
||||
),
|
||||
);
|
||||
if (withAppbar) {
|
||||
body = Column(
|
||||
children: [
|
||||
const Appbar(title: Text("")),
|
||||
Expanded(
|
||||
child: body,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListLoadingIndicator extends StatelessWidget {
|
||||
const ListLoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
child: Center(
|
||||
child: FiveDotLoadingAnimation(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
extends State<T> {
|
||||
bool isLoading = false;
|
||||
|
||||
S? data;
|
||||
|
||||
String? error;
|
||||
|
||||
Future<Res<S>> loadData();
|
||||
|
||||
Widget buildContent(BuildContext context, S data);
|
||||
|
||||
Widget? buildFrame(BuildContext context, Widget child) => null;
|
||||
|
||||
Widget buildLoading() {
|
||||
return Center(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(32).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
||||
void retry() {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
});
|
||||
loadData().then((value) {
|
||||
if (value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildError() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
error!,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Button.text(
|
||||
onPressed: retry,
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
isLoading = true;
|
||||
Future.microtask(() {
|
||||
loadData().then((value) {
|
||||
if (value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
|
||||
if (isLoading) {
|
||||
child = buildLoading();
|
||||
} else if (error != null) {
|
||||
child = buildError();
|
||||
} else {
|
||||
child = buildContent(context, data!);
|
||||
}
|
||||
|
||||
return buildFrame(context, child) ?? child;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
||||
extends State<T> {
|
||||
bool _isFirstLoading = true;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
List<S>? data;
|
||||
|
||||
String? _error;
|
||||
|
||||
int _page = 1;
|
||||
|
||||
int _maxPage = 1;
|
||||
|
||||
Future<Res<List<S>>> loadData(int page);
|
||||
|
||||
Widget? buildFrame(BuildContext context, Widget child) => null;
|
||||
|
||||
Widget buildContent(BuildContext context, List<S> data);
|
||||
|
||||
bool get isLoading => _isLoading || _isFirstLoading;
|
||||
|
||||
bool get isFirstLoading => _isFirstLoading;
|
||||
|
||||
bool get haveNextPage => _page <= _maxPage;
|
||||
|
||||
void nextPage() {
|
||||
if (_page > _maxPage) return;
|
||||
if (_isLoading) return;
|
||||
_isLoading = true;
|
||||
loadData(_page).then((value) {
|
||||
_isLoading = false;
|
||||
if (mounted) {
|
||||
if (value.success) {
|
||||
_page++;
|
||||
if (value.subData is int) {
|
||||
_maxPage = value.subData as int;
|
||||
}
|
||||
setState(() {
|
||||
data!.addAll(value.data);
|
||||
});
|
||||
} else {
|
||||
var message = value.errorMessage ?? "Network Error";
|
||||
if (message.length > 20) {
|
||||
message = "${message.substring(0, 20)}...";
|
||||
}
|
||||
context.showMessage(message: message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
setState(() {
|
||||
_isFirstLoading = true;
|
||||
_isLoading = false;
|
||||
data = null;
|
||||
_error = null;
|
||||
_page = 1;
|
||||
});
|
||||
firstLoad();
|
||||
}
|
||||
|
||||
void firstLoad() {
|
||||
Future.microtask(() {
|
||||
loadData(_page).then((value) {
|
||||
if (!mounted) return;
|
||||
if (value.success) {
|
||||
_page++;
|
||||
if (value.subData is int) {
|
||||
_maxPage = value.subData as int;
|
||||
}
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
firstLoad();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget buildLoading(BuildContext context) {
|
||||
return Center(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(32).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildError(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, maxLines: 3),
|
||||
const SizedBox(height: 12),
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
reset();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
|
||||
if (_isFirstLoading) {
|
||||
child = buildLoading(context);
|
||||
} else if (_error != null) {
|
||||
child = buildError(context, _error!);
|
||||
} else {
|
||||
child = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.maxScrollExtent) {
|
||||
nextPage();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: buildContent(context, data!),
|
||||
);
|
||||
}
|
||||
|
||||
return buildFrame(context, child) ?? child;
|
||||
}
|
||||
}
|
||||
|
||||
class FiveDotLoadingAnimation extends StatefulWidget {
|
||||
const FiveDotLoadingAnimation({super.key});
|
||||
|
||||
@override
|
||||
State<FiveDotLoadingAnimation> createState() =>
|
||||
_FiveDotLoadingAnimationState();
|
||||
}
|
||||
|
||||
class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
upperBound: 6,
|
||||
)..repeat(min: 0, max: 5.2, period: const Duration(milliseconds: 1200));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _colors = [
|
||||
Colors.red,
|
||||
Colors.green,
|
||||
Colors.blue,
|
||||
Colors.yellow,
|
||||
Colors.purple
|
||||
];
|
||||
|
||||
static const _padding = 12.0;
|
||||
|
||||
static const _dotSize = 12.0;
|
||||
|
||||
static const _height = 24.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: _dotSize * 5 + _padding * 6,
|
||||
height: _height,
|
||||
child: Stack(
|
||||
children: List.generate(5, (index) => buildDot(index)),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildDot(int index) {
|
||||
var value = _controller.value;
|
||||
var startValue = index * 0.8;
|
||||
return Positioned(
|
||||
left: index * _dotSize + (index + 1) * _padding,
|
||||
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
||||
(_height - _dotSize),
|
||||
child: Container(
|
||||
width: _dotSize,
|
||||
height: _dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: _colors[index],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
117
lib/components/menu.dart
Normal file
117
lib/components/menu.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
part of "components.dart";
|
||||
|
||||
void showDesktopMenu(
|
||||
BuildContext context, Offset location, List<DesktopMenuEntry> entries) {
|
||||
Navigator.of(context).push(DesktopMenuRoute(entries, location));
|
||||
}
|
||||
|
||||
class DesktopMenuRoute<T> extends PopupRoute<T> {
|
||||
final List<DesktopMenuEntry> entries;
|
||||
|
||||
final Offset location;
|
||||
|
||||
DesktopMenuRoute(this.entries, this.location);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.transparent;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "menu";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
const width = 196.0;
|
||||
final size = MediaQuery.of(context).size;
|
||||
var left = location.dx;
|
||||
if (left + width > size.width - 10) {
|
||||
left = size.width - width - 10;
|
||||
}
|
||||
var top = location.dy;
|
||||
var height = 16 + 32 * entries.length;
|
||||
if (top + height > size.height - 15) {
|
||||
top = size.height - height - 15;
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]),
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: entries.map((e) => buildEntry(e, context)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEntry(DesktopMenuEntry entry, BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
entry.onClick();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
if (entry.icon != null)
|
||||
Icon(
|
||||
entry.icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(entry.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return FadeTransition(
|
||||
opacity: animation.drive(Tween<double>(begin: 0, end: 1)
|
||||
.chain(CurveTween(curve: Curves.ease))),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopMenuEntry {
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final void Function() onClick;
|
||||
|
||||
DesktopMenuEntry({required this.text, this.icon, required this.onClick});
|
||||
}
|
217
lib/components/message.dart
Normal file
217
lib/components/message.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
part of "components.dart";
|
||||
|
||||
class OverlayWidget extends StatefulWidget {
|
||||
const OverlayWidget(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<OverlayWidget> createState() => OverlayWidgetState();
|
||||
}
|
||||
|
||||
class OverlayWidgetState extends State<OverlayWidget> {
|
||||
final overlayKey = GlobalKey<OverlayState>();
|
||||
|
||||
var entries = <OverlayEntry>[];
|
||||
|
||||
void addOverlay(OverlayEntry entry) {
|
||||
if (overlayKey.currentState != null) {
|
||||
overlayKey.currentState!.insert(entry);
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(OverlayEntry entry) {
|
||||
if (entries.remove(entry)) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void removeAll() {
|
||||
for (var entry in entries) {
|
||||
entry.remove();
|
||||
}
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Overlay(
|
||||
key: overlayKey,
|
||||
initialEntries: [OverlayEntry(builder: (context) => widget.child)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showDialogMessage(BuildContext context, String title, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: context.pop,
|
||||
child: Text("OK".tl),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showConfirmDialog(BuildContext context, String title, String content,
|
||||
void Function() onConfirm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: context.pop, child: Text("Cancel".tl)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
onConfirm();
|
||||
},
|
||||
child: Text("Confirm".tl)),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
class LoadingDialogController {
|
||||
void Function()? closeDialog;
|
||||
|
||||
bool closed = false;
|
||||
|
||||
void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (closeDialog == null) {
|
||||
Future.microtask(closeDialog!);
|
||||
} else {
|
||||
closeDialog!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
{void Function()? onCancel,
|
||||
bool barrierDismissible = true,
|
||||
bool allowCancel = true,
|
||||
String? message,
|
||||
String cancelButtonText = "Cancel"}) {
|
||||
var controller = LoadingDialogController();
|
||||
|
||||
var loadingDialogRoute = DialogRoute(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Text(
|
||||
message ?? 'Loading',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const Spacer(),
|
||||
if (allowCancel)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
controller.close();
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(cancelButtonText.tl))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
var navigator = Navigator.of(context);
|
||||
|
||||
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
||||
|
||||
controller.closeDialog = () {
|
||||
navigator.removeRoute(loadingDialogRoute);
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
class ContentDialog extends StatelessWidget {
|
||||
const ContentDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.dismissible = true,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
final Widget content;
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
final bool dismissible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Appbar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: dismissible ? context.pop : null,
|
||||
),
|
||||
title: Text(title),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
this.content,
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions,
|
||||
).paddingRight(12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
insetPadding: context.width < 400
|
||||
? const EdgeInsets.symmetric(horizontal: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: IntrinsicWidth(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600,
|
||||
minWidth: math.min(400, context.width - 16),
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
removeBottom: true,
|
||||
context: context,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
695
lib/components/navigation_bar.dart
Normal file
695
lib/components/navigation_bar.dart
Normal file
@@ -0,0 +1,695 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class PaneItemEntry {
|
||||
String label;
|
||||
|
||||
IconData icon;
|
||||
|
||||
IconData activeIcon;
|
||||
|
||||
PaneItemEntry(
|
||||
{required this.label, required this.icon, required this.activeIcon});
|
||||
}
|
||||
|
||||
class PaneActionEntry {
|
||||
String label;
|
||||
|
||||
IconData icon;
|
||||
|
||||
VoidCallback onTap;
|
||||
|
||||
PaneActionEntry(
|
||||
{required this.label, required this.icon, required this.onTap});
|
||||
}
|
||||
|
||||
class NaviPane extends StatefulWidget {
|
||||
const NaviPane(
|
||||
{required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChange,
|
||||
required this.observer,
|
||||
super.key});
|
||||
|
||||
final List<PaneItemEntry> paneItems;
|
||||
|
||||
final List<PaneActionEntry> paneActions;
|
||||
|
||||
final Widget Function(int page) pageBuilder;
|
||||
|
||||
final void Function(int index)? onPageChange;
|
||||
|
||||
final int initialPage;
|
||||
|
||||
final NaviObserver observer;
|
||||
|
||||
@override
|
||||
State<NaviPane> createState() => _NaviPaneState();
|
||||
}
|
||||
|
||||
class _NaviPaneState extends State<NaviPane>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentPage = widget.initialPage;
|
||||
|
||||
int get currentPage => _currentPage;
|
||||
|
||||
set currentPage(int value) {
|
||||
if (value == _currentPage) return;
|
||||
_currentPage = value;
|
||||
widget.onPageChange?.call(value);
|
||||
}
|
||||
|
||||
late AnimationController controller;
|
||||
|
||||
static const _kBottomBarHeight = 58.0;
|
||||
|
||||
static const _kFoldedSideBarWidth = 80.0;
|
||||
|
||||
static const _kSideBarWidth = 256.0;
|
||||
|
||||
static const _kTopBarHeight = 48.0;
|
||||
|
||||
double get bottomBarHeight =>
|
||||
_kBottomBarHeight + MediaQuery.of(context).padding.bottom;
|
||||
|
||||
void onNavigatorStateChange() {
|
||||
onRebuild(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
lowerBound: 0,
|
||||
upperBound: 3,
|
||||
vsync: this,
|
||||
);
|
||||
widget.observer.addListener(onNavigatorStateChange);
|
||||
StateController.put(NaviPaddingWidgetController());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
StateController.remove<NaviPaddingWidgetController>();
|
||||
controller.dispose();
|
||||
widget.observer.removeListener(onNavigatorStateChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double targetFormContext(BuildContext context) {
|
||||
var width = MediaQuery.of(context).size.width;
|
||||
double target = 0;
|
||||
if (widget.observer.pageCount > 1) {
|
||||
target = 1;
|
||||
}
|
||||
if (width > changePoint) {
|
||||
target = 2;
|
||||
}
|
||||
if (width > changePoint2) {
|
||||
target = 3;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
double? animationTarget;
|
||||
|
||||
void onRebuild(BuildContext context) {
|
||||
double target = targetFormContext(context);
|
||||
if (controller.value != target || animationTarget != target) {
|
||||
if (controller.isAnimating) {
|
||||
if (animationTarget == target) {
|
||||
return;
|
||||
} else {
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
if (target == 1) {
|
||||
StateController.find<NaviPaddingWidgetController>()
|
||||
.setWithPadding(true);
|
||||
controller.value = target;
|
||||
} else if (controller.value == 1 && target == 0) {
|
||||
StateController.findOrNull<NaviPaddingWidgetController>()
|
||||
?.setWithPadding(false);
|
||||
controller.value = target;
|
||||
} else {
|
||||
controller.animateTo(target);
|
||||
}
|
||||
animationTarget = target;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
onRebuild(context);
|
||||
return _NaviPopScope(
|
||||
action: () {
|
||||
if (App.mainNavigatorKey!.currentState!.canPop()) {
|
||||
App.mainNavigatorKey!.currentState!.pop();
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
},
|
||||
popGesture: App.isIOS && context.width >= changePoint,
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
final value = controller.value;
|
||||
return Stack(
|
||||
children: [
|
||||
if (value <= 1)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: bottomBarHeight * (0 - value),
|
||||
child: buildBottom(),
|
||||
),
|
||||
if (value <= 1)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _kTopBarHeight * (0 - value) +
|
||||
MediaQuery.of(context).padding.top * (1 - value),
|
||||
child: buildTop(),
|
||||
),
|
||||
Positioned(
|
||||
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: buildLeft(),
|
||||
),
|
||||
Positioned(
|
||||
top: _kTopBarHeight * ((1 - value).clamp(0, 1)) +
|
||||
MediaQuery.of(context).padding.top * (value == 1 ? 0 : 1),
|
||||
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
||||
((value - 2).clamp(0, 1)),
|
||||
right: 0,
|
||||
bottom: bottomBarHeight * ((1 - value).clamp(0, 1)),
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: value >= 2 || value == 0,
|
||||
context: context,
|
||||
child: Material(child: widget.pageBuilder(currentPage)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTop() {
|
||||
return Material(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
height: _kTopBarHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.paneItems[currentPage].label,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
for (var action in widget.paneActions)
|
||||
Tooltip(
|
||||
message: action.label,
|
||||
child: IconButton(
|
||||
icon: Icon(action.icon),
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBottom() {
|
||||
return Material(
|
||||
textStyle: Theme.of(context).textTheme.labelSmall,
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: _kBottomBarHeight + MediaQuery.of(context).padding.bottom,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
|
||||
child: Row(
|
||||
children: List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) => Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
},
|
||||
key: ValueKey(index),
|
||||
))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLeft() {
|
||||
final value = controller.value;
|
||||
const paddingHorizontal = 16.0;
|
||||
return Material(
|
||||
child: Container(
|
||||
width: _kFoldedSideBarWidth +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: value == 3
|
||||
? (_kSideBarWidth - paddingHorizontal * 2 - 1)
|
||||
: (_kFoldedSideBarWidth - paddingHorizontal * 2 - 1),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
...List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) => _SideNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
showTitle: value == 3,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...List<Widget>.generate(
|
||||
widget.paneActions.length,
|
||||
(index) => _PaneActionWidget(
|
||||
entry: widget.paneActions[index],
|
||||
showTitle: value == 3,
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SideNaviWidget extends StatefulWidget {
|
||||
const _SideNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
final PaneItemEntry entry;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_SideNaviWidget> createState() => _SideNaviWidgetState();
|
||||
}
|
||||
|
||||
class _SideNaviWidgetState extends State<_SideNaviWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? colorScheme.primaryContainer
|
||||
: isHovering
|
||||
? colorScheme.surfaceContainerHigh
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaneActionWidget extends StatefulWidget {
|
||||
const _PaneActionWidget(
|
||||
{required this.entry, required this.showTitle, super.key});
|
||||
|
||||
final PaneActionEntry entry;
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_PaneActionWidget> createState() => _PaneActionWidgetState();
|
||||
}
|
||||
|
||||
class _PaneActionWidgetState extends State<_PaneActionWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon = Icon(widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.entry.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: isHovering ? colorScheme.surfaceContainerHigh : null,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidget extends StatefulWidget {
|
||||
const _SingleBottomNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
final PaneItemEntry entry;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_SingleBottomNaviWidget> createState() =>
|
||||
_SingleBottomNaviWidgetState();
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController controller;
|
||||
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SingleBottomNaviWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.enabled != widget.enabled) {
|
||||
if (widget.enabled) {
|
||||
controller.forward(from: 0);
|
||||
} else {
|
||||
controller.reverse(from: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
value: widget.enabled ? 1 : 0,
|
||||
vsync: this,
|
||||
duration: _fastAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: CurvedAnimation(parent: controller, curve: Curves.ease),
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: buildContent(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContent() {
|
||||
final value = controller.value;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
color: isHovering ? colorScheme.surfaceContainer : Colors.transparent,
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32 + value * 32,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
color: value != 0
|
||||
? colorScheme.secondaryContainer
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Center(child: icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NaviObserver extends NavigatorObserver implements Listenable {
|
||||
var routes = Queue<Route>();
|
||||
|
||||
int get pageCount => routes.length;
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
routes.removeLast();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
routes.addLast(route);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route route, Route? previousRoute) {
|
||||
routes.remove(route);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||
routes.remove(oldRoute);
|
||||
if (newRoute != null) {
|
||||
routes.add(newRoute);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<VoidCallback> listeners = [];
|
||||
|
||||
@override
|
||||
void addListener(VoidCallback listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
void notifyListeners() {
|
||||
for (var listener in listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NaviPopScope extends StatelessWidget {
|
||||
const _NaviPopScope(
|
||||
{required this.child, this.popGesture = false, required this.action});
|
||||
|
||||
final Widget child;
|
||||
final bool popGesture;
|
||||
final VoidCallback action;
|
||||
|
||||
static bool panStartAtEdge = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget res = App.isIOS
|
||||
? child
|
||||
: PopScope(
|
||||
canPop: App.isAndroid ? false : true,
|
||||
// flutter <3.24.0 api
|
||||
onPopInvoked: (value) {
|
||||
action();
|
||||
},
|
||||
/*
|
||||
flutter >=3.24.0 api
|
||||
onPopInvokedWithResult: (value, result) {
|
||||
action();
|
||||
},
|
||||
*/
|
||||
child: child,
|
||||
);
|
||||
if (popGesture) {
|
||||
res = GestureDetector(
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class NaviPaddingWidgetController extends StateController {
|
||||
NaviPaddingWidgetController();
|
||||
|
||||
bool _withPadding = false;
|
||||
|
||||
void setWithPadding(bool value) {
|
||||
_withPadding = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class NaviPaddingWidget extends StatelessWidget {
|
||||
const NaviPaddingWidget({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<NaviPaddingWidgetController>(
|
||||
builder: (controller) {
|
||||
return Padding(
|
||||
padding: controller._withPadding
|
||||
? EdgeInsets.only(
|
||||
top: _NaviPaneState._kTopBarHeight + context.padding.top,
|
||||
bottom:
|
||||
_NaviPaneState._kBottomBarHeight + context.padding.bottom,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
184
lib/components/pop_up_widget.dart
Normal file
184
lib/components/pop_up_widget.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class PopUpWidget<T> extends PopupRoute<T> {
|
||||
PopUpWidget(this.widget);
|
||||
|
||||
final Widget widget;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.black54;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "exit";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
var height = MediaQuery.of(context).size.height * 0.9;
|
||||
bool showPopUp = MediaQuery.of(context).size.width > 500;
|
||||
Widget body = PopupIndicatorWidget(
|
||||
child: Container(
|
||||
decoration: showPopUp
|
||||
? const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
)
|
||||
: null,
|
||||
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
|
||||
width: showPopUp ? 500 : double.infinity,
|
||||
height: showPopUp ? height : double.infinity,
|
||||
child: ClipRect(
|
||||
child: Navigator(
|
||||
onGenerateRoute: (settings) => MaterialPageRoute(
|
||||
builder: (context) => widget,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (App.isIOS) {
|
||||
body = IOSBackGestureDetector(
|
||||
enabledCallback: () => true,
|
||||
gestureWidth: 20.0,
|
||||
onStartPopGesture: () =>
|
||||
IOSBackGestureController(controller!, navigator!),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
if (showPopUp) {
|
||||
return MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
context: context,
|
||||
child: Center(
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 350);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return FadeTransition(
|
||||
opacity: animation.drive(
|
||||
Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.ease)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PopupIndicatorWidget extends InheritedWidget {
|
||||
const PopupIndicatorWidget({super.key, required super.child});
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
|
||||
|
||||
static PopupIndicatorWidget? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<PopupIndicatorWidget>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
||||
return await Navigator.of(context).push(PopUpWidget(widget));
|
||||
}
|
||||
|
||||
class PopUpWidgetScaffold extends StatefulWidget {
|
||||
const PopUpWidgetScaffold(
|
||||
{required this.title, required this.body, this.tailing, super.key});
|
||||
|
||||
final Widget body;
|
||||
final List<Widget>? tailing;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<PopUpWidgetScaffold> createState() => _PopUpWidgetScaffoldState();
|
||||
}
|
||||
|
||||
class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 56 + context.padding.top,
|
||||
padding: EdgeInsets.only(top: context.padding.top),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "返回".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_sharp),
|
||||
onPressed: () => context.canPop()
|
||||
? context.pop()
|
||||
: App.rootNavigatorKey.currentContext?.pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.tailing != null) ...widget.tailing!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
context: context,
|
||||
child: Expanded(child: widget.body),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).viewInsets.bottom -
|
||||
0.05 * MediaQuery.of(context).size.height >
|
||||
0
|
||||
? MediaQuery.of(context).viewInsets.bottom -
|
||||
0.05 * MediaQuery.of(context).size.height
|
||||
: 0,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
100
lib/components/scroll.dart
Normal file
100
lib/components/scroll.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SmoothCustomScrollView extends StatelessWidget {
|
||||
const SmoothCustomScrollView({super.key, required this.slivers, this.controller});
|
||||
|
||||
final ScrollController? controller;
|
||||
|
||||
final List<Widget> slivers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothScrollProvider(
|
||||
controller: controller,
|
||||
builder: (context, controller, physics) {
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
slivers: slivers,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SmoothScrollProvider extends StatefulWidget {
|
||||
const SmoothScrollProvider({super.key, this.controller, required this.builder});
|
||||
|
||||
final ScrollController? controller;
|
||||
|
||||
final Widget Function(BuildContext, ScrollController, ScrollPhysics) builder;
|
||||
|
||||
static bool get isMouseScroll => _SmoothScrollProviderState._isMouseScroll;
|
||||
|
||||
@override
|
||||
State<SmoothScrollProvider> createState() => _SmoothScrollProviderState();
|
||||
}
|
||||
|
||||
class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
late final ScrollController _controller;
|
||||
|
||||
double? _futurePosition;
|
||||
|
||||
static bool _isMouseScroll = App.isDesktop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_controller = widget.controller ?? ScrollController();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(App.isMacOS) {
|
||||
return widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
const ClampingScrollPhysics(),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
if (_isMouseScroll) {
|
||||
setState(() {
|
||||
_isMouseScroll = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onPointerSignal: (pointerSignal) {
|
||||
if (pointerSignal is PointerScrollEvent) {
|
||||
if (pointerSignal.kind == PointerDeviceKind.mouse &&
|
||||
!_isMouseScroll) {
|
||||
setState(() {
|
||||
_isMouseScroll = true;
|
||||
});
|
||||
}
|
||||
if (!_isMouseScroll) return;
|
||||
var currentLocation = _controller.position.pixels;
|
||||
_futurePosition ??= currentLocation;
|
||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||
_futurePosition =
|
||||
_futurePosition! + pointerSignal.scrollDelta.dy * k;
|
||||
_futurePosition = _futurePosition!.clamp(
|
||||
_controller.position.minScrollExtent,
|
||||
_controller.position.maxScrollExtent);
|
||||
_controller.animateTo(_futurePosition!,
|
||||
duration: _fastAnimationDuration, curve: Curves.linear);
|
||||
}
|
||||
},
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
_isMouseScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
346
lib/components/select.dart
Normal file
346
lib/components/select.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Select extends StatefulWidget {
|
||||
const Select({
|
||||
required this.initialValue,
|
||||
this.width = 120,
|
||||
required this.onChange,
|
||||
super.key,
|
||||
required this.values,
|
||||
this.disabledValues = const [],
|
||||
this.outline = false,
|
||||
});
|
||||
|
||||
///初始值, 提供values的下标
|
||||
final int? initialValue;
|
||||
|
||||
///可供选取的值
|
||||
final List<String> values;
|
||||
|
||||
///宽度
|
||||
final double width;
|
||||
|
||||
///发生改变时的回调
|
||||
final void Function(int) onChange;
|
||||
|
||||
/// 禁用的值
|
||||
final List<int> disabledValues;
|
||||
|
||||
/// 是否为边框模式
|
||||
final bool outline;
|
||||
|
||||
@override
|
||||
State<Select> createState() => _SelectState();
|
||||
}
|
||||
|
||||
class _SelectState extends State<Select> {
|
||||
late int? value = widget.initialValue;
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (value != null && value! < 0) value = null;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHover = true),
|
||||
onExit: (_) => setState(() => isHover = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.values.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final renderBox = context.findRenderObject() as RenderBox;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
var size = MediaQuery.of(context).size;
|
||||
showMenu<int>(
|
||||
context: App.rootNavigatorKey.currentContext!,
|
||||
initialValue: value,
|
||||
position: RelativeRect.fromLTRB(offset.dx, offset.dy,
|
||||
offset.dx + widget.width, size.height - offset.dy),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.width,
|
||||
minWidth: widget.width,
|
||||
),
|
||||
color: context.colorScheme.surfaceContainerLowest,
|
||||
items: [
|
||||
for (int i = 0; i < widget.values.length; i++)
|
||||
if (!widget.disabledValues.contains(i))
|
||||
PopupMenuItem(
|
||||
value: i,
|
||||
height: App.isDesktop ? 38 : 42,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
value = i;
|
||||
widget.onChange(i);
|
||||
});
|
||||
},
|
||||
child: Text(widget.values[i]),
|
||||
)
|
||||
]);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(widget.outline ? 4 : 8),
|
||||
border: widget.outline
|
||||
? Border.all(
|
||||
color: context.colorScheme.outline,
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
width: widget.width,
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value == null ? "" : widget.values[value!],
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down_sharp),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color get color {
|
||||
if (widget.outline) {
|
||||
return isHover
|
||||
? context.colorScheme.outline.withOpacity(0.1)
|
||||
: Colors.transparent;
|
||||
} else {
|
||||
var color = context.colorScheme.surfaceContainerHigh;
|
||||
if (isHover) {
|
||||
color = color.withOpacity(0.8);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterChipFixedWidth extends StatefulWidget {
|
||||
const FilterChipFixedWidth(
|
||||
{required this.label,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
super.key});
|
||||
|
||||
final Widget label;
|
||||
|
||||
final bool selected;
|
||||
|
||||
final void Function(bool) onSelected;
|
||||
|
||||
@override
|
||||
State<FilterChipFixedWidth> createState() => _FilterChipFixedWidthState();
|
||||
}
|
||||
|
||||
class _FilterChipFixedWidthState extends State<FilterChipFixedWidth> {
|
||||
get selected => widget.selected;
|
||||
|
||||
double? labelWidth;
|
||||
|
||||
double? labelHeight;
|
||||
|
||||
var key = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Future.microtask(measureSize);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void measureSize() {
|
||||
final RenderBox renderBox =
|
||||
key.currentContext!.findRenderObject() as RenderBox;
|
||||
labelWidth = renderBox.size.width;
|
||||
labelHeight = renderBox.size.height;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onSelected(true),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AnimatedContainer(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: labelWidth == null ? firstBuild() : buildContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget firstBuild() {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
key: key,
|
||||
child: widget.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContent() {
|
||||
const iconSize = 18.0;
|
||||
const gap = 4.0;
|
||||
return SizedBox(
|
||||
width: iconSize + labelWidth! + gap,
|
||||
height: math.max(iconSize, labelHeight!),
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _fastAnimationDuration,
|
||||
left: selected ? (iconSize + gap) : (iconSize + gap) / 2,
|
||||
child: widget.label,
|
||||
),
|
||||
if (selected)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: labelWidth! + gap,
|
||||
child: const AnimatedCheckIcon(size: iconSize).toCenter(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCheckWidget extends AnimatedWidget {
|
||||
const AnimatedCheckWidget({
|
||||
super.key,
|
||||
required Animation<double> animation,
|
||||
this.size,
|
||||
}) : super(listenable: animation);
|
||||
|
||||
final double? size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var iconSize = size ?? IconTheme.of(context).size ?? 25;
|
||||
final animation = listenable as Animation<double>;
|
||||
return SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: animation.value,
|
||||
child: ClipRRect(
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: iconSize,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCheckIcon extends StatefulWidget {
|
||||
const AnimatedCheckIcon({this.size, super.key});
|
||||
|
||||
final double? size;
|
||||
|
||||
@override
|
||||
State<AnimatedCheckIcon> createState() => _AnimatedCheckIconState();
|
||||
}
|
||||
|
||||
class _AnimatedCheckIconState extends State<AnimatedCheckIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late Animation<double> animation;
|
||||
late AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fastAnimationDuration,
|
||||
);
|
||||
animation = Tween<double>(begin: 0, end: 1).animate(controller)
|
||||
..addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
controller.forward();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedCheckWidget(
|
||||
animation: animation,
|
||||
size: widget.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OptionChip extends StatelessWidget {
|
||||
const OptionChip(
|
||||
{super.key,
|
||||
required this.text,
|
||||
required this.isSelected,
|
||||
required this.onTap});
|
||||
|
||||
final String text;
|
||||
|
||||
final bool isSelected;
|
||||
|
||||
final void Function() onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.colorScheme.primaryContainer
|
||||
: context.colorScheme.surface,
|
||||
border: isSelected
|
||||
? Border.all(color: context.colorScheme.primaryContainer)
|
||||
: Border.all(color: context.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Text(text),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
223
lib/components/side_bar.dart
Normal file
223
lib/components/side_bar.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SideBarRoute<T> extends PopupRoute<T> {
|
||||
SideBarRoute(this.title, this.widget,
|
||||
{this.showBarrier = true,
|
||||
this.useSurfaceTintColor = false,
|
||||
required this.width,
|
||||
this.addBottomPadding = true,
|
||||
this.addTopPadding = true});
|
||||
|
||||
final String? title;
|
||||
|
||||
final Widget widget;
|
||||
|
||||
final bool showBarrier;
|
||||
|
||||
final bool useSurfaceTintColor;
|
||||
|
||||
final double width;
|
||||
|
||||
final bool addTopPadding;
|
||||
|
||||
final bool addBottomPadding;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => showBarrier ? Colors.black54 : Colors.transparent;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => "exit";
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
bool showSideBar = MediaQuery.of(context).size.width > width;
|
||||
|
||||
Widget body = SidebarBody(
|
||||
title: title,
|
||||
widget: widget,
|
||||
autoChangeTitleBarColor: !useSurfaceTintColor,
|
||||
);
|
||||
|
||||
if (addTopPadding) {
|
||||
body = Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sideBarWidth = math.min(width, MediaQuery.of(context).size.width);
|
||||
|
||||
body = Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: showSideBar
|
||||
? const BorderRadius.horizontal(left: Radius.circular(16))
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.surfaceTint),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
constraints: BoxConstraints(maxWidth: sideBarWidth),
|
||||
height: MediaQuery.of(context).size.height,
|
||||
child: GestureDetector(
|
||||
child: Material(
|
||||
child: ClipRect(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0,
|
||||
0,
|
||||
MediaQuery.of(context).padding.right,
|
||||
addBottomPadding
|
||||
? MediaQuery.of(context).padding.bottom +
|
||||
MediaQuery.of(context).viewInsets.bottom
|
||||
: 0),
|
||||
color: useSurfaceTintColor
|
||||
? Theme.of(context).colorScheme.surfaceTint.withAlpha(20)
|
||||
: null,
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (App.isIOS) {
|
||||
body = IOSBackGestureDetector(
|
||||
enabledCallback: () => true,
|
||||
gestureWidth: 20.0,
|
||||
onStartPopGesture: () =>
|
||||
IOSBackGestureController(controller!, navigator!),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
var offset =
|
||||
Tween<Offset>(begin: const Offset(1, 0), end: const Offset(0, 0));
|
||||
return SlideTransition(
|
||||
position: offset.animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarBody extends StatefulWidget {
|
||||
const SidebarBody(
|
||||
{required this.title,
|
||||
required this.widget,
|
||||
required this.autoChangeTitleBarColor,
|
||||
super.key});
|
||||
|
||||
final String? title;
|
||||
final Widget widget;
|
||||
final bool autoChangeTitleBarColor;
|
||||
|
||||
@override
|
||||
State<SidebarBody> createState() => _SidebarBodyState();
|
||||
}
|
||||
|
||||
class _SidebarBodyState extends State<SidebarBody> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Expanded(child: widget.widget);
|
||||
|
||||
if (widget.autoChangeTitleBarColor) {
|
||||
body = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
height: 60 + MediaQuery.of(context).padding.top,
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "返回",
|
||||
child: IconButton(
|
||||
iconSize: 25,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
body
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showSideBar(BuildContext context, Widget widget,
|
||||
{String? title,
|
||||
bool showBarrier = true,
|
||||
bool useSurfaceTintColor = false,
|
||||
double width = 500,
|
||||
bool addTopPadding = false}) {
|
||||
Navigator.of(context).push(
|
||||
SideBarRoute(
|
||||
title,
|
||||
widget,
|
||||
showBarrier: showBarrier,
|
||||
useSurfaceTintColor: useSurfaceTintColor,
|
||||
width: width,
|
||||
addTopPadding: addTopPadding,
|
||||
addBottomPadding: true,
|
||||
),
|
||||
);
|
||||
}
|
624
lib/components/window_frame.dart
Normal file
624
lib/components/window_frame.dart
Normal file
@@ -0,0 +1,624 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
const _kTitleBarHeight = 36.0;
|
||||
|
||||
class WindowFrameController extends StateController {
|
||||
bool useDarkTheme = false;
|
||||
|
||||
bool isHideWindowFrame = false;
|
||||
|
||||
void setDarkTheme() {
|
||||
useDarkTheme = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void resetTheme() {
|
||||
useDarkTheme = false;
|
||||
update();
|
||||
}
|
||||
|
||||
VoidCallback openSideBar = () {};
|
||||
|
||||
void hideWindowFrame() {
|
||||
isHideWindowFrame = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void showWindowFrame() {
|
||||
isHideWindowFrame = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class WindowFrame extends StatelessWidget {
|
||||
const WindowFrame(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
StateController.putIfNotExists<WindowFrameController>(
|
||||
WindowFrameController());
|
||||
if (App.isMobile) return child;
|
||||
return StateBuilder<WindowFrameController>(builder: (controller) {
|
||||
if (controller.isHideWindowFrame) return child;
|
||||
|
||||
var body = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
brightness: controller.useDarkTheme ? Brightness.dark : null,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
if (App.isMacOS)
|
||||
const DragToMoveArea(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
width: 16,
|
||||
),
|
||||
).paddingRight(52)
|
||||
else
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Text(
|
||||
'Venera',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: (controller.useDarkTheme ||
|
||||
context.brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
).toAlign(Alignment.centerLeft).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
if (!App.isMacOS)
|
||||
const WindowButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
if (App.isLinux) {
|
||||
return VirtualWindowFrame(child: body);
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildMenuButton(
|
||||
WindowFrameController controller, BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
controller.openSideBar();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 42,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(18, 20),
|
||||
painter: _MenuPainter(
|
||||
color: (controller.useDarkTheme ||
|
||||
Theme.of(context).brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
_MenuPainter({this.color = Colors.black});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = getPaint(color);
|
||||
final path = Path()
|
||||
..moveTo(0, size.height / 4)
|
||||
..lineTo(size.width, size.height / 4)
|
||||
..moveTo(0, size.height / 4 * 2)
|
||||
..lineTo(size.width, size.height / 4 * 2)
|
||||
..moveTo(0, size.height / 4 * 3)
|
||||
..lineTo(size.width, size.height / 4 * 3);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class WindowButtons extends StatefulWidget {
|
||||
const WindowButtons({super.key});
|
||||
|
||||
@override
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
bool isMaximized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
windowManager.isMaximized().then((value) {
|
||||
if (value) {
|
||||
setState(() {
|
||||
isMaximized = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
isMaximized = true;
|
||||
});
|
||||
super.onWindowMaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
isMaximized = false;
|
||||
});
|
||||
super.onWindowUnmaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
final color = dark ? Colors.white : Colors.black;
|
||||
final hoverColor = dark ? Colors.white30 : Colors.black12;
|
||||
|
||||
return SizedBox(
|
||||
width: 138,
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
WindowButton(
|
||||
icon: MinimizeIcon(color: color),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () async {
|
||||
bool isMinimized = await windowManager.isMinimized();
|
||||
if (isMinimized) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.minimize();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isMaximized)
|
||||
WindowButton(
|
||||
icon: RestoreIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.unmaximize();
|
||||
},
|
||||
)
|
||||
else
|
||||
WindowButton(
|
||||
icon: MaximizeIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.maximize();
|
||||
},
|
||||
),
|
||||
WindowButton(
|
||||
icon: CloseIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverIcon: CloseIcon(
|
||||
color: !dark ? Colors.white : Colors.black,
|
||||
),
|
||||
hoverColor: Colors.red,
|
||||
onPressed: () {
|
||||
windowManager.close();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowButton extends StatefulWidget {
|
||||
const WindowButton(
|
||||
{required this.icon,
|
||||
required this.onPressed,
|
||||
required this.hoverColor,
|
||||
this.hoverIcon,
|
||||
super.key});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
final Color hoverColor;
|
||||
|
||||
final Widget? hoverIcon;
|
||||
|
||||
@override
|
||||
State<WindowButton> createState() => _WindowButtonState();
|
||||
}
|
||||
|
||||
class _WindowButtonState extends State<WindowButton> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
isHovering = true;
|
||||
}),
|
||||
onExit: (event) => setState(() {
|
||||
isHovering = false;
|
||||
}),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
child: Container(
|
||||
width: 46,
|
||||
height: double.infinity,
|
||||
decoration:
|
||||
BoxDecoration(color: isHovering ? widget.hoverColor : null),
|
||||
child: isHovering ? widget.hoverIcon ?? widget.icon : widget.icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close
|
||||
class CloseIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const CloseIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_ClosePainter(color));
|
||||
}
|
||||
|
||||
class _ClosePainter extends _IconPainter {
|
||||
_ClosePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color, true);
|
||||
canvas.drawLine(const Offset(0, 0), Offset(size.width, size.height), p);
|
||||
canvas.drawLine(Offset(0, size.height), Offset(size.width, 0), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximize
|
||||
class MaximizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MaximizeIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||
}
|
||||
|
||||
class _MaximizePainter extends _IconPainter {
|
||||
_MaximizePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore
|
||||
class RestoreIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const RestoreIcon({
|
||||
super.key,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||
}
|
||||
|
||||
class _RestorePainter extends _IconPainter {
|
||||
_RestorePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
||||
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
||||
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
||||
canvas.drawLine(
|
||||
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
||||
canvas.drawLine(Offset(size.width, size.height - 2),
|
||||
Offset(size.width - 2, size.height - 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize
|
||||
class MinimizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MinimizeIcon({super.key, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||
}
|
||||
|
||||
class _MinimizePainter extends _IconPainter {
|
||||
_MinimizePainter(super.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers
|
||||
abstract class _IconPainter extends CustomPainter {
|
||||
_IconPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _AlignedPaint extends StatelessWidget {
|
||||
const _AlignedPaint(this.painter);
|
||||
|
||||
final CustomPainter painter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
||||
}
|
||||
}
|
||||
|
||||
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..isAntiAlias = isAntiAlias
|
||||
..strokeWidth = 1;
|
||||
|
||||
class WindowPlacement {
|
||||
final Rect rect;
|
||||
|
||||
final bool isMaximized;
|
||||
|
||||
const WindowPlacement(this.rect, this.isMaximized);
|
||||
|
||||
Future<void> applyToWindow() async {
|
||||
await windowManager.setBounds(rect);
|
||||
|
||||
if (!validate(rect)) {
|
||||
await windowManager.center();
|
||||
}
|
||||
|
||||
if (isMaximized) {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> writeToFile() async {
|
||||
var file = File("${App.dataPath}/window_placement");
|
||||
await file.writeAsString(jsonEncode({
|
||||
'width': rect.width,
|
||||
'height': rect.height,
|
||||
'x': rect.topLeft.dx,
|
||||
'y': rect.topLeft.dy,
|
||||
'isMaximized': isMaximized
|
||||
}));
|
||||
}
|
||||
|
||||
static Future<WindowPlacement> loadFromFile() async {
|
||||
try {
|
||||
var file = File("${App.dataPath}/window_placement");
|
||||
if (!file.existsSync()) {
|
||||
return defaultPlacement;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
var rect =
|
||||
Rect.fromLTWH(json['x'], json['y'], json['width'], json['height']);
|
||||
return WindowPlacement(rect, json['isMaximized']);
|
||||
} catch (e) {
|
||||
return defaultPlacement;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<WindowPlacement> get current async {
|
||||
var rect = await windowManager.getBounds();
|
||||
var isMaximized = await windowManager.isMaximized();
|
||||
return WindowPlacement(rect, isMaximized);
|
||||
}
|
||||
|
||||
static const defaultPlacement =
|
||||
WindowPlacement(Rect.fromLTWH(10, 10, 900, 600), false);
|
||||
|
||||
static WindowPlacement cache = defaultPlacement;
|
||||
|
||||
static Timer? timer;
|
||||
|
||||
static void loop() async {
|
||||
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
|
||||
var placement = await WindowPlacement.current;
|
||||
if (!validate(placement.rect)) {
|
||||
return;
|
||||
}
|
||||
if (placement.rect != cache.rect ||
|
||||
placement.isMaximized != cache.isMaximized) {
|
||||
cache = placement;
|
||||
await placement.writeToFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static bool validate(Rect rect) {
|
||||
return rect.topLeft.dx >= 0 && rect.topLeft.dy >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
class VirtualWindowFrame extends StatefulWidget {
|
||||
const VirtualWindowFrame({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// The [child] contained by the VirtualWindowFrame.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _VirtualWindowFrameState();
|
||||
}
|
||||
|
||||
class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
with WindowListener {
|
||||
bool _isFocused = true;
|
||||
bool _isMaximized = false;
|
||||
bool _isFullScreen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildVirtualWindowFrame(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: (_isMaximized || _isFullScreen) ? 0 : 1,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (!_isMaximized && !_isFullScreen)
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
offset: Offset(0.0, _isFocused ? 4 : 2),
|
||||
blurRadius: 6,
|
||||
)
|
||||
],
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DragToResizeArea(
|
||||
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
setState(() {
|
||||
_isFocused = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
setState(() {
|
||||
_isFocused = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
_isMaximized = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
_isMaximized = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
setState(() {
|
||||
_isFullScreen = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
setState(() {
|
||||
_isFullScreen = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
TransitionBuilder VirtualWindowFrameInit() {
|
||||
return (_, Widget? child) {
|
||||
return VirtualWindowFrame(
|
||||
child: child!,
|
||||
);
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user