新增静态演示界面

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/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
# Keep Flutter application sources visible even if a global gitignore ignores lib/.
!/lib/
!/lib/**
# IntelliJ related # IntelliJ related
*.iml *.iml
*.ipr *.ipr
+20 -45
View File
@@ -1,60 +1,35 @@
# Patbond Flutter # 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`. - 模拟 AI 图片/视频创作和社区发布
- Main shell page created with a bottom navigation bar. - 宠物资料、疫苗记录及健康档案管理
- Home page connected as the first tab. - 医院、美容和个人服务搜索、筛选与模拟预约
- Basic project notes added under `doc/`. - 使用 `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 ```bash
flutter pub get flutter pub get
```
Run in debug mode:
```bash
flutter run 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. ```bash
2. Build a basic home feed page. dart format --set-exit-if-changed lib test
3. Add note card models and mock data. flutter analyze
4. Add theme configuration. flutter test
5. Add detail pages and navigation when the main frame is stable. flutter build apk --debug
```
## Git Notes
Generated files such as `.dart_tool/`, `build/`, IDE workspace files, and local platform configuration should not be committed.
+2 -1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="patbond_flutter" android:label="Patbond"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
+3
View File
@@ -1,5 +1,8 @@
allprojects { allprojects {
repositories { 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/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/public") }
google() 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.java.home=/usr/lib/jvm/java-17-openjdk
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=false
android.useAndroidX=true android.useAndroidX=true
# This newDsl flag was added by the Flutter template # This newDsl flag was added by the Flutter template
android.newDsl=false android.newDsl=false
+24 -3
View File
@@ -1,16 +1,37 @@
// import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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/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}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Patbond', title: 'Patbond',
debugShowCheckedModeBanner: false, 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: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{ enum HomeSegment { feed, services }
const HomePage({super.key});
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Center( return RefreshIndicator(
child: Text("Patbond"), 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: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/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 { class MainShellPage extends StatefulWidget {
const MainShellPage({super.key}); const MainShellPage({required this.appState, super.key});
final AppState appState;
@override @override
State<MainShellPage> createState() => _MainShellPageState(); State<MainShellPage> createState() => _MainShellPageState();
@@ -10,72 +21,156 @@ class MainShellPage extends StatefulWidget {
class _MainShellPageState extends State<MainShellPage> { class _MainShellPageState extends State<MainShellPage> {
int currentIndex = 0; int currentIndex = 0;
bool showPersonalServices = false;
final pages = const [ static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
HomePage(),
Center(child: Text('发现')),
Center(child: Text('发布')),
Center(child: Text('消息')),
Center(child: Text('我的')),
];
@override void selectTab(int index) {
Widget build(BuildContext context) { setState(() => currentIndex = index);
return Scaffold( }
body: pages[currentIndex],
bottomNavigationBar: NavigationBarTheme( void openServices(bool personal) {
data: NavigationBarThemeData( setState(() {
backgroundColor: Colors.white, showPersonalServices = personal;
indicatorColor: Colors.red.shade50, currentIndex = 3;
labelTextStyle: WidgetStateProperty.all( });
const TextStyle( }
fontSize: 12,
fontWeight: FontWeight.w600, void openPost(PostModel post) {
), Navigator.of(context).push(
), MaterialPageRoute<void>(
iconTheme: WidgetStateProperty.resolveWith((states) { builder: (context) =>
if (states.contains(WidgetState.selected)) { PostDetailPage(appState: widget.appState, postId: post.id),
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: '我的',
),
],
),
), ),
); );
} }
@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() { void main() {
runApp(const App()); 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 FlutterMacOS
import Foundation import Foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }
+134 -1
View File
@@ -57,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -75,6 +91,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -139,6 +160,102 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -208,6 +325,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" 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: sdks:
dart: ">=3.12.2 <4.0.0" 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 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 # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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 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. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
shared_preferences: ^2.5.4
dev_dependencies: dev_dependencies:
flutter_test: 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:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app.dart'; import 'package:patbond_flutter/app/app.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Patbond renders the main navigation', (tester) async {
// Build our app and trigger a frame. SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const App()); await tester.pumpWidget(const App());
await tester.pumpAndSettle();
// Verify that our counter starts at 0. expect(find.text('Patbond'), findsWidgets);
expect(find.text('0'), findsOneWidget); expect(find.text('首页'), findsWidgets);
expect(find.text('1'), findsNothing); expect(find.text('创作'), findsOneWidget);
expect(find.text('档案'), findsOneWidget);
// Tap the '+' icon and trigger a frame. expect(find.text('服务'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add)); expect(find.text('我的'), findsOneWidget);
await tester.pump(); expect(find.text('北京 · 朝阳区'), findsOneWidget);
expect(find.text('28°C 晴'), findsOneWidget);
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
}); });
} }