mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
662 lines
16 KiB
Dart
662 lines
16 KiB
Dart
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.onPageChanged,
|
|
required this.observer,
|
|
required this.navigatorKey,
|
|
super.key,
|
|
});
|
|
|
|
final List<PaneItemEntry> paneItems;
|
|
|
|
final List<PaneActionEntry> paneActions;
|
|
|
|
final Widget Function(int page) pageBuilder;
|
|
|
|
final void Function(int index)? onPageChanged;
|
|
|
|
final int initialPage;
|
|
|
|
final NaviObserver observer;
|
|
|
|
final GlobalKey<NavigatorState> navigatorKey;
|
|
|
|
@override
|
|
State<NaviPane> createState() => NaviPaneState();
|
|
|
|
static NaviPaneState of(BuildContext context) {
|
|
return context.findAncestorStateOfType<NaviPaneState>()!;
|
|
}
|
|
}
|
|
|
|
typedef NaviItemTapListener = void Function(int);
|
|
|
|
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.onPageChanged?.call(value);
|
|
}
|
|
|
|
void Function()? mainViewUpdateHandler;
|
|
|
|
late AnimationController controller;
|
|
|
|
final _naviItemTapListeners = <NaviItemTapListener>[];
|
|
|
|
void addNaviItemTapListener(NaviItemTapListener listener) {
|
|
_naviItemTapListeners.add(listener);
|
|
}
|
|
|
|
void removeNaviItemTapListener(NaviItemTapListener listener) {
|
|
_naviItemTapListeners.remove(listener);
|
|
}
|
|
|
|
static const _kBottomBarHeight = 58.0;
|
|
|
|
static const _kFoldedSideBarWidth = 72.0;
|
|
|
|
static const _kSideBarWidth = 224.0;
|
|
|
|
static const _kTopBarHeight = 48.0;
|
|
|
|
double get bottomBarHeight =>
|
|
_kBottomBarHeight + MediaQuery.of(context).padding.bottom;
|
|
|
|
void onNavigatorStateChange() {
|
|
onRebuild(context);
|
|
}
|
|
|
|
void updatePage(int index) {
|
|
for (var listener in _naviItemTapListeners) {
|
|
listener(index);
|
|
}
|
|
if (widget.observer.routes.length > 1) {
|
|
widget.navigatorKey.currentState!.popUntil((route) => route.isFirst);
|
|
}
|
|
if (currentPage == index) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
currentPage = index;
|
|
});
|
|
mainViewUpdateHandler?.call();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
controller = AnimationController(
|
|
duration: const Duration(milliseconds: 250),
|
|
lowerBound: 0,
|
|
upperBound: 3,
|
|
vsync: this,
|
|
);
|
|
widget.observer.addListener(onNavigatorStateChange);
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
controller.dispose();
|
|
widget.observer.removeListener(onNavigatorStateChange);
|
|
super.dispose();
|
|
}
|
|
|
|
double targetFormContext(BuildContext context) {
|
|
var width = MediaQuery.of(context).size.width;
|
|
double target = 0;
|
|
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();
|
|
}
|
|
}
|
|
controller.animateTo(target);
|
|
animationTarget = target;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
onRebuild(context);
|
|
return _NaviPopScope(
|
|
action: () {
|
|
if (App.mainNavigatorKey!.currentState!.canPop()) {
|
|
App.mainNavigatorKey!.currentState!.maybePop();
|
|
} else {
|
|
SystemNavigator.pop();
|
|
}
|
|
},
|
|
popGesture: App.isIOS && context.width >= changePoint,
|
|
child: AnimatedBuilder(
|
|
animation: controller,
|
|
builder: (context, child) {
|
|
final value = controller.value;
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
|
|
top: 0,
|
|
bottom: 0,
|
|
child: buildLeft(),
|
|
),
|
|
Positioned.fill(
|
|
left:
|
|
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
|
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
|
((value - 2).clamp(0, 1)),
|
|
child: buildMainView(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildMainView() {
|
|
return HeroControllerScope(
|
|
controller: MaterialApp.createMaterialHeroController(),
|
|
child: NavigatorPopHandler(
|
|
onPopWithResult: (result) {
|
|
widget.navigatorKey.currentState?.maybePop(result);
|
|
},
|
|
child: Navigator(
|
|
observers: [widget.observer],
|
|
key: widget.navigatorKey,
|
|
onGenerateRoute: (settings) => AppPageRoute(
|
|
preventRebuild: false,
|
|
builder: (context) {
|
|
return _NaviMainView(state: this);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildMainViewContent() {
|
|
return widget.pageBuilder(currentPage);
|
|
}
|
|
|
|
Widget buildTop() {
|
|
return Material(
|
|
child: Container(
|
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
|
height: _kTopBarHeight,
|
|
width: double.infinity,
|
|
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,
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: List<Widget>.generate(widget.paneItems.length, (index) {
|
|
return Expanded(
|
|
child: _SingleBottomNaviWidget(
|
|
enabled: currentPage == index,
|
|
entry: widget.paneItems[index],
|
|
onTap: () {
|
|
updatePage(index);
|
|
},
|
|
key: ValueKey(index),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildLeft() {
|
|
final value = controller.value;
|
|
const paddingHorizontal = 12.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.0,
|
|
),
|
|
),
|
|
),
|
|
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: () {
|
|
updatePage(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),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SideNaviWidget extends StatelessWidget {
|
|
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
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final icon = Icon(enabled ? entry.activeIcon : entry.icon);
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: enabled ? colorScheme.primaryContainer : null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: showTitle
|
|
? Row(
|
|
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
|
)
|
|
: Align(alignment: Alignment.centerLeft, child: icon),
|
|
),
|
|
).paddingVertical(4);
|
|
}
|
|
}
|
|
|
|
class _PaneActionWidget extends StatelessWidget {
|
|
const _PaneActionWidget({
|
|
required this.entry,
|
|
required this.showTitle,
|
|
super.key,
|
|
});
|
|
|
|
final PaneActionEntry entry;
|
|
|
|
final bool showTitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final icon = Icon(entry.icon);
|
|
return InkWell(
|
|
onTap: entry.onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
height: 38,
|
|
child: showTitle
|
|
? Row(
|
|
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
|
)
|
|
: Align(alignment: Alignment.centerLeft, child: icon),
|
|
),
|
|
).paddingVertical(4);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
int count = 0;
|
|
for (var route in routes) {
|
|
if (route is AppPageRoute) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
@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 = 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 _NaviMainView extends StatefulWidget {
|
|
const _NaviMainView({required this.state});
|
|
|
|
final NaviPaneState state;
|
|
|
|
@override
|
|
State<_NaviMainView> createState() => _NaviMainViewState();
|
|
}
|
|
|
|
class _NaviMainViewState extends State<_NaviMainView> {
|
|
NaviPaneState get state => widget.state;
|
|
|
|
@override
|
|
void initState() {
|
|
state.mainViewUpdateHandler = () {
|
|
setState(() {});
|
|
};
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var shouldShowAppBar = state.controller.value < 2;
|
|
return Column(
|
|
children: [
|
|
if (shouldShowAppBar) state.buildTop().paddingTop(context.padding.top),
|
|
Expanded(
|
|
child: MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: shouldShowAppBar,
|
|
child: AnimatedSwitcher(
|
|
duration: _fastAnimationDuration,
|
|
child: state.buildMainViewContent(),
|
|
),
|
|
),
|
|
),
|
|
if (shouldShowAppBar)
|
|
state.buildBottom().paddingBottom(context.padding.bottom),
|
|
],
|
|
);
|
|
}
|
|
}
|