1007 lines
34 KiB
Dart
1007 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/storage_service.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(StorageService.to.preferences);
|
|
_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;
|
|
}
|