Files
jinzhi/lib/common/router/tab_shell.dart
T

1009 lines
34 KiB
Dart

import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../features/mall/application/mall_entry_controller.dart';
import '../../features/mall/application/mall_entry_promo_store.dart';
import '../../features/mall/application/mall_tab_config.dart';
import '../../features/mall/presentation/widgets/mall_entry_promo.dart';
import '../../features/ads/application/startup_ad_service.dart';
import '../../features/web/application/mall_bridge_state.dart';
import '../../features/web/application/mall_web_host_controller.dart';
import '../../features/web/presentation/mall_web_host.dart';
import '../theme/app_icons.dart';
import '../widgets/app_toast.dart';
import 'app_router.dart';
import '../storage/preferences_storage.dart';
class TabShell extends ConsumerStatefulWidget {
const TabShell({
super.key,
required this.navigationShell,
required this.location,
});
final StatefulNavigationShell navigationShell;
final String location;
@override
ConsumerState<TabShell> createState() => _TabShellState();
}
class _TabShellState extends ConsumerState<TabShell>
with TickerProviderStateMixin {
static const double _compactHeaderThreshold = 34;
static const double _scrollVisibilityThreshold = 16;
static const double _topRevealEpsilon = 1;
static const Curve _expandSpring = Cubic(0.32, -0.9, 0.28, 1);
bool _showCompactHeader = false;
Brightness? _mallBrightness;
bool _mallPresented = false;
bool _mallActive = false;
late final AnimationController _minimizeController;
late final CurvedAnimation _minimizeFactor;
late final AnimationController _mallTransition;
late final CurvedAnimation _mallProgress;
late final ProviderSubscription<MallTabConfig> _mallConfigSubscription;
late final MallEntryPromoStore _mallEntryPromoStore;
late bool _mallEntryTipConsumed;
late bool _mallHomeBadgeSeen;
bool _showMallHomeBadgeForPresentation = false;
bool _userScrollSession = false;
ScrollDirection _userScrollDirection = ScrollDirection.idle;
double _directionAccum = 0;
bool _barMinimized = false;
int get _index => widget.navigationShell.currentIndex;
@override
void initState() {
super.initState();
_mallEntryPromoStore = MallEntryPromoStore(
ref.read(preferencesStorageProvider),
);
_mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall;
_mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge;
MallWebHostController.instance.state.addListener(_syncMallHost);
MallBridgeController.instance.state.addListener(_refreshMallDock);
_minimizeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
reverseDuration: const Duration(milliseconds: 380),
);
_minimizeFactor = CurvedAnimation(
parent: _minimizeController,
curve: Curves.easeOutCubic,
reverseCurve: _expandSpring,
);
_mallTransition = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 520),
reverseDuration: const Duration(milliseconds: 520),
);
_mallProgress = CurvedAnimation(
parent: _mallTransition,
curve: const Cubic(0.8, 0.05, 0.2, 0.95),
reverseCurve: const Cubic(0.8, 0.05, 0.2, 0.95),
);
final initialMallState = MallWebHostController.instance.state.value;
_mallPresented = initialMallState.url != null;
_mallActive = initialMallState.visible;
_mallTransition.value = _mallActive ? 1 : 0;
_mallConfigSubscription = ref.listenManual<MallTabConfig>(
mallTabConfigProvider,
(_, next) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _preloadMall(next);
});
},
fireImmediately: true,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final brightness = Theme.of(context).brightness;
if (_mallBrightness == brightness) return;
_mallBrightness = brightness;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _preloadMall(ref.read(mallTabConfigProvider));
});
}
void _preloadMall(MallTabConfig config) {
if (!config.isVisible) return;
final uri = config.attributedUri(
clickId: '',
isDark: Theme.of(context).brightness == Brightness.dark,
);
if (uri != null) MallWebHostController.instance.preload(uri);
}
@override
void dispose() {
MallWebHostController.instance.state.removeListener(_syncMallHost);
MallBridgeController.instance.state.removeListener(_refreshMallDock);
_mallConfigSubscription.close();
_minimizeFactor.dispose();
_minimizeController.dispose();
_mallProgress.dispose();
_mallTransition.dispose();
super.dispose();
}
void _syncMallHost() {
final state = MallWebHostController.instance.state.value;
if (!mounted) return;
final changed = _mallActive != state.visible;
final showHomeBadge =
changed &&
state.visible &&
!_mallHomeBadgeSeen &&
ref.read(mallTabConfigProvider).showsEntryBadge;
setState(() {
_mallPresented |= state.url != null;
_mallActive = state.visible;
if (changed) {
_showMallHomeBadgeForPresentation = showHomeBadge;
if (showHomeBadge) _mallHomeBadgeSeen = true;
}
});
if (showHomeBadge) {
unawaited(_mallEntryPromoStore.markHomeBadgeSeen());
}
if (changed) {
if (state.visible) {
_mallTransition.forward();
} else {
_mallTransition.reverse();
}
}
}
void _refreshMallDock() {
if (mounted && _mallActive) setState(() {});
}
Future<void> _openMall(MallTabConfig config) async {
await _consumeMallEntryTip(config);
if (!mounted) return;
final result = await ref
.read(mallEntryControllerProvider)
.open(context, initialConfig: config);
if (!mounted) return;
if (result == MallEntryOpenResult.unavailable) {
toastInfo(msg: '商城暂未开放');
} else if (result == MallEntryOpenResult.failed) {
toastInfo(msg: '商城打开失败,请稍后重试');
}
}
Future<void> _consumeMallEntryTip(MallTabConfig config) async {
if (!config.showsEntryTip || _mallEntryTipConsumed) return;
setState(() => _mallEntryTipConsumed = true);
await _mallEntryPromoStore.markMallOpened();
}
bool get _isPrimaryTabRoot => switch (widget.location) {
AppRoutes.assets ||
AppRoutes.market ||
AppRoutes.news ||
AppRoutes.profile => true,
_ => false,
};
@override
void didUpdateWidget(covariant TabShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.location != widget.location) {
_expandBar(spring: false);
_resetScrollTracking();
if (_showCompactHeader) _showCompactHeader = false;
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final mall = ref.watch(mallTabConfigProvider);
final startupAdResolved = ref.watch(startupAdResolvedProvider);
final isQiyuKeyboardActive =
MallWebHostController.instance.state.value.isQiyuKeyboardActive;
final header = _headerForPath(widget.location);
final canPop = GoRouter.of(context).canPop();
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
return Scaffold(
backgroundColor: cs.background,
// The resident mall stays visually fixed for ordinary pages. Qiyu is
// different: its WebView editor must receive the resized Android body
// when the IME opens, otherwise its delayed focus-scroll ends below the
// keyboard.
resizeToAvoidBottomInset: _mallActive && isQiyuKeyboardActive,
body: Stack(
children: [
AnimatedBuilder(
animation: _mallProgress,
builder: (context, child) => Transform.translate(
offset: Offset(
-MediaQuery.sizeOf(context).width * _mallProgress.value,
0,
),
child: child,
),
child: IgnorePointer(
ignoring: _mallActive,
child: PopScope(
canPop: !isAndroid || !_mallActive,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && isAndroid && _mallActive) {
MallWebHostController.instance.handleSystemBack();
}
},
child: Scaffold(
extendBody: true,
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: widget.navigationShell,
),
),
),
),
),
),
if (_mallPresented &&
MallWebHostController.instance.state.value.url != null)
Positioned.fill(
child: AnimatedBuilder(
animation: _mallProgress,
builder: (context, child) => Transform.translate(
offset: Offset(
MediaQuery.sizeOf(context).width *
(1 - _mallProgress.value),
0,
),
child: child,
),
child: IgnorePointer(
ignoring: !_mallActive,
child: MallWebHost(
url: MallWebHostController.instance.state.value.url!,
visible: _mallActive,
onRequestExit: MallWebHostController.instance.dismiss,
),
),
),
),
if (!(_mallActive &&
(MallBridgeController.instance.state.value.canGoBack ||
MallWebHostController
.instance
.state
.value
.isShowingQiyuPage)))
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _MorphingNavigationDock(
progress: _mallProgress,
minimize: _minimizeFactor,
currentIndex: _index,
mall: mall,
showMallEntryTip:
startupAdResolved &&
_isPrimaryTabRoot &&
!_mallActive &&
!_mallEntryTipConsumed &&
mall.showsEntryTip,
showMallHomeBadge:
_showMallHomeBadgeForPresentation && mall.showsEntryBadge,
onTabTap: (index) => widget.navigationShell.goBranch(
index,
initialLocation: index == _index,
),
onMallTap: () => _openMall(mall),
),
),
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('请求'),
),
),
],
),
);
}
void _resetScrollTracking() {
_userScrollSession = false;
_userScrollDirection = ScrollDirection.idle;
_directionAccum = 0;
}
void _expandBar({bool immediate = false, bool spring = true}) {
if (!_barMinimized && _minimizeController.value == 0) return;
_barMinimized = false;
if (immediate) {
_minimizeController.value = 0;
return;
}
final alreadyReversing =
_minimizeController.isAnimating &&
_minimizeController.status == AnimationStatus.reverse;
if (!alreadyReversing) {
_minimizeFactor.reverseCurve = spring
? _expandSpring
: Curves.easeOutCubic;
}
unawaited(_minimizeController.reverse());
}
void _minimizeBar() {
if (_barMinimized && _minimizeController.value == 1) return;
_barMinimized = true;
unawaited(_minimizeController.forward());
}
bool _handleScrollNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.axis != Axis.vertical) return false;
final next = notification.metrics.pixels > _compactHeaderThreshold;
if (next != _showCompactHeader && mounted) {
setState(() => _showCompactHeader = next);
}
if (notification.depth != 0) return false;
if (notification is UserScrollNotification) {
_userScrollDirection = notification.direction;
_userScrollSession = notification.direction != ScrollDirection.idle;
return false;
}
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) {
_userScrollSession = true;
_directionAccum = 0;
}
return false;
}
final atTop = metrics.pixels <= metrics.minScrollExtent + _topRevealEpsilon;
if (atTop) {
_directionAccum = 0;
if (_barMinimized || _minimizeController.value > 0) _expandBar();
return false;
}
if (notification is! ScrollUpdateNotification) return false;
if (notification.dragDetails != null) _userScrollSession = true;
if (!_userScrollSession && _userScrollDirection == ScrollDirection.idle) {
return false;
}
final delta = notification.scrollDelta;
if (delta == null || delta == 0) return false;
final direction = _userScrollDirection != ScrollDirection.idle
? _userScrollDirection
: delta > 0
? ScrollDirection.reverse
: ScrollDirection.forward;
if (direction == ScrollDirection.reverse) {
if (_directionAccum < 0) _directionAccum = 0;
_directionAccum += delta.abs();
if (_directionAccum >= _scrollVisibilityThreshold) {
_directionAccum = 0;
_minimizeBar();
}
} else if (direction == ScrollDirection.forward) {
if (_directionAccum > 0) _directionAccum = 0;
_directionAccum -= delta.abs();
if (_directionAccum <= -_scrollVisibilityThreshold) {
_directionAccum = 0;
_expandBar();
}
}
return false;
}
}
class _MorphingNavigationDock extends StatelessWidget {
const _MorphingNavigationDock({
required this.progress,
required this.minimize,
required this.currentIndex,
required this.mall,
required this.showMallEntryTip,
required this.showMallHomeBadge,
required this.onTabTap,
required this.onMallTap,
});
final Animation<double> progress;
final Animation<double> minimize;
final int currentIndex;
final MallTabConfig mall;
final bool showMallEntryTip;
final bool showMallHomeBadge;
final ValueChanged<int> onTabTap;
final VoidCallback onMallTap;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final cs = ShadTheme.of(context).colorScheme;
final bridge = MallBridgeController.instance.state.value;
final nativeItems = [
for (var index = 0; index < _labels.length; index++)
_DockTabItem(
label: _labels[index],
outline: _outlineIcons[index],
filled: _filledIcons[index],
symbol: _nativeSymbols[index],
),
];
final mallItems = [
for (final item in _MallNavigationDock.tabs)
_DockTabItem(label: item.$4, outline: item.$2, filled: item.$3),
];
return AnimatedBuilder(
animation: Listenable.merge([progress, minimize]),
builder: (context, _) {
final t = progress.value.clamp(0.0, 1.0);
final minimized = minimize.value * (1 - t);
return SizedBox(
height: 84 + bottomInset,
child: Align(
alignment: Alignment.bottomCenter,
child: LayoutBuilder(
builder: (context, constraints) {
const side = 16.0;
const gap = 12.0;
const circle = 64.0;
final pill = mall.isVisible
? constraints.maxWidth - side * 2 - gap - circle
: constraints.maxWidth - side * 2;
final leftWidth = mall.isVisible
? lerpDouble(pill, circle, t)!
: pill;
final rightWidth = lerpDouble(circle, pill, t)!;
return Padding(
padding: EdgeInsets.fromLTRB(side, 8, side, 10 + bottomInset),
child: Transform.scale(
scale: lerpDouble(1, 0.86, minimized)!,
alignment: Alignment.bottomCenter,
child: Row(
children: [
_MallDockSurface(
width: leftWidth,
height: circle,
child: Stack(
fit: StackFit.expand,
children: [
IgnorePointer(
ignoring: t > 0.5,
child: Opacity(
opacity: (1 - t * 2).clamp(0.0, 1.0),
child: _ExpandingDockTabStrip(
items: nativeItems,
selectedIndex: currentIndex,
minimize: minimized,
onTap: onTabTap,
),
),
),
IgnorePointer(
ignoring: t < 0.5,
child: Opacity(
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
child: IconButton(
tooltip: '返回',
onPressed:
MallWebHostController.instance.dismiss,
icon: Icon(
AppIcons.arrowLeft,
size: 30,
color: cs.primary,
),
),
),
),
],
),
),
if (mall.isVisible) ...[
const SizedBox(width: gap),
_MallDockSurface(
width: rightWidth,
height: circle,
child: Stack(
fit: StackFit.expand,
children: [
IgnorePointer(
ignoring: t > 0.5,
child: Opacity(
opacity: (1 - t * 2).clamp(0.0, 1.0),
child: _MallLauncherButton(
mall: mall,
showPromoTip: showMallEntryTip,
onTap: onMallTap,
),
),
),
IgnorePointer(
ignoring: t < 0.5,
child: Opacity(
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
child: _ExpandingDockTabStrip(
items: mallItems,
selectedIndex: _MallNavigationDock.tabs
.indexWhere(
(item) =>
item.$1 == bridge.activeTab,
),
onTap: (index) => MallBridgeController
.instance
.activate(
_MallNavigationDock.tabs[index].$1,
reselect:
bridge.activeTab ==
_MallNavigationDock
.tabs[index]
.$1,
),
promoBadgeIndex: 0,
promoBadgeVisible: showMallHomeBadge,
promoBadgeLabel: mall.entryBadgeText,
),
),
),
],
),
),
],
],
),
),
);
},
),
),
);
},
);
}
}
class _MallLauncherButton extends StatelessWidget {
const _MallLauncherButton({
required this.mall,
required this.showPromoTip,
required this.onTap,
});
final MallTabConfig mall;
final bool showPromoTip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return MallEntryPromoTooltip(
visible: showPromoTip,
title: mall.entryTipTitle,
body: mall.entryTipBody,
onCardTap: onTap,
child: Semantics(
button: true,
label: mall.title,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [cs.primary.withValues(alpha: 0.78), cs.primary],
),
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Center(
child: Icon(
Icons.shopping_bag,
size: 25,
color: cs.primaryForeground,
),
),
),
),
),
),
);
}
}
class _DockTabItem {
const _DockTabItem({
required this.label,
required this.outline,
required this.filled,
this.symbol,
});
final String label;
final IconData outline;
final IconData filled;
final AppBottomTabSymbol? symbol;
}
class _ExpandingDockTabStrip extends StatelessWidget {
const _ExpandingDockTabStrip({
required this.items,
required this.selectedIndex,
required this.onTap,
this.minimize = 0,
this.promoBadgeIndex,
this.promoBadgeVisible = false,
this.promoBadgeLabel = '',
});
static const double _inset = 6;
static const double _selectedWidth = 92;
final List<_DockTabItem> items;
final int selectedIndex;
final ValueChanged<int> onTap;
final double minimize;
final int? promoBadgeIndex;
final bool promoBadgeVisible;
final String promoBadgeLabel;
@override
Widget build(BuildContext context) {
final safeIndex = selectedIndex.clamp(0, items.length - 1);
return LayoutBuilder(
builder: (context, constraints) {
final trackWidth = constraints.maxWidth - _inset * 2;
final selectedWidth = lerpDouble(
_selectedWidth.clamp(52.0, trackWidth).toDouble(),
52,
minimize.clamp(0.0, 1.0),
)!;
final compactWidth = (trackWidth - selectedWidth) / (items.length - 1);
final indicatorLeft = _inset + safeIndex * compactWidth;
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
left: indicatorLeft,
top: _inset,
bottom: _inset,
width: selectedWidth,
child: _DockSelectionHighlight(
key: ValueKey('dock-selection-${items[safeIndex].label}'),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: _inset),
child: Row(
children: [
for (var index = 0; index < items.length; index++)
AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
width: index == safeIndex ? selectedWidth : compactWidth,
child: MallEntryPromoBadge(
visible: index == promoBadgeIndex && promoBadgeVisible,
label: promoBadgeLabel,
top: -2,
right: 0,
child: _ExpandingDockTab(
item: items[index],
selected: index == safeIndex,
labelFactor: index == safeIndex
? (1 - minimize).clamp(0.0, 1.0)
: 0,
onTap: () => onTap(index),
),
),
),
],
),
),
],
);
},
);
}
}
class _DockSelectionHighlight extends StatelessWidget {
const _DockSelectionHighlight({super.key});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(26),
color: cs.primary.withValues(alpha: 0.14),
),
);
}
}
class _ExpandingDockTab extends StatelessWidget {
const _ExpandingDockTab({
required this.item,
required this.selected,
required this.labelFactor,
required this.onTap,
});
final _DockTabItem item;
final bool selected;
final double labelFactor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Semantics(
button: true,
selected: selected,
label: item.label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(26),
splashFactory: NoSplash.splashFactory,
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
child: LayoutBuilder(
builder: (context, constraints) {
// `AnimatedContainer` changes a tab's width during a tab switch.
// Derive text room from this animation frame's *actual* width,
// not the target selected width, as Purse's tab button does.
final maxLabelWidth = (constraints.maxWidth - 31)
.clamp(0.0, 64.0)
.toDouble();
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.symbol case final symbol?)
AppBottomTabIcon(
symbol: symbol,
selected: selected,
size: 24,
unselectedColor: cs.mutedForeground,
selectedColor: cs.primary,
)
else
Icon(
selected ? item.filled : item.outline,
size: 24,
color: selected ? cs.primary : cs.mutedForeground,
),
if (selected)
ClipRect(
child: Align(
alignment: Alignment.centerLeft,
widthFactor: labelFactor,
child: Opacity(
opacity: labelFactor,
child: Padding(
padding: const EdgeInsets.only(left: 7),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxLabelWidth,
),
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: TextStyle(
color: cs.primary,
fontSize: 16,
height: 1.1,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
],
),
);
},
),
),
);
}
}
abstract final class _MallNavigationDock {
static const tabs = <(MallBridgeTab, IconData, IconData, String)>[
(MallBridgeTab.home, Icons.shopping_bag_outlined, Icons.shopping_bag, '首页'),
(MallBridgeTab.market, Icons.show_chart, Icons.insert_chart, '行情'),
(
MallBridgeTab.orders,
Icons.receipt_long_outlined,
Icons.receipt_long,
'订单',
),
(MallBridgeTab.profile, Icons.person_outline, Icons.person, '我的'),
];
}
class _MallDockSurface extends StatelessWidget {
const _MallDockSurface({this.width, this.height = 56, required this.child});
final double? width;
final double height;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
// Clip only the frosted background. The mall promotion badge is
// intentionally positioned above the pill and must remain visible.
child: ClipRRect(
borderRadius: BorderRadius.circular(height / 2),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.background.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(height / 2),
border: Border.all(color: cs.border.withValues(alpha: 0.8)),
),
),
),
),
),
Positioned.fill(child: child),
],
),
);
}
}
const _filledIcons = [
AppIcons.assetsFill,
AppIcons.marketFill,
AppIcons.newsFill,
AppIcons.meFill,
];
const _outlineIcons = [
AppIcons.assetsLine,
AppIcons.marketLine,
AppIcons.newsLine,
AppIcons.meLine,
];
const _nativeSymbols = [
AppBottomTabSymbol.assets,
AppBottomTabSymbol.market,
AppBottomTabSymbol.news,
AppBottomTabSymbol.profile,
];
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;
}