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 createState() => _TabShellState(); } class _TabShellState extends ConsumerState 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 _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( 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 _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 _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( 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 progress; final Animation minimize; final int currentIndex; final MallTabConfig mall; final bool showMallEntryTip; final bool showMallHomeBadge; final ValueChanged 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 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; }