Files
lixi 694543647f
CI / flutter-gates (push) Failing after 1s
新增:首页问候语真实化 + 刻意保留的 demo 占位显式登记(T3.5-10,ADR-022)
- 「下午好,豆豆」改真实展示名(`nickname ?? username`,与资料页共用同一个
  ProfileController:一次 /me 供两个消费点,改昵称后两处一起变)。资料未到手
  时退化为不称名的「下午好 👋」,不编造假名。
- 按 ADR-022 决策 D3.5-1 钉死范围,**其余首页 demo 一律不动**,并在代码里逐项
  标注为「刻意保留的 demo 占位」:天气/位置(需接外部服务)、圈子=话题
  (ADR-018 剪出)、促销卡(M5 服务域)、搜索与本地服务段。
  widget 测试同时钉住「保留项仍在」,避免后续以「顺手清理」越出拍板范围。
- 新增 compose 真链路桌面实测脚本 `integration_test/profile_avatar_live_test.dart`
  (默认跳过,`PATBOND_PROFILE_LIVE=1 ... -d linux` 开启):UI 注册 → 登录 →
  资料真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像,逐步截图。
  只有选图与压缩两层是桌面替身,createUpload / 预签名 PUT / confirm / PATCH
  全为生产实现。两处实测坑写进注释:Feed 作者名有服务端 60s 缓存滞后;
  1×1 的极小图能上传能下载但 Flutter 解码器拒绝,会被误判成「没传上」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:48 +08:00

1160 lines
40 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/features/profile/profile_controller.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 }
/// 首页 TabFeed 段自 T3-14 起消费 [CommunityController] 真实数据
/// (四态 + 游标翻页 + 聚合曝光埋点);问候语自 T3.5-10 起用真实展示名。
///
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1 钉死范围,留待对应里程碑;
/// 在此登记以免实测重复反馈):
/// - **天气条与地区选择**[_WeatherStatusBar] / [_AreaPickerSheet] /
/// [_WeatherDetailsSheet]):需接外部天气服务(含 API key 与配额管理),
/// 不属本迭代。
/// - **story 环的「柴犬圈 / 猫咪圈 / 救助站」**([_StoryRow]):实为话题,
/// ADR-018 已把话题剪出 M3 范围;环内「发布」是真入口。
/// - **促销卡「新用户首单立减 ¥20」**[_PromoCard]):属 M5 服务域
/// (优惠/订单能力尚不存在)。
/// - **搜索框与「本地服务」段**:搜索只过滤已加载页(契约无检索端点);
/// 服务商数据为 demo 常量,同属 M5。
class HomePage extends StatefulWidget {
const HomePage({
required this.appState,
required this.communityController,
required this.onOpenServices,
required this.onOpenCompose,
super.key,
this.profileController,
this.onOpenPost,
this.feedAnalytics,
this.isActive = true,
});
final AppState appState;
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
final CommunityController communityController;
/// 展示名数据源(T3.5-10):与资料页**共用同一控制器**,
/// 故改昵称后两处一起变,且全程只发一次 `/me`。
/// 未注入或资料尚未到手时问候语不带名字(不编造「豆豆」这类假名)。
final ProfileController? profileController;
final ValueChanged<bool> onOpenServices;
/// 发布页入口(T3-17story 环「发布」与空态 CTA 均 push 真实发布页,
/// entryPoint=feed;创作 Tab 的 AI 模拟不再是社区发帖入口)。
final VoidCallback onOpenCompose;
/// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。
final ValueChanged<String>? onOpenPost;
/// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。
final FeedAnalytics? feedAnalytics;
/// 首页 Tab 是否为当前可见 TabIndexedStack 各 Tab 常驻构建,
/// 由主壳传入以驱动浏览段开/结算)。
final bool isActive;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
HomeSegment segment = HomeSegment.feed;
String query = '';
String sort = '综合';
/// 当前 Feed 浏览段(不在 Feed 面上时为 null)。
FeedViewSegment? _viewSegment;
/// 曝光扫描用:postId → 卡片 GlobalKey。
final Map<String, GlobalKey> _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);
// 展示名变化(资料首次到手 / 用户改昵称后返回)即重建问候语。
widget.profileController?.addListener(_onProfileChanged);
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
if (_feed.phase == FeedPhase.initial) {
_refreshFeed(userInitiated: false);
}
if (_feedSurfaceVisible) _startSegment();
}
void _onProfileChanged() {
if (mounted) setState(() {});
}
@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();
widget.profileController?.removeListener(_onProfileChanged);
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<void> _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<void> _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<FeedCard> 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<ServiceProviderModel> 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<void> selectArea() async {
final selected = await showModalBottomSheet<LocationWeather>(
context: context,
useSafeArea: true,
showDragHandle: true,
builder: (context) {
return _AreaPickerSheet(current: widget.appState.locationWeather);
},
);
if (selected != null) {
await widget.appState.updateLocationWeather(selected);
}
}
Future<void> showWeatherDetails() {
final weather = widget.appState.locationWeather;
return showModalBottomSheet<void>(
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<ScrollNotification>(
onNotification: _onScrollNotification,
child: RefreshIndicator(
onRefresh: () => segment == HomeSegment.feed
? _refreshFeed(userInitiated: true)
: Future<void>.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,
// T3.5-10:真实展示名(`nickname ?? username`,与资料页
// 同源同规则)取代硬编码的 demo 宠物名「豆豆」。
// 资料未到手时为 null → 只问候不称名,不编造假名。
displayName: widget.profileController?.displayName,
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<HomeSegment>(
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.onOpenCompose),
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<Widget> _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.onOpenCompose,
),
];
}
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),
// 点赞/收藏经共享 ToggleSyncsource=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<FeedCard> 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),
),
),
);
}
}
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):定位与天气需接外部
/// 服务(含 API key 与配额管理),本迭代不做;地区选择只改本机 demo 数据。
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.displayName,
required this.petAvatar,
required this.advice,
});
final String greeting;
/// 当前用户展示名;null 即资料未到手(问候语退化为不称名形态)。
final String? displayName;
/// **刻意保留的 demo 占位**:卡右侧大图仍是 demo 宠物图(用户头像与宠物
/// 头像本单已真实化,但「首页该显示哪只宠物」需要一个『当前宠物』概念,
/// 尚不存在——不在 ADR-022 范围内)。
final String petAvatar;
/// **刻意保留的 demo 占位**:养宠建议来自 demo 天气数据(外部天气服务
/// 未接,ADR-022 决策 D3.5-1)。
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(
displayName == null
? '$greeting 👋'
: '$greeting$displayName 👋',
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),
};
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):「柴犬圈 / 猫咪圈 /
/// 救助站」三个圈子实为**话题**,ADR-018 已把话题能力剪出 M3 范围,
/// 留待话题里程碑。首格「发布」是真入口(T3-17 起 push 真实发布页)。
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,
),
],
),
),
);
},
),
);
}
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):促销文案与「去使用」
/// 属 **M5 服务域**——优惠券与订单能力尚不存在,当前点按只跳本地服务段。
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<String> 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),
],
),
),
);
}
}