新增静态演示界面

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
+4
View File
@@ -12,6 +12,10 @@
.swiftpm/
migrate_working_dir/
# Keep Flutter application sources visible even if a global gitignore ignores lib/.
!/lib/
!/lib/**
# IntelliJ related
*.iml
*.ipr
+20 -45
View File
@@ -1,60 +1,35 @@
# Patbond Flutter
Patbond is a Flutter learning project. The current goal is to build the basic app frame for a social content app with a layout similar to Xiaohongshu.
Patbond Flutter 版本,迁移自原 React 前端原型。项目不依赖 Gemini Key 或后端服务,可运行在 Android、iOS、Web 和桌面平台。
## Current Progress
## 功能
- Flutter project initialized.
- App entry extracted into `lib/app/app.dart`.
- Main shell page created with a bottom navigation bar.
- Home page connected as the first tab.
- Basic project notes added under `doc/`.
- 社区动态搜索、点赞、收藏与评论
- 模拟 AI 图片/视频创作和社区发布
- 宠物资料、疫苗记录及健康档案管理
- 医院、美容和个人服务搜索、筛选与模拟预约
- 使用 `shared_preferences` 持久化宠物、疫苗和帖子数据
## Project Structure
AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
```text
lib/
main.dart
app/
app.dart
features/
home/
home_page.dart
main/
main_shell_page.dart
doc/
android_development.md
common_app_structure.md
main_app_const.md
project_structure.md
```
## Run The App
Install dependencies:
## 运行
```bash
flutter pub get
```
Run in debug mode:
```bash
flutter run
```
Or open the project in VS Code, select a device, and press `F5`.
Web 调试:
## Development Plan
```bash
flutter run -d chrome
```
Near-term steps:
## 验证
1. Replace placeholder tabs with real feature pages.
2. Build a basic home feed page.
3. Add note card models and mock data.
4. Add theme configuration.
5. Add detail pages and navigation when the main frame is stable.
## Git Notes
Generated files such as `.dart_tool/`, `build/`, IDE workspace files, and local platform configuration should not be committed.
```bash
dart format --set-exit-if-changed lib test
flutter analyze
flutter test
flutter build apk --debug
```
+2 -1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="patbond_flutter"
android:label="Patbond"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
+3
View File
@@ -1,5 +1,8 @@
allprojects {
repositories {
// Flutter Android engine artifacts. The mirror avoids very slow or
// interrupted downloads from storage.googleapis.com in China.
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
+3 -2
View File
@@ -1,7 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m
org.gradle.workers.max=2
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.parallel=false
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
+24 -3
View File
@@ -1,16 +1,37 @@
// import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/main/main_shell_page.dart';
import 'package:patbond_flutter/state/app_state.dart';
class App extends StatelessWidget{
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final AppState appState;
@override
void initState() {
super.initState();
appState = AppState()..load();
}
@override
void dispose() {
appState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Patbond',
debugShowCheckedModeBanner: false,
home: const MainShellPage(),
theme: buildAppTheme(),
home: MainShellPage(appState: appState),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
abstract final class AppColors {
static const primary = Color(0xFF4F46E5);
static const canvas = Color(0xFFF8FAFC);
static const ink = Color(0xFF0F172A);
static const muted = Color(0xFF64748B);
static const border = Color(0xFFE2E8F0);
static const success = Color(0xFF10B981);
static const warning = Color(0xFFF59E0B);
}
ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light,
surface: Colors.white,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.canvas,
fontFamilyFallback: const [
'Noto Sans CJK SC',
'Noto Sans SC',
'PingFang SC',
'Microsoft YaHei',
'sans-serif',
],
textTheme: const TextTheme(
headlineSmall: TextStyle(
color: AppColors.ink,
fontSize: 22,
fontWeight: FontWeight.w800,
),
titleLarge: TextStyle(
color: AppColors.ink,
fontSize: 18,
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
color: AppColors.ink,
fontSize: 15,
fontWeight: FontWeight.w700,
),
bodyMedium: TextStyle(color: AppColors.ink, fontSize: 14, height: 1.5),
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
),
cardTheme: const CardThemeData(
color: Colors.white,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
side: BorderSide(color: Color(0xFFF1F5F9)),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.white,
indicatorColor: const Color(0xFFE0E7FF),
height: 72,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
return TextStyle(
fontSize: 11,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? AppColors.primary
: AppColors.muted,
);
}),
),
);
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:patbond_flutter/models/models.dart';
const petAvatar =
'https://images.unsplash.com/photo-1552053831-71594a27632d?auto=format&fit=crop&w=600&q=85';
const userAvatar =
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=300&q=80';
const generatedPetImage =
'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?auto=format&fit=crop&w=1200&q=85';
const locationWeatherOptions = [
LocationWeather(
city: '北京',
district: '朝阳区',
temperature: 28,
condition: WeatherCondition.sunny,
conditionText: '',
highTemperature: 31,
lowTemperature: 21,
humidity: 42,
),
LocationWeather(
city: '上海',
district: '浦东新区',
temperature: 25,
condition: WeatherCondition.cloudy,
conditionText: '多云',
highTemperature: 28,
lowTemperature: 22,
humidity: 68,
),
LocationWeather(
city: '深圳',
district: '南山区',
temperature: 32,
condition: WeatherCondition.rainy,
conditionText: '阵雨',
highTemperature: 34,
lowTemperature: 27,
humidity: 81,
),
LocationWeather(
city: '成都',
district: '高新区',
temperature: 23,
condition: WeatherCondition.overcast,
conditionText: '',
highTemperature: 26,
lowTemperature: 19,
humidity: 72,
),
LocationWeather(
city: '杭州',
district: '西湖区',
temperature: 27,
condition: WeatherCondition.cloudy,
conditionText: '多云',
highTemperature: 30,
lowTemperature: 22,
humidity: 64,
),
LocationWeather(
city: '哈尔滨',
district: '道里区',
temperature: 6,
condition: WeatherCondition.snow,
conditionText: '小雪',
highTemperature: 8,
lowTemperature: 1,
humidity: 73,
),
];
final initialLocationWeather = locationWeatherOptions.first;
const initialPet = PetProfile(
name: '豆豆',
breed: '柴犬',
gender: PetGender.male,
birthday: '2024-05-15',
weight: 5.2,
tags: ['已绝育', '疫苗齐全', '定期驱虫', '芯片植入'],
avatarUrl: petAvatar,
);
const initialVaccines = VaccineRecord(
completedDoses: 2,
totalDoses: 3,
items: [
VaccineItem(
id: 'v1',
name: '犬五联 第1针',
status: VaccineStatus.completed,
date: '2025-05-12',
),
VaccineItem(
id: 'v2',
name: '狂犬疫苗',
status: VaccineStatus.completed,
date: '2025-06-15',
),
VaccineItem(
id: 'v3',
name: '犬五联 加强针',
status: VaccineStatus.pending,
date: '待定',
),
],
reminderVaccine: '犬五联 加强针',
reminderDate: '2026-08-15',
);
const initialPosts = [
PostModel(
id: 'post1',
authorName: '豆豆的妈妈',
authorAvatar: userAvatar,
time: '2小时前',
breedTag: '柴犬',
content: '用 AI 把豆豆生成了治愈系插画,太可爱了吧!大家也快去试试创作功能!✨',
mainImage: generatedPetImage,
likes: 128,
tags: ['AI创作', '柴犬', 'Patbond'],
comments: [
CommentModel(
id: 'c1',
authorName: 'CorgiLover99',
authorAvatar:
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
content: '这也太像了吧!求教程怎么生成的?',
time: '1小时前',
),
CommentModel(
id: 'c2',
authorName: 'MiloDaCat',
authorAvatar:
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80',
content: '好可爱的画风,豆豆真上镜!',
time: '45分钟前',
),
],
),
PostModel(
id: 'post2',
authorName: 'Momo と猫',
authorAvatar:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=200&q=80',
time: '5小时前',
breedTag: '求助专区',
content: '猫咪最近总是挠耳朵,有懂的朋友吗?',
mainImage:
'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=1200&q=85',
likes: 12,
tags: ['猫咪健康', '求助'],
comments: [
CommentModel(
id: 'c3',
authorName: '猫奴小七',
authorAvatar:
'https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&w=200&q=80',
content: '建议先去医院检查耳道,不要自行用药哦。',
time: '4小时前',
),
],
),
];
const serviceProviders = [
ServiceProviderModel(
id: 'h1',
kind: ProviderKind.hospital,
name: '安宠动物医院(高新旗舰店)',
distance: '1.2km',
rating: 4.9,
description: '猫科内科 · 骨外科 · 预防医学 · 24H急诊',
tags: ['猫专科认证', '24H急诊'],
price: 299,
image:
'https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'h2',
kind: ProviderKind.hospital,
name: '瑞派宠物医院(南山分院)',
distance: '3.5km',
rating: 4.7,
description: '异宠专科 · 牙科 · 皮肤科',
tags: ['异宠专家坐诊'],
price: 399,
image:
'https://images.unsplash.com/photo-1581888227599-779811939961?auto=format&fit=crop&w=900&q=85',
),
ServiceProviderModel(
id: 'g1',
kind: ProviderKind.grooming,
name: 'PawSpa 高级宠物沙龙',
distance: '1.6km',
rating: 4.9,
description: '资深美容师主理 · 环境静音 · 猫咪免应激',
tags: ['日系精修', '猫咪免应激'],
price: 158,
image:
'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'p1',
kind: ProviderKind.personal,
name: '阳光遛狗员-阿健',
distance: '0.8km',
rating: 4.9,
description: '资深铲屎官,提供耐心、安全的上门遛狗服务。',
tags: ['实时定位', '自带清洁包', '只接中小型犬'],
price: 45,
image:
'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'p2',
kind: ProviderKind.personal,
name: '喵汪小保姆-莉莉',
distance: '3.5km',
rating: 4.7,
description: '周末全天可接单,可接大型犬并提供喂食服务。',
tags: ['可接大型犬', '体能充沛'],
price: 60,
image:
'https://images.unsplash.com/photo-1558788353-f76d92427f16?auto=format&fit=crop&w=900&q=85',
),
];
const creationStyles = [
CreationStyle(
id: 'healing',
title: '治愈动画',
subtitle: '梦幻动漫感',
image: generatedPetImage,
),
CreationStyle(
id: '3d',
title: '3D 卡通',
subtitle: '经典渲染',
image:
'https://images.unsplash.com/photo-1558929996-da64ba858215?auto=format&fit=crop&w=700&q=85',
),
CreationStyle(
id: 'comic',
title: '漫画风',
subtitle: '帅气网格',
image:
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=700&q=85',
),
CreationStyle(
id: 'watercolor',
title: '水彩',
subtitle: '柔和手绘质感',
image:
'https://images.unsplash.com/photo-1537151608828-ea2b11777ee8?auto=format&fit=crop&w=700&q=85',
),
];
const serviceCategories = [
('投喂', 'restaurant'),
('遛狗', 'walk'),
('洗澡', 'bath'),
('美容', 'cut'),
('寄养', 'home'),
('运输', 'truck'),
('领养', 'favorite'),
('医院', 'hospital'),
];
+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('立即预约'),
),
],
),
],
),
),
],
),
);
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ import 'app/app.dart';
void main() {
runApp(const App());
}
}
+376
View File
@@ -0,0 +1,376 @@
enum PetGender { male, female }
enum WeatherCondition { sunny, cloudy, rainy, overcast, snow }
class LocationWeather {
const LocationWeather({
required this.city,
required this.district,
required this.temperature,
required this.condition,
required this.conditionText,
required this.highTemperature,
required this.lowTemperature,
required this.humidity,
});
final String city;
final String district;
final int temperature;
final WeatherCondition condition;
final String conditionText;
final int highTemperature;
final int lowTemperature;
final int humidity;
String get displayArea => district.isEmpty ? city : '$city · $district';
String get petAdvice {
if (condition == WeatherCondition.rainy) {
return '今天有雨,外出记得带雨具并及时擦干脚掌。';
}
if (condition == WeatherCondition.snow) {
return '天气寒冷,短毛宠物外出记得做好保暖。';
}
if (temperature >= 30) {
return '今天较热,建议清晨或傍晚带毛孩子出门。';
}
if (temperature <= 8) {
return '气温偏低,散步时间不宜过长,注意保暖。';
}
if (condition == WeatherCondition.sunny) {
return '天气不错,适合和毛孩子一起去户外散步。';
}
return '体感舒适,适合安排一次轻松的户外活动。';
}
factory LocationWeather.fromJson(Map<String, dynamic> json) {
return LocationWeather(
city: json['city'] as String,
district: json['district'] as String? ?? '',
temperature: json['temperature'] as int,
condition: WeatherCondition.values.firstWhere(
(value) => value.name == json['condition'],
orElse: () => WeatherCondition.sunny,
),
conditionText: json['conditionText'] as String,
highTemperature: json['highTemperature'] as int,
lowTemperature: json['lowTemperature'] as int,
humidity: json['humidity'] as int,
);
}
Map<String, dynamic> toJson() => {
'city': city,
'district': district,
'temperature': temperature,
'condition': condition.name,
'conditionText': conditionText,
'highTemperature': highTemperature,
'lowTemperature': lowTemperature,
'humidity': humidity,
};
}
class PetProfile {
const PetProfile({
required this.name,
required this.breed,
required this.gender,
required this.birthday,
required this.weight,
required this.tags,
required this.avatarUrl,
});
final String name;
final String breed;
final PetGender gender;
final String birthday;
final double weight;
final List<String> tags;
final String avatarUrl;
PetProfile copyWith({
String? name,
String? breed,
PetGender? gender,
String? birthday,
double? weight,
List<String>? tags,
String? avatarUrl,
}) {
return PetProfile(
name: name ?? this.name,
breed: breed ?? this.breed,
gender: gender ?? this.gender,
birthday: birthday ?? this.birthday,
weight: weight ?? this.weight,
tags: tags ?? this.tags,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
factory PetProfile.fromJson(Map<String, dynamic> json) {
return PetProfile(
name: json['name'] as String,
breed: json['breed'] as String,
gender: json['gender'] == 'female' ? PetGender.female : PetGender.male,
birthday: json['birthday'] as String,
weight: (json['weight'] as num).toDouble(),
tags: List<String>.from(json['tags'] as List),
avatarUrl: json['avatarUrl'] as String,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'breed': breed,
'gender': gender.name,
'birthday': birthday,
'weight': weight,
'tags': tags,
'avatarUrl': avatarUrl,
};
}
enum VaccineStatus { completed, pending }
class VaccineItem {
const VaccineItem({
required this.id,
required this.name,
required this.status,
required this.date,
});
final String id;
final String name;
final VaccineStatus status;
final String date;
VaccineItem copyWith({VaccineStatus? status, String? date}) {
return VaccineItem(
id: id,
name: name,
status: status ?? this.status,
date: date ?? this.date,
);
}
factory VaccineItem.fromJson(Map<String, dynamic> json) {
return VaccineItem(
id: json['id'] as String,
name: json['name'] as String,
status: json['status'] == 'completed'
? VaccineStatus.completed
: VaccineStatus.pending,
date: json['date'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'status': status.name,
'date': date,
};
}
class VaccineRecord {
const VaccineRecord({
required this.completedDoses,
required this.totalDoses,
required this.items,
required this.reminderVaccine,
required this.reminderDate,
});
final int completedDoses;
final int totalDoses;
final List<VaccineItem> items;
final String reminderVaccine;
final String reminderDate;
factory VaccineRecord.fromJson(Map<String, dynamic> json) {
return VaccineRecord(
completedDoses: json['completedDoses'] as int,
totalDoses: json['totalDoses'] as int,
items: (json['items'] as List)
.map((item) => VaccineItem.fromJson(item as Map<String, dynamic>))
.toList(),
reminderVaccine: json['reminderVaccine'] as String,
reminderDate: json['reminderDate'] as String,
);
}
Map<String, dynamic> toJson() => {
'completedDoses': completedDoses,
'totalDoses': totalDoses,
'items': items.map((item) => item.toJson()).toList(),
'reminderVaccine': reminderVaccine,
'reminderDate': reminderDate,
};
}
class CommentModel {
const CommentModel({
required this.id,
required this.authorName,
required this.authorAvatar,
required this.content,
required this.time,
});
final String id;
final String authorName;
final String authorAvatar;
final String content;
final String time;
factory CommentModel.fromJson(Map<String, dynamic> json) {
return CommentModel(
id: json['id'] as String,
authorName: json['authorName'] as String,
authorAvatar: json['authorAvatar'] as String,
content: json['content'] as String,
time: json['time'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'authorName': authorName,
'authorAvatar': authorAvatar,
'content': content,
'time': time,
};
}
class PostModel {
const PostModel({
required this.id,
required this.authorName,
required this.authorAvatar,
required this.time,
required this.breedTag,
required this.content,
required this.mainImage,
required this.likes,
required this.tags,
required this.comments,
this.hasLiked = false,
this.hasBookmarked = false,
});
final String id;
final String authorName;
final String authorAvatar;
final String time;
final String breedTag;
final String content;
final String mainImage;
final int likes;
final List<String> tags;
final List<CommentModel> comments;
final bool hasLiked;
final bool hasBookmarked;
PostModel copyWith({
int? likes,
List<CommentModel>? comments,
bool? hasLiked,
bool? hasBookmarked,
}) {
return PostModel(
id: id,
authorName: authorName,
authorAvatar: authorAvatar,
time: time,
breedTag: breedTag,
content: content,
mainImage: mainImage,
likes: likes ?? this.likes,
tags: tags,
comments: comments ?? this.comments,
hasLiked: hasLiked ?? this.hasLiked,
hasBookmarked: hasBookmarked ?? this.hasBookmarked,
);
}
factory PostModel.fromJson(Map<String, dynamic> json) {
return PostModel(
id: json['id'] as String,
authorName: json['authorName'] as String,
authorAvatar: json['authorAvatar'] as String,
time: json['time'] as String,
breedTag: json['breedTag'] as String,
content: json['content'] as String,
mainImage: json['mainImage'] as String,
likes: json['likes'] as int,
tags: List<String>.from(json['tags'] as List),
comments: (json['comments'] as List)
.map((item) => CommentModel.fromJson(item as Map<String, dynamic>))
.toList(),
hasLiked: json['hasLiked'] as bool? ?? false,
hasBookmarked: json['hasBookmarked'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'authorName': authorName,
'authorAvatar': authorAvatar,
'time': time,
'breedTag': breedTag,
'content': content,
'mainImage': mainImage,
'likes': likes,
'tags': tags,
'comments': comments.map((comment) => comment.toJson()).toList(),
'hasLiked': hasLiked,
'hasBookmarked': hasBookmarked,
};
}
enum ProviderKind { hospital, grooming, personal }
class ServiceProviderModel {
const ServiceProviderModel({
required this.id,
required this.kind,
required this.name,
required this.distance,
required this.rating,
required this.description,
required this.tags,
required this.price,
required this.image,
this.verified = false,
});
final String id;
final ProviderKind kind;
final String name;
final String distance;
final double rating;
final String description;
final List<String> tags;
final int price;
final String image;
final bool verified;
}
class CreationStyle {
const CreationStyle({
required this.id,
required this.title,
required this.subtitle,
required this.image,
});
final String id;
final String title;
final String subtitle;
final String image;
}
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppState extends ChangeNotifier {
static const _petKey = 'patbond_pet';
static const _vaccinesKey = 'patbond_vaccines';
static const _postsKey = 'patbond_posts';
static const _locationWeatherKey = 'patbond_location_weather';
PetProfile pet = initialPet;
VaccineRecord vaccines = initialVaccines;
List<PostModel> posts = List<PostModel>.from(initialPosts);
LocationWeather locationWeather = initialLocationWeather;
bool isReady = false;
Future<void> load() async {
try {
final preferences = await SharedPreferences.getInstance();
final savedPet = preferences.getString(_petKey);
final savedVaccines = preferences.getString(_vaccinesKey);
final savedPosts = preferences.getString(_postsKey);
final savedLocationWeather = preferences.getString(_locationWeatherKey);
if (savedPet != null) {
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
}
if (savedVaccines != null) {
vaccines = VaccineRecord.fromJson(
jsonDecode(savedVaccines) as Map<String, dynamic>,
);
}
if (savedPosts != null) {
posts = (jsonDecode(savedPosts) as List)
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
.toList();
}
if (savedLocationWeather != null) {
locationWeather = LocationWeather.fromJson(
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
);
}
} catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
} finally {
isReady = true;
notifyListeners();
}
}
Future<void> updatePet(PetProfile value) async {
pet = value;
notifyListeners();
await _save(_petKey, value.toJson());
}
Future<void> updateVaccines(VaccineRecord value) async {
vaccines = value;
notifyListeners();
await _save(_vaccinesKey, value.toJson());
}
Future<void> updatePost(PostModel value) async {
final index = posts.indexWhere((post) => post.id == value.id);
if (index == -1) return;
posts[index] = value;
posts = List<PostModel>.from(posts);
notifyListeners();
await _savePosts();
}
Future<void> publishPost(PostModel value) async {
posts = [value, ...posts];
notifyListeners();
await _savePosts();
}
Future<void> updateLocationWeather(LocationWeather value) async {
locationWeather = value;
notifyListeners();
await _save(_locationWeatherKey, value.toJson());
}
Future<void> resetDemoData() async {
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
notifyListeners();
final preferences = await SharedPreferences.getInstance();
await Future.wait([
preferences.remove(_petKey),
preferences.remove(_vaccinesKey),
preferences.remove(_postsKey),
preferences.remove(_locationWeatherKey),
]);
}
Future<void> _savePosts() {
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
}
Future<void> _save(String key, Object value) async {
try {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(key, jsonEncode(value));
} catch (error, stackTrace) {
debugPrint('保存本地数据失败:$error\n$stackTrace');
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
class RemoteImage extends StatelessWidget {
const RemoteImage({
required this.url,
super.key,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius = BorderRadius.zero,
});
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.network(
url,
width: width,
height: height,
fit: fit,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: const Color(0xFFF1F5F9),
child: SizedBox(
width: width,
height: height,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: const Color(0xFFF1F5F9),
child: SizedBox(
width: width,
height: height,
child: const Center(
child: Icon(Icons.pets, color: AppColors.muted, size: 34),
),
),
);
},
),
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({required this.child, super.key, this.padding});
final Widget child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: padding ?? const EdgeInsets.all(18),
child: child,
),
);
}
}
class TagPill extends StatelessWidget {
const TagPill(this.label, {super.key, this.color = AppColors.primary});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(99),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({required this.message, super.key});
final String message;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
const Icon(
Icons.search_off_rounded,
size: 42,
color: AppColors.muted,
),
const SizedBox(height: 12),
Text(message, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}
+134 -1
View File
@@ -57,6 +57,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
@@ -75,6 +91,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
@@ -139,6 +160,102 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.dev"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -208,6 +325,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.44.0"
+2 -1
View File
@@ -1,5 +1,5 @@
name: patbond_flutter
description: "A new Flutter project."
description: "Patbond pet community and health companion."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -34,6 +34,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
shared_preferences: ^2.5.4
dev_dependencies:
flutter_test:
+13 -21
View File
@@ -1,29 +1,21 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
testWidgets('Patbond renders the main navigation', (tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const App());
await tester.pumpAndSettle();
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('Patbond'), findsWidgets);
expect(find.text('首页'), findsWidgets);
expect(find.text('创作'), findsOneWidget);
expect(find.text('档案'), findsOneWidget);
expect(find.text('服务'), findsOneWidget);
expect(find.text('我的'), findsOneWidget);
expect(find.text('北京 · 朝阳区'), findsOneWidget);
expect(find.text('28°C 晴'), findsOneWidget);
});
}