694543647f
CI / flutter-gates (push) Failing after 1s
- 「下午好,豆豆」改真实展示名(`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>
1160 lines
40 KiB
Dart
1160 lines
40 KiB
Dart
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 }
|
||
|
||
/// 首页 Tab:Feed 段自 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-17:story 环「发布」与空态 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 是否为当前可见 Tab(IndexedStack 各 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),
|
||
// 点赞/收藏经共享 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<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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|