Files
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

1114 lines
37 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/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] 真实数据
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
class HomePage extends StatefulWidget {
const HomePage({
required this.appState,
required this.communityController,
required this.onOpenServices,
required this.onOpenCompose,
super.key,
this.onOpenPost,
this.feedAnalytics,
this.isActive = true,
});
final AppState appState;
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
final CommunityController communityController;
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);
// 主壳挂载即预取(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<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,
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<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),
),
),
);
}
}
}
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<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),
],
),
),
);
}
}