新增静态演示界面
This commit is contained in:
@@ -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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('立即预约'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user