zero_adaptive_link 0.4.0
zero_adaptive_link: ^0.4.0 copied to clipboard
Adaptive navigation for Flutter: one onTap, the right behavior everywhere.
example/lib/main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:zero_adaptive_link/zero_adaptive_link.dart';
import 'pages.dart';
import 'theme.dart';
import 'widgets.dart';
void main() {
runApp(const ExampleApp());
}
/// One of the width presets used to verify the three behaviors.
/// 用于验证三种行为的宽度预设之一。
@immutable
class WidthPreset {
const WidthPreset(this.label, this.width);
final String label;
final double width;
}
const List<WidthPreset> kWidthPresets = [
WidthPreset('Phone 480', 480),
WidthPreset('Foldable 720', 720),
WidthPreset('Tablet 1023', 1023),
WidthPreset('Desktop', 1440),
];
/// Publishes the simulated viewport width to the shell builder so the demo can
/// also visually frame the content.
/// 向 shell builder 发布模拟的视口宽度,使演示同时能在视觉上框住内容。
class ExampleFrameScope extends InheritedWidget {
const ExampleFrameScope({
required this.simulatedWidth,
required super.child,
super.key,
});
final double? simulatedWidth;
static double? maybeWidthOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<ExampleFrameScope>()
?.simulatedWidth;
}
@override
bool updateShouldNotify(ExampleFrameScope oldWidget) =>
simulatedWidth != oldWidget.simulatedWidth;
}
class ExampleApp extends StatefulWidget {
const ExampleApp({super.key});
@override
State<ExampleApp> createState() => _ExampleAppState();
}
class _ExampleAppState extends State<ExampleApp> {
double? _simulatedWidth;
final ValueNotifier<SlidingPanesSide> _paneSide =
ValueNotifier<SlidingPanesSide>(SlidingPanesSide.right);
bool _loggedIn = false;
late final ZeroRouter _router = ZeroRouter(
initialLocation: '/inbox',
// Auth redirect: /you/billing requires being signed in.
// 登录态重定向:/you/billing 需要先登录。
redirect: (location) {
if (!_loggedIn && location.startsWith('/you/billing')) {
return '/login?from=${Uri.encodeQueryComponent(location)}';
}
return null;
},
paneSideListenable: _paneSide,
paneBuilder: (context, pane, isLeft) => _PaneFrame(child: pane),
placeholder: const _EmptyDetailPane(),
routes: [
ZeroShell(
builder: _buildShell,
tabs: [
ZeroTab(
initialLocation: '/inbox',
routes: [
ZeroRoute(
path: '/inbox',
title: (state) => 'Inbox',
builder: buildInboxList,
routes: [
ZeroRoute(
path: ':id',
title: (state) => 'Ticket #${state.pathParameters['id']}',
builder: _buildTicketDetail,
routes: [
ZeroRoute(
path: 'attachment',
fullscreen: true,
opaque: false,
barrierColor: const Color(0x99000000),
barrierDismissible: true,
title: (state) => 'Attachment',
builder: buildAttachmentPreview,
),
],
),
],
),
],
),
ZeroTab(
initialLocation: '/customers',
routes: [
ZeroRoute(
path: '/customers',
title: (state) => 'Customers',
builder: buildCustomers,
),
],
),
ZeroTab(
initialLocation: '/you',
routes: [
ZeroRoute(
path: '/you',
title: (state) => 'You',
builder: _buildYou,
routes: [
ZeroRoute(
path: 'billing',
title: (state) => 'Billing',
builder: buildBilling,
),
],
),
ZeroRoute(
path: '/login',
title: (state) => 'Sign in',
builder: _buildLogin,
),
],
),
],
),
],
);
Widget _buildTicketDetail(BuildContext context, ZeroRouteState state) {
final id = state.pathParameters['id']!;
return buildTicketDetail(context, state, (text) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Reply sent to ticket #$id')));
});
}
Widget _buildYou(BuildContext context, ZeroRouteState state) {
return buildYou(context, state, _loggedIn);
}
Widget _buildLogin(BuildContext context, ZeroRouteState state) {
return buildLogin(context, state, () => setState(() => _loggedIn = true));
}
// ------------------------------------------------------------ shell ----
Widget _buildShell(BuildContext context, ZeroShellState shell, Widget child) {
const railDestinations = [
NavigationRailDestination(
icon: Icon(Icons.inbox_outlined),
selectedIcon: Icon(Icons.inbox),
label: Text('Inbox'),
),
NavigationRailDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: Text('Customers'),
),
NavigationRailDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: Text('You'),
),
];
const barDestinations = [
NavigationDestination(
icon: Icon(Icons.inbox_outlined),
selectedIcon: Icon(Icons.inbox),
label: 'Inbox',
),
NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Customers',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'You',
),
];
if (shell.isCompact) {
return ZeroShellChrome(
bottomNavigationBar: (context) => NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: shell.goTab,
destinations: barDestinations,
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
indicatorColor: AppColors.pineTint,
),
child: _framed(child),
);
}
return Scaffold(
backgroundColor: AppColors.paper,
body: Row(
children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: shell.goTab,
destinations: railDestinations,
backgroundColor: AppColors.paper,
selectedIconTheme: const IconThemeData(color: AppColors.pine),
selectedLabelTextStyle: const TextStyle(
color: AppColors.pine,
fontWeight: FontWeight.w600,
fontSize: 12,
),
unselectedIconTheme: const IconThemeData(color: AppColors.muted),
unselectedLabelTextStyle: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
labelType: NavigationRailLabelType.all,
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(child: _framed(child)),
],
),
);
}
/// Frames the content to the simulated width so the demo looks like the
/// selected device class.
/// 将内容框到模拟宽度,使演示看起来像所选的设备档位。
Widget _framed(Widget child) {
final width = ExampleFrameScope.maybeWidthOf(context);
if (width == null) {
return child;
}
return ColoredBox(
color: AppColors.paper,
child: Center(
child: SizedBox(width: width, child: child),
),
);
}
// ------------------------------------------------------------- build ----
@override
Widget build(BuildContext context) {
return ExampleFrameScope(
simulatedWidth: _simulatedWidth,
child: _SimulatedMediaQuery(
width: _simulatedWidth,
child: MaterialApp.router(
title: 'zero_adaptive_link example',
theme: buildAppTheme(),
routerConfig: _router,
// Debug-only corner ribbon; a demo does not need the visual noise
// (and it never appears in release builds anyway).
// 仅 debug 构建显示的角标;演示不需要这份视觉噪音(release 本就不显示)。
debugShowCheckedModeBanner: false,
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
// The preview strip is a plain `Material`, not an `AppBar`, so it
// does not reserve the status bar by itself — without this the
// system status bar paints an opaque band over it on mobile.
// 预览条是普通 `Material` 而非 `AppBar`,不会自己避让状态栏——
// 不设置的话移动端的系统状态栏会在它上面压出一条不透明黑带。
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: AppColors.paper,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: ColoredBox(
// Paper behind everything: nothing above the builder paints a
// background, so uncovered corners would otherwise show Flutter's
// raw black surface.
// 全局铺纸色:builder 之上没有任何背景绘制,未覆盖到的角落
// 会露出 Flutter 的原始黑色表面。
color: AppColors.paper,
child: Column(
// Stretch the preview strip to the full window width — the
// horizontally scrollable row only takes its content width and
// would otherwise sit centered with raw gaps at both ends.
// 让预览条铺满窗口宽度——横向滚动的行只占内容宽度,否则会
// 居中且两端露出底色。
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: _PreviewBar(
simulatedWidth: _simulatedWidth,
paneSide: _paneSide,
onSelected: (width) =>
setState(() => _simulatedWidth = width),
onPaneSideChanged: (side) => _paneSide.value = side,
),
),
Expanded(child: child!),
],
),
),
),
),
),
);
}
}
/// Overrides the MediaQuery size while a width preset is active.
/// 宽度预设生效时覆盖 MediaQuery 尺寸。
class _SimulatedMediaQuery extends StatelessWidget {
const _SimulatedMediaQuery({required this.width, required this.child});
final double? width;
final Widget child;
@override
Widget build(BuildContext context) {
final preset = width;
if (preset == null) {
return child;
}
final view = PlatformDispatcher.instance.implicitView;
final base = view == null
? const MediaQueryData()
: MediaQueryData.fromView(view);
return MediaQuery(
data: base.copyWith(size: Size(preset, base.size.height)),
child: child,
);
}
}
/// The demo control strip: it drives the simulated width and the pane side.
/// It is a demo tool, not product chrome, so it stays visually quiet.
/// 演示控制条:驱动模拟宽度与 pane 展开方向。它是演示工具而非产品界面,
/// 因此保持视觉低调。
class _PreviewBar extends StatelessWidget {
const _PreviewBar({
required this.simulatedWidth,
required this.paneSide,
required this.onSelected,
required this.onPaneSideChanged,
});
final double? simulatedWidth;
final ValueListenable<SlidingPanesSide> paneSide;
final ValueChanged<double?> onSelected;
final ValueChanged<SlidingPanesSide> onPaneSideChanged;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceDim,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const TagChip(label: 'preview', color: AppColors.muted),
const SizedBox(width: 10),
const Text('Width:', style: kCaptionStyle),
const SizedBox(width: 6),
for (final preset in kWidthPresets)
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(preset.label),
selected: simulatedWidth == preset.width,
onSelected: (_) => onSelected(preset.width),
labelStyle: kCaptionStyle,
backgroundColor: AppColors.surface,
selectedColor: AppColors.pineTint,
side: const BorderSide(color: AppColors.hairline),
showCheckmark: false,
visualDensity: VisualDensity.compact,
),
),
TextButton(
onPressed: () => onSelected(null),
child: const Text('Window'),
),
const SizedBox(width: 14),
const Text('Expand:', style: kCaptionStyle),
const SizedBox(width: 6),
// The bar must *listen* to the notifier, not just read it once:
// otherwise the SegmentedButton keeps a stale selection and
// tapping the stale-selected segment is a no-op.
// 这里必须*监听* notifier 而不仅是读一次:否则 SegmentedButton
// 会保留过期的选中态,再点「已选中」的那一段就成了空操作。
ValueListenableBuilder<SlidingPanesSide>(
valueListenable: paneSide,
builder: (context, side, _) =>
SegmentedButton<SlidingPanesSide>(
selected: {side},
onSelectionChanged: (selected) =>
onPaneSideChanged(selected.first),
style: SegmentedButton.styleFrom(
backgroundColor: AppColors.surface,
selectedBackgroundColor: AppColors.pineTint,
foregroundColor: AppColors.inkSoft,
selectedForegroundColor: AppColors.pine,
side: const BorderSide(color: AppColors.hairline),
visualDensity: VisualDensity.compact,
),
segments: const [
ButtonSegment<SlidingPanesSide>(
value: SlidingPanesSide.right,
label: Text('Expand right'),
),
ButtonSegment<SlidingPanesSide>(
value: SlidingPanesSide.left,
label: Text('Expand left'),
),
],
),
),
],
),
),
),
);
}
}
/// A quiet surface for each pane: hairline border, no shadow, no gradient.
/// 每个 pane 的克制表面:发丝边框、无阴影、无渐变。
class _PaneFrame extends StatelessWidget {
const _PaneFrame({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.hairline),
),
clipBehavior: Clip.antiAlias,
child: child,
),
),
);
}
}
class _EmptyDetailPane extends StatelessWidget {
const _EmptyDetailPane();
@override
Widget build(BuildContext context) {
return const EmptyHint(
icon: Icons.forum_outlined,
title: 'No ticket selected',
subtitle: 'Pick a thread and it opens here.',
);
}
}