import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/feed_skeleton.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/post_card.dart'; import 'package:patbond_flutter/data/demo_data.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_display.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/community/feed_exposure.dart'; import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; enum HomeSegment { feed, services } /// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据 /// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与 /// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。 class HomePage extends StatefulWidget { const HomePage({ required this.appState, required this.communityController, required this.onOpenServices, required this.onOpenCreate, super.key, this.onOpenPost, this.feedAnalytics, this.isActive = true, }); final AppState appState; /// Feed 数据源(Tab 级单例,app.dart 装配注入)。 final CommunityController communityController; final ValueChanged onOpenServices; final VoidCallback onOpenCreate; /// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。 final ValueChanged? onOpenPost; /// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。 final FeedAnalytics? feedAnalytics; /// 首页 Tab 是否为当前可见 Tab(IndexedStack 各 Tab 常驻构建, /// 由主壳传入以驱动浏览段开/结算)。 final bool isActive; @override State createState() => _HomePageState(); } class _HomePageState extends State with WidgetsBindingObserver { HomeSegment segment = HomeSegment.feed; String query = ''; String sort = '综合'; /// 当前 Feed 浏览段(不在 Feed 面上时为 null)。 FeedViewSegment? _viewSegment; /// 曝光扫描用:postId → 卡片 GlobalKey。 final Map _cardKeys = {}; final GlobalKey _listKey = GlobalKey(); AppLifecycleState _lastLifecycle = AppLifecycleState.resumed; CommunityController get _feed => widget.communityController; bool get _feedSurfaceVisible => widget.isActive && segment == HomeSegment.feed; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); // 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。 // 首屏自动预取不计入浏览段 refreshCount(非用户动作)。 if (_feed.phase == FeedPhase.initial) { _refreshFeed(userInitiated: false); } if (_feedSurfaceVisible) _startSegment(); } @override void didUpdateWidget(HomePage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isActive != widget.isActive) { widget.isActive ? _maybeStartSegment() : _settleSegment(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 退后台即结算浏览段(06 §1.2:feed_viewed 触发点之一);只在离开 // resumed 的第一次变更结算(inactive/hidden/paused 级联不重复)。 // 回前台若仍在 Feed 面上则开新段。 if (state == AppLifecycleState.resumed) { _lastLifecycle = state; _maybeStartSegment(); return; } if (_lastLifecycle == AppLifecycleState.resumed) { _settleSegment(); } _lastLifecycle = state; } @override void dispose() { _settleSegment(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } // ---- 浏览段开/结算 ---- void _startSegment() { _viewSegment ??= FeedViewSegment(); _scheduleVisibilityScan(); } void _maybeStartSegment() { if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) { _startSegment(); } } void _settleSegment() { final summary = _viewSegment?.settle(); _viewSegment = null; if (summary != null && widget.feedAnalytics != null) { summary.report(widget.feedAnalytics!); } } void _onSegmentChanged(HomeSegment value) { if (value == segment) return; setState(() => segment = value); value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment(); } // ---- 曝光可见性扫描 ---- bool _scanScheduled = false; /// 帧末扫描(滚动通知发生在本帧布局前,同步读 RenderBox 是旧位置; /// 统一调度到 post-frame,一帧至多一次)。 void _scheduleVisibilityScan() { if (_scanScheduled) return; _scanScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _scanScheduled = false; _scanVisibility(); }); } /// 以列表视口与各卡片 RenderBox 的纵向交叠比例回报可见性 /// (≥50% 且驻留 ≥500ms 才计曝光,判定在 [FeedViewSegment] 内)。 void _scanVisibility() { final viewSegment = _viewSegment; if (viewSegment == null || !mounted) return; final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?; if (listBox == null || !listBox.attached) return; final viewportTop = listBox.localToGlobal(Offset.zero).dy; final viewportBottom = viewportTop + listBox.size.height; for (final entry in _cardKeys.entries) { final box = entry.value.currentContext?.findRenderObject() as RenderBox?; var fraction = 0.0; if (box != null && box.attached && box.hasSize && box.size.height > 0) { final top = box.localToGlobal(Offset.zero).dy; final bottom = top + box.size.height; final visible = bottom.clamp(viewportTop, viewportBottom) - top.clamp(viewportTop, viewportBottom); fraction = visible / box.size.height; } viewSegment.updateVisibility(entry.key, fraction); } } bool _onScrollNotification(ScrollNotification notification) { if (segment != HomeSegment.feed) return false; if (notification is ScrollUpdateNotification || notification is ScrollEndNotification) { _scheduleVisibilityScan(); // 滚动近底触发翻页(余量 400 提前预取);失败态不自动重试, // 只走尾部重试条显式点按(避免滚动风暴反复打失败端点)。 if (notification.metrics.extentAfter < 400 && _feed.loadMorePhase == LoadMorePhase.idle) { _loadMoreFeed(); } } return false; } // ---- 数据加载与失败埋点 ---- Future _refreshFeed({required bool userInitiated}) async { if (userInitiated) _viewSegment?.recordRefresh(); await _feed.refresh(); final error = _feed.refreshError ?? _feed.lastError; if (error != null) { widget.feedAnalytics?.feedLoadFailedFrom( error, feedTab: FeedTab.home, loadType: FeedLoadType.refresh, ); } // 刷新失败但旧列表被保留:SnackBar 轻提示(首屏失败走 error 态横幅)。 if (_feed.refreshError != null && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))), ); } } Future _loadMoreFeed() async { if (_feed.phase != FeedPhase.ready || !_feed.hasMore || _feed.loadMorePhase == LoadMorePhase.loading) { return; } _viewSegment?.recordLoadMore(); await _feed.loadMore(); final error = _feed.loadMoreError; if (error != null) { widget.feedAnalytics?.feedLoadFailedFrom( error, feedTab: FeedTab.home, loadType: FeedLoadType.loadMore, ); } } /// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求; /// 契约 v1.3.0 无搜索端点)。 List get _visibleCards { final keyword = query.trim().toLowerCase(); if (keyword.isEmpty) return _feed.feed; return _feed.feed.where((card) { return card.contentPreview.toLowerCase().contains(keyword) || (card.title?.toLowerCase().contains(keyword) ?? false) || authorDisplayName(card.author).toLowerCase().contains(keyword); }).toList(); } List get filteredProviders { final keyword = query.trim().toLowerCase(); final result = serviceProviders.where((provider) { if (keyword.isEmpty) return true; return provider.name.toLowerCase().contains(keyword) || provider.description.toLowerCase().contains(keyword) || provider.tags.any((tag) => tag.toLowerCase().contains(keyword)); }).toList(); if (sort == '距离') { result.sort( (a, b) => double.parse( a.distance.replaceAll('km', ''), ).compareTo(double.parse(b.distance.replaceAll('km', ''))), ); } else if (sort == '评分') { result.sort((a, b) => b.rating.compareTo(a.rating)); } return result; } String get greeting { final hour = DateTime.now().hour; if (hour < 6) return '夜深了'; if (hour < 11) return '早上好'; if (hour < 14) return '中午好'; if (hour < 18) return '下午好'; return '晚上好'; } Future selectArea() async { final selected = await showModalBottomSheet( context: context, useSafeArea: true, showDragHandle: true, builder: (context) { return _AreaPickerSheet(current: widget.appState.locationWeather); }, ); if (selected != null) { await widget.appState.updateLocationWeather(selected); } } Future showWeatherDetails() { final weather = widget.appState.locationWeather; return showModalBottomSheet( context: context, useSafeArea: true, showDragHandle: true, builder: (context) => _WeatherDetailsSheet(weather: weather), ); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _feed, builder: (context, _) { // 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页 // 共用 controller 的 toggleError 消费口,先消费者清空)。 if (_feed.toggleError != null) { _feed.clearToggleError(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('操作失败,请重试'))); }); } // ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。 if (_viewSegment != null && _feed.phase == FeedPhase.ready) { _scheduleVisibilityScan(); } return NotificationListener( onNotification: _onScrollNotification, child: RefreshIndicator( onRefresh: () => segment == HomeSegment.feed ? _refreshFeed(userInitiated: true) : Future.delayed(const Duration(milliseconds: 500)), child: ListView( key: _listKey, padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), children: [ _WeatherStatusBar( weather: widget.appState.locationWeather, onAreaTap: selectArea, onWeatherTap: showWeatherDetails, ), const SizedBox(height: 12), _PetGreetingCard( greeting: greeting, petName: widget.appState.pet.name, petAvatar: widget.appState.pet.avatarUrl, advice: widget.appState.locationWeather.petAdvice, ), const SizedBox(height: 14), TextField( onChanged: (value) => setState(() => query = value), decoration: const InputDecoration( hintText: '搜索动态、医院、美容或遛狗服务…', prefixIcon: Icon(Icons.search), ), ), const SizedBox(height: 14), SegmentedButton( segments: const [ ButtonSegment( value: HomeSegment.feed, icon: Icon(Icons.forum_outlined), label: Text('社区动态'), ), ButtonSegment( value: HomeSegment.services, icon: Icon(Icons.storefront_outlined), label: Text('本地服务'), ), ], selected: {segment}, showSelectedIcon: false, onSelectionChanged: (value) => _onSegmentChanged(value.first), ), const SizedBox(height: 18), if (segment == HomeSegment.feed) ...[ _StoryRow(onCreate: widget.onOpenCreate), const SizedBox(height: 18), _PromoCard(onTap: () => widget.onOpenServices(true)), const SizedBox(height: 18), ..._feedSection(), ] else ...[ _CategoryGrid(onTap: (_) => widget.onOpenServices(false)), const SizedBox(height: 18), Row( children: [ Text( '附近推荐', style: Theme.of(context).textTheme.titleLarge, ), const Spacer(), for (final item in ['综合', '距离', '评分']) Padding( padding: const EdgeInsets.only(left: 6), child: ChoiceChip( label: Text(item), selected: sort == item, onSelected: (_) => setState(() => sort = item), ), ), ], ), const SizedBox(height: 12), if (filteredProviders.isEmpty) const EmptyState(message: '没有找到相关服务') else ...filteredProviders.map( (provider) => Padding( padding: const EdgeInsets.only(bottom: 12), child: _ProviderCompactCard( provider: provider, onTap: () => widget.onOpenServices( provider.kind == ProviderKind.personal, ), ), ), ), ], ], ), ), ); }, ); } // ---- Feed 段四态渲染 ---- List _feedSection() { switch (_feed.phase) { case FeedPhase.initial: case FeedPhase.loading: return const [ FeedSkeleton(), SizedBox(height: 16), FeedSkeleton(), SizedBox(height: 16), FeedSkeleton(), ]; case FeedPhase.error: return [ Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Column( children: [ InlineErrorBanner( message: feedLoadErrorMessage(_feed.lastError), ), const SizedBox(height: 16), FilledButton( onPressed: () => _refreshFeed(userInitiated: true), child: const Text('重试'), ), ], ), ), ]; case FeedPhase.ready: if (_feed.feed.isEmpty) { return [ EmptyStateIllustration( icon: Icons.forum_outlined, title: '还没有动态', description: '关注的毛孩子们还没发帖,去逛逛话题吧', ctaLabel: '发布第一条', onCtaPressed: widget.onOpenCreate, ), ]; } final cards = _visibleCards; _pruneCardKeys(cards); if (cards.isEmpty) { return const [EmptyState(message: '没有找到相关动态')]; } return [ for (final card in cards) Padding( padding: const EdgeInsets.only(bottom: 16), child: KeyedSubtree( key: _cardKeys.putIfAbsent(card.id, GlobalKey.new), // 点赞/收藏经共享 ToggleSync(source=feed 缺省);整卡与 // 评论钮进详情(T3-15 导航接通,T3-14 的占位提示移除)。 child: PostCard( card: card, onTap: () => widget.onOpenPost?.call(card.id), onCommentTap: () => widget.onOpenPost?.call(card.id), onLikeTap: () => _feed.toggleLike(card.id), onBookmarkTap: () => _feed.toggleBookmark(card.id), ), ), ), // 搜索过滤中不渲染尾部(过滤只作用于已加载页,翻页语义混淆)。 if (query.trim().isEmpty) _feedTail(), ]; } } /// 卡片 key 表随当前列表收敛(刷新整体替换后旧帖的驻留计时作废)。 void _pruneCardKeys(List cards) { final alive = {for (final card in cards) card.id}; _cardKeys.removeWhere((id, _) { if (alive.contains(id)) return false; _viewSegment?.updateVisibility(id, 0); return true; }); } /// 尾部三态:加载中转圈 / 失败重试 / 到底「没有更多了」。 Widget _feedTail() { switch (_feed.loadMorePhase) { case LoadMorePhase.loading: return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2.5, color: AppColors.primary, ), ), ), ); case LoadMorePhase.error: return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: [ Text( feedLoadErrorMessage(_feed.loadMoreError), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), TextButton( onPressed: _loadMoreFeed, child: const Text('加载失败,点此重试'), ), ], ), ); case LoadMorePhase.idle: if (_feed.hasMore) return const SizedBox(height: 24); return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: Text( '没有更多了', style: TextStyle(color: AppColors.inkSoft, fontSize: 12), ), ), ); } } } class _WeatherStatusBar extends StatelessWidget { const _WeatherStatusBar({ required this.weather, required this.onAreaTap, required this.onWeatherTap, }); final LocationWeather weather; final VoidCallback onAreaTap; final VoidCallback onWeatherTap; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: InkWell( key: const ValueKey('area-selector'), onTap: onAreaTap, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 3), child: Row( children: [ const Icon( Icons.location_on_outlined, size: 21, color: AppColors.ink, ), const SizedBox(width: 5), Flexible( child: Text( weather.displayArea, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 3), const Icon( Icons.keyboard_arrow_down, size: 18, color: AppColors.muted, ), ], ), ), ), ), InkWell( key: const ValueKey('weather-details'), onTap: onWeatherTap, borderRadius: BorderRadius.circular(99), child: Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7), decoration: BoxDecoration( color: _weatherColor(weather.condition).withAlpha(22), borderRadius: BorderRadius.circular(99), ), child: Row( children: [ Icon( _weatherIcon(weather.condition), size: 19, color: _weatherColor(weather.condition), ), const SizedBox(width: 5), Text( '${weather.temperature}°C ${weather.conditionText}', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, ), ), ], ), ), ), ], ); } } class _PetGreetingCard extends StatelessWidget { const _PetGreetingCard({ required this.greeting, required this.petName, required this.petAvatar, required this.advice, }); final String greeting; final String petName; final String petAvatar; final String advice; @override Widget build(BuildContext context) { return Container( height: 138, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(26), gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.surfaceTint, AppColors.canvas], ), ), child: Stack( children: [ Positioned( left: 160, top: 15, child: Icon( Icons.pets, size: 28, color: AppColors.primary.withAlpha(34), ), ), Positioned( right: -5, bottom: -10, child: RemoteImage( url: petAvatar, width: 145, height: 145, borderRadius: BorderRadius.circular(72), ), ), Padding( padding: const EdgeInsets.fromLTRB(18, 19, 130, 17), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$greeting,$petName 👋', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.ink, fontSize: 20, fontWeight: FontWeight.w900, ), ), const SizedBox(height: 7), Text( advice, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.primaryDark, fontSize: 12, height: 1.45, fontWeight: FontWeight.w600, ), ), ], ), ), ], ), ); } } class _AreaPickerSheet extends StatelessWidget { const _AreaPickerSheet({required this.current}); final LocationWeather current; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('选择地区', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 4), Text( '当前为演示天气,选择后会保存在本机。', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 14), Flexible( child: ListView.separated( shrinkWrap: true, itemCount: locationWeatherOptions.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final option = locationWeatherOptions[index]; final selected = option.city == current.city && option.district == current.district; return ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: _weatherColor( option.condition, ).withAlpha(22), child: Icon( _weatherIcon(option.condition), color: _weatherColor(option.condition), ), ), title: Text( option.displayArea, style: const TextStyle(fontWeight: FontWeight.w800), ), subtitle: Text( '${option.conditionText} · ${option.lowTemperature}° / ${option.highTemperature}°', ), trailing: selected ? const Icon(Icons.check_circle, color: AppColors.primary) : Text('${option.temperature}°C'), onTap: () => Navigator.pop(context, option), ); }, ), ), ], ), ); } } class _WeatherDetailsSheet extends StatelessWidget { const _WeatherDetailsSheet({required this.weather}); final LocationWeather weather; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( weather.displayArea, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 18), Row( children: [ Icon( _weatherIcon(weather.condition), color: _weatherColor(weather.condition), size: 56, ), const SizedBox(width: 16), Text( '${weather.temperature}°', style: const TextStyle( fontSize: 44, fontWeight: FontWeight.w300, ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( weather.conditionText, style: const TextStyle(fontWeight: FontWeight.w800), ), Text( '${weather.lowTemperature}° / ${weather.highTemperature}°', style: Theme.of(context).textTheme.bodySmall, ), ], ), const Spacer(), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ const Text('湿度'), Text( '${weather.humidity}%', style: const TextStyle(fontWeight: FontWeight.w800), ), ], ), ], ), const SizedBox(height: 20), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.surfaceTint, borderRadius: BorderRadius.circular(18), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.pets, color: AppColors.primary), const SizedBox(width: 10), Expanded(child: Text(weather.petAdvice)), ], ), ), ], ), ); } } IconData _weatherIcon(WeatherCondition condition) { return switch (condition) { WeatherCondition.sunny => Icons.wb_sunny_rounded, WeatherCondition.cloudy => Icons.cloud_queue_rounded, WeatherCondition.rainy => Icons.water_drop_rounded, WeatherCondition.overcast => Icons.cloud_rounded, WeatherCondition.snow => Icons.ac_unit_rounded, }; } Color _weatherColor(WeatherCondition condition) { return switch (condition) { WeatherCondition.sunny => AppColors.warning, WeatherCondition.cloudy => AppColors.muted, WeatherCondition.rainy => const Color(0xFF0284C7), WeatherCondition.overcast => AppColors.ink, WeatherCondition.snow => const Color(0xFF0891B2), }; } class _StoryRow extends StatelessWidget { const _StoryRow({required this.onCreate}); final VoidCallback onCreate; @override Widget build(BuildContext context) { final stories = [ ('发布', Icons.add, petAvatar), ('柴犬圈', Icons.pets, generatedPetImage), ( '猫咪圈', Icons.pets, 'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=300&q=80', ), ( '救助站', Icons.volunteer_activism, 'https://images.unsplash.com/photo-1548199973-03cce0bbc87b?auto=format&fit=crop&w=300&q=80', ), ]; return SizedBox( height: 86, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: stories.length, separatorBuilder: (_, _) => const SizedBox(width: 16), itemBuilder: (context, index) { final item = stories[index]; return InkWell( onTap: index == 0 ? onCreate : null, borderRadius: BorderRadius.circular(36), child: SizedBox( width: 62, child: Column( children: [ Container( width: 58, height: 58, padding: const EdgeInsets.all(2), decoration: const BoxDecoration( shape: BoxShape.circle, gradient: AppColors.brandGradient, ), child: index == 0 ? const CircleAvatar( backgroundColor: Colors.white, child: Icon(Icons.add, color: AppColors.primary), ) : RemoteImage( url: item.$3, borderRadius: BorderRadius.circular(28), ), ), const SizedBox(height: 5), Text( item.$1, maxLines: 1, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ); }, ), ); } } class _PromoCard extends StatelessWidget { const _PromoCard({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( // 深端在左承载白字(4.5:1 达标);brandGradient 不承载正文文字(FIX-1)。 gradient: const LinearGradient( colors: [AppColors.primaryStrong, AppColors.primary], ), borderRadius: BorderRadius.circular(26), ), child: Row( children: [ const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '新用户首单立减 ¥20', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800, ), ), SizedBox(height: 5), Text( '遛狗 / 洗澡 / 寄养均可使用', style: TextStyle(color: Colors.white, fontSize: 12), ), ], ), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: AppColors.primaryStrong, ), onPressed: onTap, child: const Text('去使用'), ), ], ), ); } } class _CategoryGrid extends StatelessWidget { const _CategoryGrid({required this.onTap}); final ValueChanged onTap; static const items = [ ('投喂', Icons.restaurant), ('遛狗', Icons.directions_walk), ('洗澡', Icons.bathtub_outlined), ('美容', Icons.content_cut), ('寄养', Icons.home_outlined), ('运输', Icons.local_shipping_outlined), ('领养', Icons.favorite_border), ('医院', Icons.local_hospital_outlined), ]; @override Widget build(BuildContext context) { return SectionCard( child: GridView.count( crossAxisCount: 4, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 18, crossAxisSpacing: 8, childAspectRatio: .9, children: items.map((item) { return InkWell( onTap: () => onTap(item.$1), borderRadius: BorderRadius.circular(18), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( backgroundColor: AppColors.surfaceTint, child: Icon(item.$2, color: AppColors.primary), ), const SizedBox(height: 7), Text(item.$1, style: Theme.of(context).textTheme.bodySmall), ], ), ); }).toList(), ), ); } } class _ProviderCompactCard extends StatelessWidget { const _ProviderCompactCard({required this.provider, required this.onTap}); final ServiceProviderModel provider; final VoidCallback onTap; @override Widget build(BuildContext context) { return SectionCard( padding: const EdgeInsets.all(12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(18), child: Row( children: [ RemoteImage( url: provider.image, width: 82, height: 82, borderRadius: BorderRadius.circular(17), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( provider.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w800), ), const SizedBox(height: 4), Text( '${provider.distance} · ⭐ ${provider.rating}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 7), Text( '¥${provider.price} 起', style: const TextStyle( color: AppColors.primary, fontWeight: FontWeight.w800, ), ), ], ), ), const Icon(Icons.chevron_right, color: AppColors.muted), ], ), ), ); } }