feat:参照研听的基础工程
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../features/assets/presentation/assets_page.dart';
|
||||
import '../../features/auth/presentation/delete_account_page.dart';
|
||||
import '../../features/auth/presentation/login_page.dart';
|
||||
import '../../features/debug/presentation/debug_info_page.dart';
|
||||
import '../../features/debug/presentation/debug_page.dart';
|
||||
import '../../features/debug/presentation/debug_shorebird_page.dart';
|
||||
import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
|
||||
import '../../features/debug/presentation/typography_test_page.dart';
|
||||
import '../../features/market/presentation/market_page.dart';
|
||||
import '../../features/news/presentation/news_page.dart';
|
||||
import '../../features/profile/presentation/profile_page.dart';
|
||||
import '../../features/settings/presentation/settings_page.dart';
|
||||
import '../../features/settings/presentation/privacy_consent_page.dart';
|
||||
import '../services/device_header_service.dart';
|
||||
import 'tab_shell.dart';
|
||||
|
||||
abstract final class AppRoutes {
|
||||
static const assets = '/assets';
|
||||
static const market = '/market';
|
||||
static const news = '/news';
|
||||
static const profile = '/profile';
|
||||
static const login = '/login';
|
||||
static const settings = '/settings';
|
||||
static const deleteAccount = '/delete-account';
|
||||
static const deleteAccountCompleted = '/delete-account-completed';
|
||||
static const debug = '/debug';
|
||||
static const debugInfo = '/debug/info';
|
||||
static const debugShorebird = '/debug/shorebird';
|
||||
static const debugTypography = '/debug/typography';
|
||||
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
||||
static const privacyConsent = '/privacy-consent';
|
||||
}
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final startupListenable = ValueNotifier<int>(0);
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.assets,
|
||||
refreshListenable: startupListenable,
|
||||
redirect: (context, state) {
|
||||
final loc = state.matchedLocation;
|
||||
final onConsent = loc == AppRoutes.privacyConsent;
|
||||
final showStartup =
|
||||
DeviceHeaderService.to.privacyConsentRequired ||
|
||||
DeviceHeaderService.to.iosStartupRequired;
|
||||
if (showStartup) {
|
||||
if (!onConsent) return AppRoutes.privacyConsent;
|
||||
} else if (onConsent) {
|
||||
return AppRoutes.assets;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
TabShell(location: state.matchedLocation, child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.assets,
|
||||
builder: (ctx, s) => const AssetsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.market,
|
||||
builder: (ctx, s) => const MarketPage(),
|
||||
),
|
||||
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (ctx, s) => const ProfilePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
builder: (_, s) => LoginPage(next: s.uri.queryParameters['next']),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settings,
|
||||
builder: (_, s) => const SettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.deleteAccount,
|
||||
builder: (_, s) => const DeleteAccountPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.deleteAccountCompleted,
|
||||
builder: (_, s) => const DeleteAccountSuccessPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.debugInfo,
|
||||
builder: (_, s) => const DebugInfoPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.debugShorebird,
|
||||
builder: (_, s) => const DebugShorebirdPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.debugTypography,
|
||||
builder: (_, s) => const TypographyTestPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.fancyDioInspector,
|
||||
builder: (_, s) => const FancyDioInspectorPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.privacyConsent,
|
||||
builder: (_, s) => const PrivacyConsentPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
String tabFallbackForPath(String path) {
|
||||
if (path.startsWith('/assets')) return '/assets';
|
||||
if (path.startsWith('/market')) return '/market';
|
||||
if (path.startsWith('/news')) return '/news';
|
||||
if (path.startsWith('/profile')) return '/profile';
|
||||
return '/assets';
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../theme/app_icons.dart';
|
||||
import '../widgets/adaptive.dart';
|
||||
import 'app_router.dart';
|
||||
|
||||
class TabShell extends StatefulWidget {
|
||||
const TabShell({super.key, required this.child, required this.location});
|
||||
|
||||
final Widget child;
|
||||
final String location;
|
||||
|
||||
@override
|
||||
State<TabShell> createState() => _TabShellState();
|
||||
}
|
||||
|
||||
class _TabShellState extends State<TabShell> {
|
||||
static const double _compactHeaderThreshold = 34;
|
||||
|
||||
bool _showCompactHeader = false;
|
||||
|
||||
static const _tabs = ['/assets', '/market', '/news', '/profile'];
|
||||
|
||||
int get _index =>
|
||||
_tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.location != widget.location && _showCompactHeader) {
|
||||
_showCompactHeader = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final header = _headerForPath(widget.location);
|
||||
final canPop = GoRouter.of(context).canPop();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
children: [
|
||||
Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
toolbarHeight: 32,
|
||||
leading: canPop
|
||||
? IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
)
|
||||
: null,
|
||||
title: AnimatedOpacity(
|
||||
opacity: _showCompactHeader ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
header.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
if (header.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
header.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ColoredBox(
|
||||
color: cs.background,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: Stack(children: [Positioned.fill(child: widget.child)]),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
border: Border(
|
||||
top: BorderSide(color: cs.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 62,
|
||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.tabsMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _tabs.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
icon: i == _index
|
||||
? _filledIcons[i]
|
||||
: _outlineIcons[i],
|
||||
label: _labels[i],
|
||||
selected: i == _index,
|
||||
onTap: () => context.go(_tabs[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!kReleaseMode)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 88,
|
||||
child: FilledButton(
|
||||
onPressed: () => context.push(AppRoutes.fancyDioInspector),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.primaryForeground,
|
||||
minimumSize: const Size(60, 60),
|
||||
shape: const CircleBorder(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: const Text('请求'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
final next = notification.metrics.pixels > _compactHeaderThreshold;
|
||||
if (next != _showCompactHeader && mounted) {
|
||||
setState(() => _showCompactHeader = next);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final color = selected ? cs.foreground : cs.mutedForeground;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: color),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: color,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _filledIcons = [
|
||||
AppIcons.assetsFill,
|
||||
AppIcons.marketFill,
|
||||
AppIcons.newsFill,
|
||||
AppIcons.meFill,
|
||||
];
|
||||
|
||||
const _outlineIcons = [
|
||||
AppIcons.assetsLine,
|
||||
AppIcons.marketLine,
|
||||
AppIcons.newsLine,
|
||||
AppIcons.meLine,
|
||||
];
|
||||
|
||||
const _labels = ['资产', '行情', '资讯', '我的'];
|
||||
|
||||
_ShellHeader _headerForPath(String path) {
|
||||
return switch (path) {
|
||||
'/market' => const _ShellHeader('行情', ''),
|
||||
'/news' => const _ShellHeader('资讯', ''),
|
||||
'/profile' => const _ShellHeader('我的'),
|
||||
_ => const _ShellHeader('金值', ''),
|
||||
};
|
||||
}
|
||||
|
||||
class _ShellHeader {
|
||||
const _ShellHeader(this.title, [this.subtitle]);
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
}
|
||||
Reference in New Issue
Block a user