新增静态演示界面

This commit is contained in:
2026-07-21 17:41:01 +08:00
parent 030b11fe67
commit b9d7c9f2ee
23 changed files with 4215 additions and 144 deletions
+544
View File
@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum CreationMode { image, video }
class CreatePage extends StatefulWidget {
const CreatePage({
required this.appState,
required this.onPublished,
super.key,
});
final AppState appState;
final ValueChanged<PostModel> onPublished;
@override
State<CreatePage> createState() => _CreatePageState();
}
class _CreatePageState extends State<CreatePage> {
final titleController = TextEditingController();
final contentController = TextEditingController();
CreationMode mode = CreationMode.image;
CreationStyle selectedStyle = creationStyles.first;
String selectedModel = 'Patbond-V1';
String duration = '5 秒';
String resolution = '1080P';
bool upscaling = true;
bool uploaded = false;
bool uploading = false;
bool generating = false;
int generationStep = 0;
String? resultUrl;
List<String> tags = ['可爱修勾', 'AI宠物'];
@override
void dispose() {
titleController.dispose();
contentController.dispose();
super.dispose();
}
Future<void> simulateUpload() async {
setState(() => uploading = true);
await Future<void>.delayed(const Duration(milliseconds: 700));
if (!mounted) return;
setState(() {
uploading = false;
uploaded = true;
resultUrl = null;
});
}
Future<void> generate() async {
if (!uploaded) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先选择一张宠物照片')));
return;
}
setState(() {
generating = true;
generationStep = 1;
});
for (var step = 2; step <= 4; step++) {
await Future<void>.delayed(const Duration(milliseconds: 650));
if (!mounted) return;
setState(() => generationStep = step);
}
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() {
generating = false;
resultUrl = selectedStyle.image;
titleController.text = mode == CreationMode.image
? '豆豆的${selectedStyle.title}冒险'
: '豆豆的 AI 萌宠短片';
contentController.text = '刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨';
});
}
Future<void> addTag() async {
final controller = TextEditingController();
final value = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('添加话题'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: '输入话题名称'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('添加'),
),
],
),
);
controller.dispose();
if (value == null || value.isEmpty || tags.contains(value)) return;
setState(() => tags = [...tags, value]);
}
void publish() {
if (resultUrl == null || titleController.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
return;
}
final post = PostModel(
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
authorName: '萌宠新手(我)',
authorAvatar: userAvatar,
time: '刚刚',
breedTag: 'AI创作',
content:
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
mainImage: resultUrl!,
likes: 1,
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
comments: const [],
hasLiked: true,
);
widget.appState.publishPost(post);
setState(() {
uploaded = false;
resultUrl = null;
generationStep = 0;
titleController.clear();
contentController.clear();
});
widget.onPublished(post);
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
SegmentedButton<CreationMode>(
segments: const [
ButtonSegment(
value: CreationMode.image,
icon: Icon(Icons.image_outlined),
label: Text('AI 图片'),
),
ButtonSegment(
value: CreationMode.video,
icon: Icon(Icons.movie_creation_outlined),
label: Text('AI 视频'),
),
],
selected: {mode},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() {
mode = value.first;
resultUrl = null;
}),
),
const SizedBox(height: 16),
_UploadCard(
uploaded: uploaded,
uploading: uploading,
imageUrl: widget.appState.pet.avatarUrl,
onTap: simulateUpload,
onRemove: () => setState(() {
uploaded = false;
resultUrl = null;
}),
),
const SizedBox(height: 16),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('生成设置', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: selectedModel,
decoration: const InputDecoration(labelText: '创作模型'),
items: ['Patbond-V1', 'Pet-Art Pro', 'Cute Motion']
.map(
(model) =>
DropdownMenuItem(value: model, child: Text(model)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => selectedModel = value);
},
),
if (mode == CreationMode.video) ...[
const SizedBox(height: 12),
_ChoiceRow(
title: '视频时长',
values: const ['5 秒', '10 秒', '15 秒'],
selected: duration,
onChanged: (value) => setState(() => duration = value),
),
],
const SizedBox(height: 12),
_ChoiceRow(
title: '分辨率',
values: const ['720P', '1080P', '2K'],
selected: resolution,
onChanged: (value) => setState(() => resolution = value),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('高清增强'),
subtitle: const Text('提升毛发与眼睛细节'),
value: upscaling,
onChanged: (value) => setState(() => upscaling = value),
),
],
),
),
const SizedBox(height: 18),
Text('热门风格', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: creationStyles.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final style = creationStyles[index];
final selected = style.id == selectedStyle.id;
return InkWell(
onTap: () => setState(() {
selectedStyle = style;
resultUrl = null;
}),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 125,
decoration: BoxDecoration(
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(20),
),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: style.image,
borderRadius: BorderRadius.circular(18),
),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Color(0xCC0F172A)],
),
),
),
Positioned(
left: 12,
right: 12,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
Text(
style.subtitle,
style: const TextStyle(
color: Color(0xFFE2E8F0),
fontSize: 10,
),
),
],
),
),
],
),
),
);
},
),
),
const SizedBox(height: 18),
if (generating) _GenerationProgress(step: generationStep),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.ink,
),
onPressed: generating ? null : generate,
icon: generating
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.auto_awesome),
label: Text(generating ? '正在生成…' : '开始生成'),
),
if (resultUrl != null) ...[
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.check_circle, color: AppColors.success),
const SizedBox(width: 8),
Text(
'生成完成',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 14),
AspectRatio(
aspectRatio: 16 / 10,
child: RemoteImage(
url: resultUrl!,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 14),
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: '发布标题(必填)'),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
maxLines: 3,
decoration: const InputDecoration(labelText: '分享正文'),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...tags.map(
(tag) => InputChip(
label: Text('#$tag'),
onDeleted: () => setState(
() =>
tags = tags.where((item) => item != tag).toList(),
),
),
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('话题'),
onPressed: addTag,
),
],
),
const SizedBox(height: 14),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.location_on_outlined),
title: Text('北京市 · 朝阳区'),
trailing: Icon(Icons.chevron_right),
),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
onPressed: publish,
icon: const Icon(Icons.send_rounded),
label: const Text('发布到社区'),
),
],
),
),
],
],
);
}
}
class _UploadCard extends StatelessWidget {
const _UploadCard({
required this.uploaded,
required this.uploading,
required this.imageUrl,
required this.onTap,
required this.onRemove,
});
final bool uploaded;
final bool uploading;
final String imageUrl;
final VoidCallback onTap;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SizedBox(
height: 190,
width: double.infinity,
child: uploading
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('正在读取宠物照片…'),
],
)
: uploaded
? Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: imageUrl,
borderRadius: BorderRadius.circular(18),
),
Positioned(
right: 8,
top: 8,
child: IconButton.filled(
tooltip: '移除照片',
onPressed: onRemove,
icon: const Icon(Icons.close),
),
),
],
)
: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
child: Icon(Icons.add_photo_alternate_outlined),
),
SizedBox(height: 12),
Text(
'选择宠物照片',
style: TextStyle(fontWeight: FontWeight.w800),
),
SizedBox(height: 4),
Text('演示模式会读取豆豆的档案头像'),
],
),
),
),
);
}
}
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({
required this.title,
required this.values,
required this.selected,
required this.onChanged,
});
final String title;
final List<String> values;
final String selected;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 7),
Wrap(
spacing: 8,
children: values
.map(
(value) => ChoiceChip(
label: Text(value),
selected: value == selected,
onSelected: (_) => onChanged(value),
),
)
.toList(),
),
],
);
}
}
class _GenerationProgress extends StatelessWidget {
const _GenerationProgress({required this.step});
final int step;
@override
Widget build(BuildContext context) {
const labels = ['分析宠物特征', '加载风格模型', '生成画面细节', '高清增强与合成'];
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: SectionCard(
child: Column(
children: [
LinearProgressIndicator(value: step / labels.length),
const SizedBox(height: 12),
for (var index = 0; index < labels.length; index++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
index < step ? Icons.check_circle : Icons.circle_outlined,
color: index < step ? AppColors.success : AppColors.muted,
size: 20,
),
title: Text(labels[index]),
),
],
),
),
);
}
}
+852 -5
View File
@@ -1,13 +1,860 @@
// import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class HomePage extends StatelessWidget{
const HomePage({super.key});
enum HomeSegment { feed, services }
class HomePage extends StatefulWidget {
const HomePage({
required this.appState,
required this.onOpenPost,
required this.onOpenServices,
required this.onOpenCreate,
super.key,
});
final AppState appState;
final ValueChanged<PostModel> onOpenPost;
final ValueChanged<bool> onOpenServices;
final VoidCallback onOpenCreate;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
HomeSegment segment = HomeSegment.feed;
String query = '';
String sort = '综合';
List<PostModel> get filteredPosts {
final keyword = query.trim().toLowerCase();
if (keyword.isEmpty) return widget.appState.posts;
return widget.appState.posts.where((post) {
return post.content.toLowerCase().contains(keyword) ||
post.authorName.toLowerCase().contains(keyword) ||
post.tags.any((tag) => tag.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 const Center(
child: Text("Patbond"),
return RefreshIndicator(
onRefresh: () async =>
Future<void>.delayed(const Duration(milliseconds: 500)),
child: ListView(
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) {
setState(() => segment = 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),
if (filteredPosts.isEmpty)
const EmptyState(message: '没有找到相关动态')
else
...filteredPosts.map(
(post) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _PostCard(
post: post,
onTap: () => widget.onOpenPost(post),
onLike: () => widget.appState.updatePost(
post.copyWith(
hasLiked: !post.hasLiked,
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
),
),
),
),
),
] 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,
),
),
),
),
],
],
),
);
}
}
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: [Color(0xFFECFDF5), Color(0xFFFFF7ED)],
),
),
child: Stack(
children: [
const Positioned(
left: 160,
top: 15,
child: Icon(Icons.pets, size: 28, color: Color(0x224F46E5)),
),
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: Color(0xFF475569),
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: const Color(0xFFEEF2FF),
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 => const Color(0xFFF59E0B),
WeatherCondition.cloudy => const Color(0xFF64748B),
WeatherCondition.rainy => const Color(0xFF0284C7),
WeatherCondition.overcast => const Color(0xFF475569),
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: LinearGradient(
colors: [AppColors.primary, Color(0xFF8B5CF6)],
),
),
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(
gradient: const LinearGradient(
colors: [AppColors.primary, Color(0xFF7C3AED)],
),
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: Color(0xFFE0E7FF), fontSize: 12),
),
],
),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.primary,
),
onPressed: onTap,
child: const Text('去使用'),
),
],
),
);
}
}
class _PostCard extends StatelessWidget {
const _PostCard({
required this.post,
required this.onTap,
required this.onLike,
});
final PostModel post;
final VoidCallback onTap;
final VoidCallback onLike;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RemoteImage(
url: post.authorAvatar,
width: 38,
height: 38,
borderRadius: BorderRadius.circular(19),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
'${post.time} · ${post.breedTag}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.more_horiz, color: AppColors.muted),
],
),
),
AspectRatio(
aspectRatio: 4 / 3,
child: RemoteImage(url: post.mainImage),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
ActionChip(
avatar: Icon(
post.hasLiked
? Icons.favorite
: Icons.favorite_border,
size: 17,
color: post.hasLiked ? Colors.red : AppColors.primary,
),
label: Text('${post.likes}'),
onPressed: onLike,
),
const SizedBox(width: 8),
Chip(
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text('${post.comments.length}'),
),
const Spacer(),
const Icon(Icons.share_outlined, color: AppColors.muted),
],
),
],
),
),
],
),
),
);
}
}
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: const Color(0xFFEEF2FF),
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),
],
),
),
);
}
}
+159 -64
View File
@@ -1,8 +1,19 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/pets/pets_page.dart';
import 'package:patbond_flutter/features/post/post_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_page.dart';
import 'package:patbond_flutter/features/services/services_page.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget {
const MainShellPage({super.key});
const MainShellPage({required this.appState, super.key});
final AppState appState;
@override
State<MainShellPage> createState() => _MainShellPageState();
@@ -10,72 +21,156 @@ class MainShellPage extends StatefulWidget {
class _MainShellPageState extends State<MainShellPage> {
int currentIndex = 0;
bool showPersonalServices = false;
final pages = const [
HomePage(),
Center(child: Text('发现')),
Center(child: Text('发布')),
Center(child: Text('消息')),
Center(child: Text('我的')),
];
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
@override
Widget build(BuildContext context) {
return Scaffold(
body: pages[currentIndex],
bottomNavigationBar: NavigationBarTheme(
data: NavigationBarThemeData(
backgroundColor: Colors.white,
indicatorColor: Colors.red.shade50,
labelTextStyle: WidgetStateProperty.all(
const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: Colors.red);
}
return const IconThemeData(color: Colors.grey);
}),
),
child: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
setState(() {
currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: '发现',
),
NavigationDestination(
icon: Icon(Icons.add_circle_outline),
selectedIcon: Icon(Icons.add_circle),
label: '发布',
),
NavigationDestination(
icon: Icon(Icons.notifications_outlined),
selectedIcon: Icon(Icons.notifications),
label: '消息',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
void selectTab(int index) {
setState(() => currentIndex = index);
}
void openServices(bool personal) {
setState(() {
showPersonalServices = personal;
currentIndex = 3;
});
}
void openPost(PostModel post) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) =>
PostDetailPage(appState: widget.appState, postId: post.id),
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.appState,
builder: (context, _) {
if (!widget.appState.isReady) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final pages = [
HomePage(
appState: widget.appState,
onOpenPost: openPost,
onOpenServices: openServices,
onOpenCreate: () => selectTab(1),
),
CreatePage(
appState: widget.appState,
onPublished: (post) {
selectTab(0);
WidgetsBinding.instance.addPostFrameCallback(
(_) => openPost(post),
);
},
),
PetsPage(appState: widget.appState),
ServicesPage(
showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather,
),
ProfilePage(appState: widget.appState),
];
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white.withAlpha(245),
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: 1,
centerTitle: true,
title: Text(
titles[currentIndex],
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
leadingWidth: 118,
leading: InkWell(
onTap: () => selectTab(2),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.only(left: 14),
child: Row(
children: [
RemoteImage(
url: widget.appState.pet.avatarUrl,
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 7),
const Expanded(
child: Text(
'Patbond',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
),
actions: [
IconButton.filledTonal(
tooltip: '通知',
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('暂无新通知 🐾')));
},
icon: const Icon(Icons.notifications_outlined, size: 21),
),
const SizedBox(width: 10),
],
),
body: SafeArea(
top: false,
child: IndexedStack(index: currentIndex, children: pages),
),
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: selectTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: '创作',
),
NavigationDestination(
icon: Icon(Icons.pets_outlined),
selectedIcon: Icon(Icons.pets),
label: '档案',
),
NavigationDestination(
icon: Icon(Icons.storefront_outlined),
selectedIcon: Icon(Icons.storefront),
label: '服务',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
);
},
);
}
}
+723
View File
@@ -0,0 +1,723 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class PetsPage extends StatelessWidget {
const PetsPage({required this.appState, super.key});
final AppState appState;
String ageLabel(String value) {
final birthday = DateTime.tryParse(value);
if (birthday == null) return '年龄未知';
final today = DateTime.now();
var age = today.year - birthday.year;
if (today.month < birthday.month ||
(today.month == birthday.month && today.day < birthday.day)) {
age--;
}
return '${age < 0 ? 0 : age}';
}
Future<void> editPet(BuildContext context) async {
final value = await showModalBottomSheet<PetProfile>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => EditPetSheet(pet: appState.pet),
);
if (value != null) await appState.updatePet(value);
}
Future<void> editVaccines(BuildContext context) async {
final value = await showModalBottomSheet<VaccineRecord>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => VaccineSheet(record: appState.vaccines),
);
if (value != null) await appState.updateVaccines(value);
}
@override
Widget build(BuildContext context) {
final pet = appState.pet;
final vaccines = appState.vaccines;
final progress = vaccines.totalDoses == 0
? 0.0
: vaccines.completedDoses / vaccines.totalDoses;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Column(
children: [
Stack(
children: [
InkWell(
onTap: () => editPet(context),
borderRadius: BorderRadius.circular(54),
child: RemoteImage(
url: pet.avatarUrl,
width: 104,
height: 104,
borderRadius: BorderRadius.circular(52),
),
),
Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 16,
backgroundColor: AppColors.primary,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: '编辑资料',
onPressed: () => editPet(context),
icon: const Icon(
Icons.edit,
color: Colors.white,
size: 15,
),
),
),
),
],
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: const Text('编辑资料'),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.monitor_weight_outlined,
color: AppColors.primary,
value: '${pet.weight.toStringAsFixed(1)}kg',
label: '体重',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: Icons.vaccines_outlined,
color: AppColors.success,
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
label: '疫苗进度',
onTap: () => editVaccines(context),
),
),
const SizedBox(width: 10),
const Expanded(
child: _StatCard(
icon: Icons.payments_outlined,
color: AppColors.warning,
value: '¥328',
label: '本月花费',
),
),
],
),
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 18),
Row(
children: [
SizedBox.square(
dimension: 62,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 7,
backgroundColor: const Color(0xFFE2E8F0),
color: AppColors.success,
),
Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: '管理疫苗',
onPressed: () => editVaccines(context),
icon: const Icon(Icons.edit_outlined),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFECFDF5),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFA7F3D0)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: AppColors.success),
SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
style: TextStyle(color: Color(0xFF047857), height: 1.5),
),
),
],
),
),
const SizedBox(height: 22),
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
const _TimelineTile(
icon: Icons.medical_services_outlined,
title: '医疗 · 狂犬疫苗接种',
subtitle: '2025-06-12 · 瑞派宠物医院',
status: '已完成',
),
const _TimelineTile(
icon: Icons.restaurant_outlined,
title: '喂养 · 更换幼犬粮',
subtitle: '2025-05-20 · 体重增长稳定',
status: '已记录',
),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.color,
required this.value,
required this.label,
this.onTap,
});
final IconData icon;
final Color color;
final String value;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 6),
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
);
}
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.icon,
required this.title,
required this.subtitle,
required this.status,
});
final IconData icon;
final String title;
final String subtitle;
final String status;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SectionCard(
child: Row(
children: [
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
TagPill(status, color: AppColors.success),
],
),
),
);
}
}
class EditPetSheet extends StatefulWidget {
const EditPetSheet({required this.pet, super.key});
final PetProfile pet;
@override
State<EditPetSheet> createState() => _EditPetSheetState();
}
class _EditPetSheetState extends State<EditPetSheet> {
late final TextEditingController nameController;
late final TextEditingController weightController;
late String breed;
late PetGender gender;
late DateTime birthday;
late String avatar;
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
static const avatars = [
petAvatar,
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
];
@override
void initState() {
super.initState();
nameController = TextEditingController(text: widget.pet.name);
weightController = TextEditingController(text: '${widget.pet.weight}');
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
gender = widget.pet.gender;
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
avatar = widget.pet.avatarUrl;
}
@override
void dispose() {
nameController.dispose();
weightController.dispose();
super.dispose();
}
String dateText(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 14),
Center(
child: InkWell(
onTap: () {
final index = avatars.indexOf(avatar);
setState(
() => avatar = avatars[(index + 1) % avatars.length],
);
},
borderRadius: BorderRadius.circular(48),
child: Stack(
children: [
RemoteImage(
url: avatar,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
),
const Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 15,
child: Icon(Icons.edit, size: 15),
),
),
],
),
),
),
const SizedBox(height: 18),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '宠物昵称'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: breed,
decoration: const InputDecoration(labelText: '品种'),
items: breeds
.map(
(value) =>
DropdownMenuItem(value: value, child: Text(value)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => breed = value);
},
),
const SizedBox(height: 12),
SegmentedButton<PetGender>(
segments: const [
ButtonSegment(
value: PetGender.male,
icon: Icon(Icons.male),
label: Text('男孩'),
),
ButtonSegment(
value: PetGender.female,
icon: Icon(Icons.female),
label: Text('女孩'),
),
],
selected: {gender},
onSelectionChanged: (value) =>
setState(() => gender = value.first),
),
const SizedBox(height: 12),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(18),
),
leading: const Icon(Icons.cake_outlined),
title: const Text('生日'),
subtitle: Text(dateText(birthday)),
trailing: const Icon(Icons.calendar_month_outlined),
onTap: () async {
final value = await showDatePicker(
context: context,
initialDate: birthday,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (value != null) setState(() => birthday = value);
},
),
const SizedBox(height: 12),
TextField(
controller: weightController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: '体重(kg'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () {
final name = nameController.text.trim();
final weight = double.tryParse(weightController.text);
if (name.isEmpty || weight == null || weight <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
return;
}
Navigator.pop(
context,
widget.pet.copyWith(
name: name,
breed: breed,
gender: gender,
birthday: dateText(birthday),
weight: weight,
avatarUrl: avatar,
),
);
},
icon: const Icon(Icons.save_outlined),
label: const Text('保存修改'),
),
],
),
),
);
}
}
class VaccineSheet extends StatefulWidget {
const VaccineSheet({required this.record, super.key});
final VaccineRecord record;
@override
State<VaccineSheet> createState() => _VaccineSheetState();
}
class _VaccineSheetState extends State<VaccineSheet> {
late List<VaccineItem> items;
late int totalDoses;
late final TextEditingController reminderController;
late final TextEditingController dateController;
int get completed =>
items.where((item) => item.status == VaccineStatus.completed).length;
@override
void initState() {
super.initState();
items = List<VaccineItem>.from(widget.record.items);
totalDoses = widget.record.totalDoses;
reminderController = TextEditingController(
text: widget.record.reminderVaccine,
);
dateController = TextEditingController(text: widget.record.reminderDate);
}
@override
void dispose() {
reminderController.dispose();
dateController.dispose();
super.dispose();
}
void changeTotal(int delta) {
final minimum = completed > items.length ? completed : items.length;
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
}
@override
Widget build(BuildContext context) {
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
const SizedBox(width: 10),
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
SectionCard(
child: Column(
children: [
Row(
children: [
const Text('已接种 / 总规划'),
const Spacer(),
IconButton.filledTonal(
onPressed: () => changeTotal(-1),
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'$completed / $totalDoses',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
IconButton.filled(
onPressed: () => changeTotal(1),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(value: progress, minHeight: 9),
],
),
),
const SizedBox(height: 16),
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final done = item.status == VaccineStatus.completed;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: CheckboxListTile(
value: done,
title: Text(item.name),
subtitle: Text(
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
),
secondary: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? AppColors.success : AppColors.muted,
),
onChanged: (_) {
final today = DateTime.now();
final date =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
setState(() {
items[index] = item.copyWith(
status: done
? VaccineStatus.pending
: VaccineStatus.completed,
date: done ? '待定' : date,
);
});
},
),
);
}),
const SizedBox(height: 10),
TextField(
controller: reminderController,
decoration: const InputDecoration(labelText: '下一针提醒'),
),
const SizedBox(height: 12),
TextField(
controller: dateController,
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => Navigator.pop(
context,
VaccineRecord(
completedDoses: completed,
totalDoses: totalDoses,
items: items,
reminderVaccine: reminderController.text.trim(),
reminderDate: dateController.text.trim(),
),
),
icon: const Icon(Icons.save_outlined),
label: const Text('保存疫苗记录'),
),
],
),
),
);
}
}
class _SheetHandle extends StatelessWidget {
const _SheetHandle();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44,
height: 5,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(99),
),
),
);
}
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class PostDetailPage extends StatefulWidget {
const PostDetailPage({
required this.appState,
required this.postId,
super.key,
});
final AppState appState;
final String postId;
@override
State<PostDetailPage> createState() => _PostDetailPageState();
}
class _PostDetailPageState extends State<PostDetailPage> {
final commentController = TextEditingController();
bool isFollowing = false;
@override
void dispose() {
commentController.dispose();
super.dispose();
}
PostModel get post =>
widget.appState.posts.firstWhere((item) => item.id == widget.postId);
void toggleLike() {
final current = post;
widget.appState.updatePost(
current.copyWith(
hasLiked: !current.hasLiked,
likes: current.hasLiked ? current.likes - 1 : current.likes + 1,
),
);
}
void sendComment() {
final text = commentController.text.trim();
if (text.isEmpty) return;
final current = post;
final comment = CommentModel(
id: 'comment_${DateTime.now().millisecondsSinceEpoch}',
authorName: '萌宠新手',
authorAvatar: userAvatar,
content: text,
time: '刚刚',
);
widget.appState.updatePost(
current.copyWith(comments: [comment, ...current.comments]),
);
commentController.clear();
FocusScope.of(context).unfocus();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.appState,
builder: (context, _) {
final current = post;
return Scaffold(
appBar: AppBar(
title: const Text(
'社区动态',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
centerTitle: true,
actions: [
IconButton(
tooltip: '分享',
onPressed: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('分享链接已准备好(演示)'))),
icon: const Icon(Icons.ios_share_outlined),
),
],
),
body: ListView(
padding: const EdgeInsets.only(bottom: 100),
children: [
AspectRatio(
aspectRatio: 1,
child: RemoteImage(url: current.mainImage),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionCard(
child: Row(
children: [
RemoteImage(
url: current.authorAvatar,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
current.authorName,
style: Theme.of(
context,
).textTheme.titleMedium,
),
Text(
current.breedTag,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
FilledButton.tonal(
onPressed: () =>
setState(() => isFollowing = !isFollowing),
child: Text(isFollowing ? '已关注' : '关注'),
),
],
),
),
const SizedBox(height: 14),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(current.content),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: current.tags
.map((tag) => TagPill('#$tag'))
.toList(),
),
const SizedBox(height: 12),
Text(
'发布于 ${current.time}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
FilledButton.tonalIcon(
onPressed: toggleLike,
icon: Icon(
current.hasLiked
? Icons.favorite
: Icons.favorite_border,
color: current.hasLiked ? Colors.red : null,
),
label: Text('${current.likes}'),
),
const SizedBox(width: 10),
FilledButton.tonalIcon(
onPressed: () {},
icon: const Icon(Icons.chat_bubble_outline),
label: Text('${current.comments.length}'),
),
const Spacer(),
IconButton.filledTonal(
onPressed: () => widget.appState.updatePost(
current.copyWith(
hasBookmarked: !current.hasBookmarked,
),
),
icon: Icon(
current.hasBookmarked
? Icons.bookmark
: Icons.bookmark_border,
color: current.hasBookmarked
? AppColors.primary
: null,
),
),
],
),
const SizedBox(height: 22),
Text(
'评论 (${current.comments.length})',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
...current.comments.map(
(comment) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RemoteImage(
url: comment.authorAvatar,
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 10),
Expanded(
child: SectionCard(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
comment.authorName,
style: const TextStyle(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(comment.content),
const SizedBox(height: 5),
Text(
comment.time,
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
),
],
),
),
),
],
),
),
],
),
bottomSheet: SafeArea(
top: false,
child: Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Expanded(
child: TextField(
controller: commentController,
textInputAction: TextInputAction.send,
onSubmitted: (_) => sendComment(),
decoration: const InputDecoration(
hintText: '写下你的评论…',
isDense: true,
),
),
),
const SizedBox(width: 8),
IconButton.filled(
tooltip: '发送评论',
onPressed: sendComment,
icon: const Icon(Icons.send_rounded),
),
],
),
),
),
);
},
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({required this.appState, super.key});
final AppState appState;
static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
(Icons.badge_outlined, '宠物健康卡包', '电子接种证与体检报告'),
(Icons.location_on_outlined, '地址与定位管理', '管理家庭住址与常用医院'),
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
];
void showDemoMessage(BuildContext context, String name) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$name」功能为演示入口')));
}
Future<void> reset(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('恢复演示数据'),
content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认恢复'),
),
],
),
);
if (confirmed == true) {
await appState.resetDemoData();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('演示数据已恢复')));
}
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.ink,
borderRadius: BorderRadius.circular(30),
),
child: Column(
children: [
RemoteImage(
url: userAvatar,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
const SizedBox(height: 12),
const Text(
'萌宠新手(豆豆家长)',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
const Text(
'Patbond 社区创作达人',
style: TextStyle(color: Color(0xFFA5B4FC), fontSize: 12),
),
const SizedBox(height: 20),
const Divider(color: Color(0xFF334155)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat(
value: '${appState.posts.length}',
label: '我的作品',
),
],
),
],
),
),
const SizedBox(height: 18),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: menuItems.map((item) {
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFEEF2FF),
child: Icon(item.$1, color: AppColors.primary),
),
title: Text(
item.$2,
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(item.$3),
trailing: const Icon(Icons.chevron_right),
onTap: () => showDemoMessage(context, item.$2),
);
}).toList(),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => reset(context),
icon: const Icon(Icons.restart_alt),
label: const Text('恢复演示数据'),
),
const SizedBox(height: 10),
TextButton(
onPressed: () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'),
),
],
);
}
}
class _ProfileStat extends StatelessWidget {
const _ProfileStat({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
label,
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
),
],
);
}
}
+291
View File
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/widgets/common.dart';
class ServicesPage extends StatefulWidget {
const ServicesPage({
required this.showPersonal,
required this.locationWeather,
super.key,
});
final bool showPersonal;
final LocationWeather locationWeather;
@override
State<ServicesPage> createState() => _ServicesPageState();
}
class _ServicesPageState extends State<ServicesPage> {
late bool personal;
String query = '';
String filter = '全部';
@override
void initState() {
super.initState();
personal = widget.showPersonal;
}
@override
void didUpdateWidget(covariant ServicesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.showPersonal != widget.showPersonal) {
personal = widget.showPersonal;
}
}
List<ServiceProviderModel> get providers {
final keyword = query.trim().toLowerCase();
var list = serviceProviders.where((provider) {
final matchesType = personal
? provider.kind == ProviderKind.personal
: provider.kind != ProviderKind.personal;
final matchesQuery =
keyword.isEmpty ||
provider.name.toLowerCase().contains(keyword) ||
provider.description.toLowerCase().contains(keyword) ||
provider.tags.any((tag) => tag.toLowerCase().contains(keyword));
return matchesType && matchesQuery;
}).toList();
if (!personal) {
if (filter == '距离优先') {
list.sort(
(a, b) => double.parse(
a.distance.replaceAll('km', ''),
).compareTo(double.parse(b.distance.replaceAll('km', ''))),
);
} else if (filter == '24H急诊') {
list = list.where((item) => item.tags.contains('24H急诊')).toList();
} else if (filter == '美容洗护') {
list = list
.where((item) => item.kind == ProviderKind.grooming)
.toList();
}
}
return list;
}
Future<void> book(ServiceProviderModel provider) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认预约'),
content: Text('预约 ${provider.name}\n服务价格:¥${provider.price}'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirmed == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已提交「${provider.name}」预约(演示)')));
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.apartment_outlined),
label: Text('专业机构'),
),
ButtonSegment(
value: true,
icon: Icon(Icons.person_pin_circle_outlined),
label: Text('个人服务'),
),
],
selected: {personal},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() {
personal = value.first;
filter = '全部';
}),
),
const SizedBox(height: 10),
Row(
children: [
const Icon(
Icons.near_me_outlined,
size: 17,
color: AppColors.primary,
),
const SizedBox(width: 5),
Text(
'正在展示 ${widget.locationWeather.displayArea} 附近的服务',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 14),
TextField(
onChanged: (value) => setState(() => query = value),
decoration: InputDecoration(
hintText: personal ? '搜索遛狗、寄养或上门喂养…' : '搜索宠物医院、美容或服务…',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
tooltip: '筛选',
onPressed: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('可使用下方快捷筛选条件'))),
icon: const Icon(Icons.tune),
),
),
),
if (!personal) ...[
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ['全部', '距离优先', '24H急诊', '美容洗护']
.map(
(value) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(value),
selected: filter == value,
onSelected: (_) => setState(() => filter = value),
),
),
)
.toList(),
),
),
],
const SizedBox(height: 16),
if (providers.isEmpty)
const EmptyState(message: '没有找到符合条件的服务')
else
...providers.map(
(provider) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: _ServiceCard(
provider: provider,
onBook: () => book(provider),
),
),
),
],
);
}
}
class _ServiceCard extends StatelessWidget {
const _ServiceCard({required this.provider, required this.onBook});
final ServiceProviderModel provider;
final VoidCallback onBook;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: RemoteImage(url: provider.image),
),
if (provider.verified)
const Positioned(
left: 12,
top: 12,
child: TagPill('认证服务', color: AppColors.success),
),
Positioned(
right: 12,
bottom: 10,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(235),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 5,
),
child: Text(
'${provider.rating}',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
provider.name,
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
provider.distance,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 6),
Text(provider.description),
const SizedBox(height: 10),
Wrap(
spacing: 7,
runSpacing: 7,
children: provider.tags.map(TagPill.new).toList(),
),
const SizedBox(height: 14),
Row(
children: [
Text(
'¥${provider.price}',
style: const TextStyle(
color: AppColors.primary,
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
const Text('', style: TextStyle(color: AppColors.muted)),
const Spacer(),
FilledButton.icon(
onPressed: onBook,
icon: const Icon(Icons.calendar_month_outlined, size: 18),
label: const Text('立即预约'),
),
],
),
],
),
),
],
),
);
}
}