新增静态演示界面
This commit is contained in:
@@ -12,6 +12,10 @@
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# Keep Flutter application sources visible even if a global gitignore ignores lib/.
|
||||
!/lib/
|
||||
!/lib/**
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
@@ -1,60 +1,35 @@
|
||||
# Patbond Flutter
|
||||
|
||||
Patbond is a Flutter learning project. The current goal is to build the basic app frame for a social content app with a layout similar to Xiaohongshu.
|
||||
Patbond 的 Flutter 版本,迁移自原 React 前端原型。项目不依赖 Gemini Key 或后端服务,可运行在 Android、iOS、Web 和桌面平台。
|
||||
|
||||
## Current Progress
|
||||
## 功能
|
||||
|
||||
- Flutter project initialized.
|
||||
- App entry extracted into `lib/app/app.dart`.
|
||||
- Main shell page created with a bottom navigation bar.
|
||||
- Home page connected as the first tab.
|
||||
- Basic project notes added under `doc/`.
|
||||
- 社区动态搜索、点赞、收藏与评论
|
||||
- 模拟 AI 图片/视频创作和社区发布
|
||||
- 宠物资料、疫苗记录及健康档案管理
|
||||
- 医院、美容和个人服务搜索、筛选与模拟预约
|
||||
- 使用 `shared_preferences` 持久化宠物、疫苗和帖子数据
|
||||
|
||||
## Project Structure
|
||||
AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
|
||||
|
||||
```text
|
||||
lib/
|
||||
main.dart
|
||||
app/
|
||||
app.dart
|
||||
features/
|
||||
home/
|
||||
home_page.dart
|
||||
main/
|
||||
main_shell_page.dart
|
||||
doc/
|
||||
android_development.md
|
||||
common_app_structure.md
|
||||
main_app_const.md
|
||||
project_structure.md
|
||||
```
|
||||
|
||||
## Run The App
|
||||
|
||||
Install dependencies:
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
Run in debug mode:
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
Or open the project in VS Code, select a device, and press `F5`.
|
||||
Web 调试:
|
||||
|
||||
## Development Plan
|
||||
```bash
|
||||
flutter run -d chrome
|
||||
```
|
||||
|
||||
Near-term steps:
|
||||
## 验证
|
||||
|
||||
1. Replace placeholder tabs with real feature pages.
|
||||
2. Build a basic home feed page.
|
||||
3. Add note card models and mock data.
|
||||
4. Add theme configuration.
|
||||
5. Add detail pages and navigation when the main frame is stable.
|
||||
|
||||
## Git Notes
|
||||
|
||||
Generated files such as `.dart_tool/`, `build/`, IDE workspace files, and local platform configuration should not be committed.
|
||||
```bash
|
||||
dart format --set-exit-if-changed lib test
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:label="patbond_flutter"
|
||||
android:label="Patbond"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
// Flutter Android engine artifacts. The mirror avoids very slow or
|
||||
// interrupted downloads from storage.googleapis.com in China.
|
||||
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
google()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m
|
||||
org.gradle.workers.max=2
|
||||
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.parallel=false
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
|
||||
+24
-3
@@ -1,16 +1,37 @@
|
||||
// import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/features/main/main_shell_page.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
|
||||
class App extends StatelessWidget{
|
||||
class App extends StatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> {
|
||||
late final AppState appState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
appState = AppState()..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
appState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Patbond',
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const MainShellPage(),
|
||||
theme: buildAppTheme(),
|
||||
home: MainShellPage(appState: appState),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -0,0 +1,544 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
enum CreationMode { image, video }
|
||||
|
||||
class CreatePage extends StatefulWidget {
|
||||
const CreatePage({
|
||||
required this.appState,
|
||||
required this.onPublished,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
final ValueChanged<PostModel> onPublished;
|
||||
|
||||
@override
|
||||
State<CreatePage> createState() => _CreatePageState();
|
||||
}
|
||||
|
||||
class _CreatePageState extends State<CreatePage> {
|
||||
final titleController = TextEditingController();
|
||||
final contentController = TextEditingController();
|
||||
CreationMode mode = CreationMode.image;
|
||||
CreationStyle selectedStyle = creationStyles.first;
|
||||
String selectedModel = 'Patbond-V1';
|
||||
String duration = '5 秒';
|
||||
String resolution = '1080P';
|
||||
bool upscaling = true;
|
||||
bool uploaded = false;
|
||||
bool uploading = false;
|
||||
bool generating = false;
|
||||
int generationStep = 0;
|
||||
String? resultUrl;
|
||||
List<String> tags = ['可爱修勾', 'AI宠物'];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
titleController.dispose();
|
||||
contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> simulateUpload() async {
|
||||
setState(() => uploading = true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
uploading = false;
|
||||
uploaded = true;
|
||||
resultUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> generate() async {
|
||||
if (!uploaded) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先选择一张宠物照片')));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
generating = true;
|
||||
generationStep = 1;
|
||||
});
|
||||
for (var step = 2; step <= 4; step++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||
if (!mounted) return;
|
||||
setState(() => generationStep = step);
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
generating = false;
|
||||
resultUrl = selectedStyle.image;
|
||||
titleController.text = mode == CreationMode.image
|
||||
? '豆豆的${selectedStyle.title}冒险'
|
||||
: '豆豆的 AI 萌宠短片';
|
||||
contentController.text = '刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addTag() async {
|
||||
final controller = TextEditingController();
|
||||
final value = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('添加话题'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: '输入话题名称'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('添加'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (value == null || value.isEmpty || tags.contains(value)) return;
|
||||
setState(() => tags = [...tags, value]);
|
||||
}
|
||||
|
||||
void publish() {
|
||||
if (resultUrl == null || titleController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
|
||||
return;
|
||||
}
|
||||
final post = PostModel(
|
||||
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
authorName: '萌宠新手(我)',
|
||||
authorAvatar: userAvatar,
|
||||
time: '刚刚',
|
||||
breedTag: 'AI创作',
|
||||
content:
|
||||
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
|
||||
mainImage: resultUrl!,
|
||||
likes: 1,
|
||||
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
|
||||
comments: const [],
|
||||
hasLiked: true,
|
||||
);
|
||||
widget.appState.publishPost(post);
|
||||
setState(() {
|
||||
uploaded = false;
|
||||
resultUrl = null;
|
||||
generationStep = 0;
|
||||
titleController.clear();
|
||||
contentController.clear();
|
||||
});
|
||||
widget.onPublished(post);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
SegmentedButton<CreationMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: CreationMode.image,
|
||||
icon: Icon(Icons.image_outlined),
|
||||
label: Text('AI 图片'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: CreationMode.video,
|
||||
icon: Icon(Icons.movie_creation_outlined),
|
||||
label: Text('AI 视频'),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (value) => setState(() {
|
||||
mode = value.first;
|
||||
resultUrl = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_UploadCard(
|
||||
uploaded: uploaded,
|
||||
uploading: uploading,
|
||||
imageUrl: widget.appState.pet.avatarUrl,
|
||||
onTap: simulateUpload,
|
||||
onRemove: () => setState(() {
|
||||
uploaded = false;
|
||||
resultUrl = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('生成设置', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 14),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedModel,
|
||||
decoration: const InputDecoration(labelText: '创作模型'),
|
||||
items: ['Patbond-V1', 'Pet-Art Pro', 'Cute Motion']
|
||||
.map(
|
||||
(model) =>
|
||||
DropdownMenuItem(value: model, child: Text(model)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => selectedModel = value);
|
||||
},
|
||||
),
|
||||
if (mode == CreationMode.video) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ChoiceRow(
|
||||
title: '视频时长',
|
||||
values: const ['5 秒', '10 秒', '15 秒'],
|
||||
selected: duration,
|
||||
onChanged: (value) => setState(() => duration = value),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_ChoiceRow(
|
||||
title: '分辨率',
|
||||
values: const ['720P', '1080P', '2K'],
|
||||
selected: resolution,
|
||||
onChanged: (value) => setState(() => resolution = value),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('高清增强'),
|
||||
subtitle: const Text('提升毛发与眼睛细节'),
|
||||
value: upscaling,
|
||||
onChanged: (value) => setState(() => upscaling = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('热门风格', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: creationStyles.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final style = creationStyles[index];
|
||||
final selected = style.id == selectedStyle.id;
|
||||
return InkWell(
|
||||
onTap: () => setState(() {
|
||||
selectedStyle = style;
|
||||
resultUrl = null;
|
||||
}),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
width: 125,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primary : AppColors.border,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: style.image,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Color(0xCC0F172A)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
style.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
style.subtitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE2E8F0),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (generating) _GenerationProgress(step: generationStep),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: AppColors.ink,
|
||||
),
|
||||
onPressed: generating ? null : generate,
|
||||
icon: generating
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome),
|
||||
label: Text(generating ? '正在生成…' : '开始生成'),
|
||||
),
|
||||
if (resultUrl != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.success),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成完成',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 10,
|
||||
child: RemoteImage(
|
||||
url: resultUrl!,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(labelText: '发布标题(必填)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: contentController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: '分享正文'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
...tags.map(
|
||||
(tag) => InputChip(
|
||||
label: Text('#$tag'),
|
||||
onDeleted: () => setState(
|
||||
() =>
|
||||
tags = tags.where((item) => item != tag).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('话题'),
|
||||
onPressed: addTag,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.location_on_outlined),
|
||||
title: Text('北京市 · 朝阳区'),
|
||||
trailing: Icon(Icons.chevron_right),
|
||||
),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
onPressed: publish,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
label: const Text('发布到社区'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadCard extends StatelessWidget {
|
||||
const _UploadCard({
|
||||
required this.uploaded,
|
||||
required this.uploading,
|
||||
required this.imageUrl,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final bool uploaded;
|
||||
final bool uploading;
|
||||
final String imageUrl;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionCard(
|
||||
child: SizedBox(
|
||||
height: 190,
|
||||
width: double.infinity,
|
||||
child: uploading
|
||||
? const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 12),
|
||||
Text('正在读取宠物照片…'),
|
||||
],
|
||||
)
|
||||
: uploaded
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: imageUrl,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton.filled(
|
||||
tooltip: '移除照片',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
child: Icon(Icons.add_photo_alternate_outlined),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'选择宠物照片',
|
||||
style: TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('演示模式会读取豆豆的档案头像'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChoiceRow extends StatelessWidget {
|
||||
const _ChoiceRow({
|
||||
required this.title,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<String> values;
|
||||
final String selected;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 7),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: values
|
||||
.map(
|
||||
(value) => ChoiceChip(
|
||||
label: Text(value),
|
||||
selected: value == selected,
|
||||
onSelected: (_) => onChanged(value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenerationProgress extends StatelessWidget {
|
||||
const _GenerationProgress({required this.step});
|
||||
|
||||
final int step;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const labels = ['分析宠物特征', '加载风格模型', '生成画面细节', '高清增强与合成'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: SectionCard(
|
||||
child: Column(
|
||||
children: [
|
||||
LinearProgressIndicator(value: step / labels.length),
|
||||
const SizedBox(height: 12),
|
||||
for (var index = 0; index < labels.length; index++)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
index < step ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: index < step ? AppColors.success : AppColors.muted,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(labels[index]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,860 @@
|
||||
// import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class HomePage extends StatelessWidget{
|
||||
const HomePage({super.key});
|
||||
enum HomeSegment { feed, services }
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
required this.appState,
|
||||
required this.onOpenPost,
|
||||
required this.onOpenServices,
|
||||
required this.onOpenCreate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
final ValueChanged<PostModel> onOpenPost;
|
||||
final ValueChanged<bool> onOpenServices;
|
||||
final VoidCallback onOpenCreate;
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
HomeSegment segment = HomeSegment.feed;
|
||||
String query = '';
|
||||
String sort = '综合';
|
||||
|
||||
List<PostModel> get filteredPosts {
|
||||
final keyword = query.trim().toLowerCase();
|
||||
if (keyword.isEmpty) return widget.appState.posts;
|
||||
return widget.appState.posts.where((post) {
|
||||
return post.content.toLowerCase().contains(keyword) ||
|
||||
post.authorName.toLowerCase().contains(keyword) ||
|
||||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<ServiceProviderModel> get filteredProviders {
|
||||
final keyword = query.trim().toLowerCase();
|
||||
final result = serviceProviders.where((provider) {
|
||||
if (keyword.isEmpty) return true;
|
||||
return provider.name.toLowerCase().contains(keyword) ||
|
||||
provider.description.toLowerCase().contains(keyword) ||
|
||||
provider.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
||||
}).toList();
|
||||
if (sort == '距离') {
|
||||
result.sort(
|
||||
(a, b) => double.parse(
|
||||
a.distance.replaceAll('km', ''),
|
||||
).compareTo(double.parse(b.distance.replaceAll('km', ''))),
|
||||
);
|
||||
} else if (sort == '评分') {
|
||||
result.sort((a, b) => b.rating.compareTo(a.rating));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String get greeting {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 11) return '早上好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
}
|
||||
|
||||
Future<void> selectArea() async {
|
||||
final selected = await showModalBottomSheet<LocationWeather>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return _AreaPickerSheet(current: widget.appState.locationWeather);
|
||||
},
|
||||
);
|
||||
if (selected != null) {
|
||||
await widget.appState.updateLocationWeather(selected);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showWeatherDetails() {
|
||||
final weather = widget.appState.locationWeather;
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _WeatherDetailsSheet(weather: weather),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text("Patbond"),
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
Future<void>.delayed(const Duration(milliseconds: 500)),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_WeatherStatusBar(
|
||||
weather: widget.appState.locationWeather,
|
||||
onAreaTap: selectArea,
|
||||
onWeatherTap: showWeatherDetails,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PetGreetingCard(
|
||||
greeting: greeting,
|
||||
petName: widget.appState.pet.name,
|
||||
petAvatar: widget.appState.pet.avatarUrl,
|
||||
advice: widget.appState.locationWeather.petAdvice,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
onChanged: (value) => setState(() => query = value),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索动态、医院、美容或遛狗服务…',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SegmentedButton<HomeSegment>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: HomeSegment.feed,
|
||||
icon: Icon(Icons.forum_outlined),
|
||||
label: Text('社区动态'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: HomeSegment.services,
|
||||
icon: Icon(Icons.storefront_outlined),
|
||||
label: Text('本地服务'),
|
||||
),
|
||||
],
|
||||
selected: {segment},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (value) {
|
||||
setState(() => segment = value.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (segment == HomeSegment.feed) ...[
|
||||
_StoryRow(onCreate: widget.onOpenCreate),
|
||||
const SizedBox(height: 18),
|
||||
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
||||
const SizedBox(height: 18),
|
||||
if (filteredPosts.isEmpty)
|
||||
const EmptyState(message: '没有找到相关动态')
|
||||
else
|
||||
...filteredPosts.map(
|
||||
(post) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _PostCard(
|
||||
post: post,
|
||||
onTap: () => widget.onOpenPost(post),
|
||||
onLike: () => widget.appState.updatePost(
|
||||
post.copyWith(
|
||||
hasLiked: !post.hasLiked,
|
||||
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
for (final item in ['综合', '距离', '评分'])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: ChoiceChip(
|
||||
label: Text(item),
|
||||
selected: sort == item,
|
||||
onSelected: (_) => setState(() => sort = item),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (filteredProviders.isEmpty)
|
||||
const EmptyState(message: '没有找到相关服务')
|
||||
else
|
||||
...filteredProviders.map(
|
||||
(provider) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _ProviderCompactCard(
|
||||
provider: provider,
|
||||
onTap: () => widget.onOpenServices(
|
||||
provider.kind == ProviderKind.personal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeatherStatusBar extends StatelessWidget {
|
||||
const _WeatherStatusBar({
|
||||
required this.weather,
|
||||
required this.onAreaTap,
|
||||
required this.onWeatherTap,
|
||||
});
|
||||
|
||||
final LocationWeather weather;
|
||||
final VoidCallback onAreaTap;
|
||||
final VoidCallback onWeatherTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: const ValueKey('area-selector'),
|
||||
onTap: onAreaTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 21,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
weather.displayArea,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 18,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
key: const ValueKey('weather-details'),
|
||||
onTap: onWeatherTap,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: _weatherColor(weather.condition).withAlpha(22),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_weatherIcon(weather.condition),
|
||||
size: 19,
|
||||
color: _weatherColor(weather.condition),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${weather.temperature}°C ${weather.conditionText}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PetGreetingCard extends StatelessWidget {
|
||||
const _PetGreetingCard({
|
||||
required this.greeting,
|
||||
required this.petName,
|
||||
required this.petAvatar,
|
||||
required this.advice,
|
||||
});
|
||||
|
||||
final String greeting;
|
||||
final String petName;
|
||||
final String petAvatar;
|
||||
final String advice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 138,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFECFDF5), Color(0xFFFFF7ED)],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned(
|
||||
left: 160,
|
||||
top: 15,
|
||||
child: Icon(Icons.pets, size: 28, color: Color(0x224F46E5)),
|
||||
),
|
||||
Positioned(
|
||||
right: -5,
|
||||
bottom: -10,
|
||||
child: RemoteImage(
|
||||
url: petAvatar,
|
||||
width: 145,
|
||||
height: 145,
|
||||
borderRadius: BorderRadius.circular(72),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 19, 130, 17),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$greeting,$petName 👋',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.ink,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
advice,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AreaPickerSheet extends StatelessWidget {
|
||||
const _AreaPickerSheet({required this.current});
|
||||
|
||||
final LocationWeather current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('选择地区', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'当前为演示天气,选择后会保存在本机。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: locationWeatherOptions.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final option = locationWeatherOptions[index];
|
||||
final selected =
|
||||
option.city == current.city &&
|
||||
option.district == current.district;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _weatherColor(
|
||||
option.condition,
|
||||
).withAlpha(22),
|
||||
child: Icon(
|
||||
_weatherIcon(option.condition),
|
||||
color: _weatherColor(option.condition),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
option.displayArea,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${option.conditionText} · ${option.lowTemperature}° / ${option.highTemperature}°',
|
||||
),
|
||||
trailing: selected
|
||||
? const Icon(Icons.check_circle, color: AppColors.primary)
|
||||
: Text('${option.temperature}°C'),
|
||||
onTap: () => Navigator.pop(context, option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeatherDetailsSheet extends StatelessWidget {
|
||||
const _WeatherDetailsSheet({required this.weather});
|
||||
|
||||
final LocationWeather weather;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
weather.displayArea,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_weatherIcon(weather.condition),
|
||||
color: _weatherColor(weather.condition),
|
||||
size: 56,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'${weather.temperature}°',
|
||||
style: const TextStyle(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
weather.conditionText,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
'${weather.lowTemperature}° / ${weather.highTemperature}°',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('湿度'),
|
||||
Text(
|
||||
'${weather.humidity}%',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEF2FF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.pets, color: AppColors.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(weather.petAdvice)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _weatherIcon(WeatherCondition condition) {
|
||||
return switch (condition) {
|
||||
WeatherCondition.sunny => Icons.wb_sunny_rounded,
|
||||
WeatherCondition.cloudy => Icons.cloud_queue_rounded,
|
||||
WeatherCondition.rainy => Icons.water_drop_rounded,
|
||||
WeatherCondition.overcast => Icons.cloud_rounded,
|
||||
WeatherCondition.snow => Icons.ac_unit_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
Color _weatherColor(WeatherCondition condition) {
|
||||
return switch (condition) {
|
||||
WeatherCondition.sunny => const Color(0xFFF59E0B),
|
||||
WeatherCondition.cloudy => const Color(0xFF64748B),
|
||||
WeatherCondition.rainy => const Color(0xFF0284C7),
|
||||
WeatherCondition.overcast => const Color(0xFF475569),
|
||||
WeatherCondition.snow => const Color(0xFF0891B2),
|
||||
};
|
||||
}
|
||||
|
||||
class _StoryRow extends StatelessWidget {
|
||||
const _StoryRow({required this.onCreate});
|
||||
|
||||
final VoidCallback onCreate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stories = [
|
||||
('发布', Icons.add, petAvatar),
|
||||
('柴犬圈', Icons.pets, generatedPetImage),
|
||||
(
|
||||
'猫咪圈',
|
||||
Icons.pets,
|
||||
'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=300&q=80',
|
||||
),
|
||||
(
|
||||
'救助站',
|
||||
Icons.volunteer_activism,
|
||||
'https://images.unsplash.com/photo-1548199973-03cce0bbc87b?auto=format&fit=crop&w=300&q=80',
|
||||
),
|
||||
];
|
||||
return SizedBox(
|
||||
height: 86,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stories.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final item = stories[index];
|
||||
return InkWell(
|
||||
onTap: index == 0 ? onCreate : null,
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
child: SizedBox(
|
||||
width: 62,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primary, Color(0xFF8B5CF6)],
|
||||
),
|
||||
),
|
||||
child: index == 0
|
||||
? const CircleAvatar(
|
||||
backgroundColor: Colors.white,
|
||||
child: Icon(Icons.add, color: AppColors.primary),
|
||||
)
|
||||
: RemoteImage(
|
||||
url: item.$3,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
item.$1,
|
||||
maxLines: 1,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoCard extends StatelessWidget {
|
||||
const _PromoCard({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primary, Color(0xFF7C3AED)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'新用户首单立减 ¥20',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'遛狗 / 洗澡 / 寄养均可使用',
|
||||
style: TextStyle(color: Color(0xFFE0E7FF), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
onPressed: onTap,
|
||||
child: const Text('去使用'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostCard extends StatelessWidget {
|
||||
const _PostCard({
|
||||
required this.post,
|
||||
required this.onTap,
|
||||
required this.onLike,
|
||||
});
|
||||
|
||||
final PostModel post;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLike;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: post.authorAvatar,
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: BorderRadius.circular(19),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.authorName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
'${post.time} · ${post.breedTag}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.more_horiz, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: RemoteImage(url: post.mainImage),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.content,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
post.hasLiked
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
size: 17,
|
||||
color: post.hasLiked ? Colors.red : AppColors.primary,
|
||||
),
|
||||
label: Text('${post.likes}'),
|
||||
onPressed: onLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
|
||||
label: Text('${post.comments.length}'),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.share_outlined, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryGrid extends StatelessWidget {
|
||||
const _CategoryGrid({required this.onTap});
|
||||
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
static const items = [
|
||||
('投喂', Icons.restaurant),
|
||||
('遛狗', Icons.directions_walk),
|
||||
('洗澡', Icons.bathtub_outlined),
|
||||
('美容', Icons.content_cut),
|
||||
('寄养', Icons.home_outlined),
|
||||
('运输', Icons.local_shipping_outlined),
|
||||
('领养', Icons.favorite_border),
|
||||
('医院', Icons.local_hospital_outlined),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionCard(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 18,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: .9,
|
||||
children: items.map((item) {
|
||||
return InkWell(
|
||||
onTap: () => onTap(item.$1),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: const Color(0xFFEEF2FF),
|
||||
child: Icon(item.$2, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(item.$1, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderCompactCard extends StatelessWidget {
|
||||
const _ProviderCompactCard({required this.provider, required this.onTap});
|
||||
|
||||
final ServiceProviderModel provider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionCard(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Row(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: provider.image,
|
||||
width: 82,
|
||||
height: 82,
|
||||
borderRadius: BorderRadius.circular(17),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
provider.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${provider.distance} · ⭐ ${provider.rating}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
'¥${provider.price} 起',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_page.dart';
|
||||
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
||||
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
||||
import 'package:patbond_flutter/features/services/services_page.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class MainShellPage extends StatefulWidget {
|
||||
const MainShellPage({super.key});
|
||||
const MainShellPage({required this.appState, super.key});
|
||||
|
||||
final AppState appState;
|
||||
|
||||
@override
|
||||
State<MainShellPage> createState() => _MainShellPageState();
|
||||
@@ -10,72 +21,156 @@ class MainShellPage extends StatefulWidget {
|
||||
|
||||
class _MainShellPageState extends State<MainShellPage> {
|
||||
int currentIndex = 0;
|
||||
bool showPersonalServices = false;
|
||||
|
||||
final pages = const [
|
||||
HomePage(),
|
||||
Center(child: Text('发现')),
|
||||
Center(child: Text('发布')),
|
||||
Center(child: Text('消息')),
|
||||
Center(child: Text('我的')),
|
||||
];
|
||||
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: pages[currentIndex],
|
||||
bottomNavigationBar: NavigationBarTheme(
|
||||
data: NavigationBarThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: Colors.red.shade50,
|
||||
labelTextStyle: WidgetStateProperty.all(
|
||||
const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: Colors.red);
|
||||
}
|
||||
return const IconThemeData(color: Colors.grey);
|
||||
}),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
currentIndex = index;
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: '发现',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.add_circle_outline),
|
||||
selectedIcon: Icon(Icons.add_circle),
|
||||
label: '发布',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.notifications_outlined),
|
||||
selectedIcon: Icon(Icons.notifications),
|
||||
label: '消息',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
void selectTab(int index) {
|
||||
setState(() => currentIndex = index);
|
||||
}
|
||||
|
||||
void openServices(bool personal) {
|
||||
setState(() {
|
||||
showPersonalServices = personal;
|
||||
currentIndex = 3;
|
||||
});
|
||||
}
|
||||
|
||||
void openPost(PostModel post) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) =>
|
||||
PostDetailPage(appState: widget.appState, postId: post.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.appState,
|
||||
builder: (context, _) {
|
||||
if (!widget.appState.isReady) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final pages = [
|
||||
HomePage(
|
||||
appState: widget.appState,
|
||||
onOpenPost: openPost,
|
||||
onOpenServices: openServices,
|
||||
onOpenCreate: () => selectTab(1),
|
||||
),
|
||||
CreatePage(
|
||||
appState: widget.appState,
|
||||
onPublished: (post) {
|
||||
selectTab(0);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => openPost(post),
|
||||
);
|
||||
},
|
||||
),
|
||||
PetsPage(appState: widget.appState),
|
||||
ServicesPage(
|
||||
showPersonal: showPersonalServices,
|
||||
locationWeather: widget.appState.locationWeather,
|
||||
),
|
||||
ProfilePage(appState: widget.appState),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white.withAlpha(245),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
scrolledUnderElevation: 1,
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
titles[currentIndex],
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
leadingWidth: 118,
|
||||
leading: InkWell(
|
||||
onTap: () => selectTab(2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: widget.appState.pet.avatarUrl,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Patbond',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: '通知',
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无新通知 🐾')));
|
||||
},
|
||||
icon: const Icon(Icons.notifications_outlined, size: 21),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: IndexedStack(index: currentIndex, children: pages),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: selectTab,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home_rounded),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.auto_awesome_outlined),
|
||||
selectedIcon: Icon(Icons.auto_awesome),
|
||||
label: '创作',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.pets_outlined),
|
||||
selectedIcon: Icon(Icons.pets),
|
||||
label: '档案',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.storefront_outlined),
|
||||
selectedIcon: Icon(Icons.storefront),
|
||||
label: '服务',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class PetsPage extends StatelessWidget {
|
||||
const PetsPage({required this.appState, super.key});
|
||||
|
||||
final AppState appState;
|
||||
|
||||
String ageLabel(String value) {
|
||||
final birthday = DateTime.tryParse(value);
|
||||
if (birthday == null) return '年龄未知';
|
||||
final today = DateTime.now();
|
||||
var age = today.year - birthday.year;
|
||||
if (today.month < birthday.month ||
|
||||
(today.month == birthday.month && today.day < birthday.day)) {
|
||||
age--;
|
||||
}
|
||||
return '${age < 0 ? 0 : age} 岁';
|
||||
}
|
||||
|
||||
Future<void> editPet(BuildContext context) async {
|
||||
final value = await showModalBottomSheet<PetProfile>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => EditPetSheet(pet: appState.pet),
|
||||
);
|
||||
if (value != null) await appState.updatePet(value);
|
||||
}
|
||||
|
||||
Future<void> editVaccines(BuildContext context) async {
|
||||
final value = await showModalBottomSheet<VaccineRecord>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => VaccineSheet(record: appState.vaccines),
|
||||
);
|
||||
if (value != null) await appState.updateVaccines(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pet = appState.pet;
|
||||
final vaccines = appState.vaccines;
|
||||
final progress = vaccines.totalDoses == 0
|
||||
? 0.0
|
||||
: vaccines.completedDoses / vaccines.totalDoses;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => editPet(context),
|
||||
borderRadius: BorderRadius.circular(54),
|
||||
child: RemoteImage(
|
||||
url: pet.avatarUrl,
|
||||
width: 104,
|
||||
height: 104,
|
||||
borderRadius: BorderRadius.circular(52),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: AppColors.primary,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: '编辑资料',
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
color: Colors.white,
|
||||
size: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(Icons.settings_outlined, size: 17),
|
||||
label: const Text('编辑资料'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.monitor_weight_outlined,
|
||||
color: AppColors.primary,
|
||||
value: '${pet.weight.toStringAsFixed(1)}kg',
|
||||
label: '体重',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.vaccines_outlined,
|
||||
color: AppColors.success,
|
||||
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
|
||||
label: '疫苗进度',
|
||||
onTap: () => editVaccines(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.payments_outlined,
|
||||
color: AppColors.warning,
|
||||
value: '¥328',
|
||||
label: '本月花费',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 62,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 7,
|
||||
backgroundColor: const Color(0xFFE2E8F0),
|
||||
color: AppColors.success,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${(progress * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '管理疫苗',
|
||||
onPressed: () => editVaccines(context),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF5),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFA7F3D0)),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: AppColors.success),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
|
||||
style: TextStyle(color: Color(0xFF047857), height: 1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 10),
|
||||
const _TimelineTile(
|
||||
icon: Icons.medical_services_outlined,
|
||||
title: '医疗 · 狂犬疫苗接种',
|
||||
subtitle: '2025-06-12 · 瑞派宠物医院',
|
||||
status: '已完成',
|
||||
),
|
||||
const _TimelineTile(
|
||||
icon: Icons.restaurant_outlined,
|
||||
title: '喂养 · 更换幼犬粮',
|
||||
subtitle: '2025-05-20 · 体重增长稳定',
|
||||
status: '已记录',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String value;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineTile extends StatelessWidget {
|
||||
const _TimelineTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
TagPill(status, color: AppColors.success),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditPetSheet extends StatefulWidget {
|
||||
const EditPetSheet({required this.pet, super.key});
|
||||
|
||||
final PetProfile pet;
|
||||
|
||||
@override
|
||||
State<EditPetSheet> createState() => _EditPetSheetState();
|
||||
}
|
||||
|
||||
class _EditPetSheetState extends State<EditPetSheet> {
|
||||
late final TextEditingController nameController;
|
||||
late final TextEditingController weightController;
|
||||
late String breed;
|
||||
late PetGender gender;
|
||||
late DateTime birthday;
|
||||
late String avatar;
|
||||
|
||||
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
|
||||
static const avatars = [
|
||||
petAvatar,
|
||||
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
|
||||
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
nameController = TextEditingController(text: widget.pet.name);
|
||||
weightController = TextEditingController(text: '${widget.pet.weight}');
|
||||
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
|
||||
gender = widget.pet.gender;
|
||||
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
|
||||
avatar = widget.pet.avatarUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
weightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String dateText(DateTime value) {
|
||||
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
10,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SheetHandle(),
|
||||
Row(
|
||||
children: [
|
||||
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final index = avatars.indexOf(avatar);
|
||||
setState(
|
||||
() => avatar = avatars[(index + 1) % avatars.length],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
child: Stack(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: avatar,
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
),
|
||||
const Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
radius: 15,
|
||||
child: Icon(Icons.edit, size: 15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: '宠物昵称'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: breed,
|
||||
decoration: const InputDecoration(labelText: '品种'),
|
||||
items: breeds
|
||||
.map(
|
||||
(value) =>
|
||||
DropdownMenuItem(value: value, child: Text(value)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => breed = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<PetGender>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: PetGender.male,
|
||||
icon: Icon(Icons.male),
|
||||
label: Text('男孩'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: PetGender.female,
|
||||
icon: Icon(Icons.female),
|
||||
label: Text('女孩'),
|
||||
),
|
||||
],
|
||||
selected: {gender},
|
||||
onSelectionChanged: (value) =>
|
||||
setState(() => gender = value.first),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
leading: const Icon(Icons.cake_outlined),
|
||||
title: const Text('生日'),
|
||||
subtitle: Text(dateText(birthday)),
|
||||
trailing: const Icon(Icons.calendar_month_outlined),
|
||||
onTap: () async {
|
||||
final value = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: birthday,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (value != null) setState(() => birthday = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: weightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(labelText: '体重(kg)'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final weight = double.tryParse(weightController.text);
|
||||
if (name.isEmpty || weight == null || weight <= 0) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
|
||||
return;
|
||||
}
|
||||
Navigator.pop(
|
||||
context,
|
||||
widget.pet.copyWith(
|
||||
name: name,
|
||||
breed: breed,
|
||||
gender: gender,
|
||||
birthday: dateText(birthday),
|
||||
weight: weight,
|
||||
avatarUrl: avatar,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('保存修改'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VaccineSheet extends StatefulWidget {
|
||||
const VaccineSheet({required this.record, super.key});
|
||||
|
||||
final VaccineRecord record;
|
||||
|
||||
@override
|
||||
State<VaccineSheet> createState() => _VaccineSheetState();
|
||||
}
|
||||
|
||||
class _VaccineSheetState extends State<VaccineSheet> {
|
||||
late List<VaccineItem> items;
|
||||
late int totalDoses;
|
||||
late final TextEditingController reminderController;
|
||||
late final TextEditingController dateController;
|
||||
|
||||
int get completed =>
|
||||
items.where((item) => item.status == VaccineStatus.completed).length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
items = List<VaccineItem>.from(widget.record.items);
|
||||
totalDoses = widget.record.totalDoses;
|
||||
reminderController = TextEditingController(
|
||||
text: widget.record.reminderVaccine,
|
||||
);
|
||||
dateController = TextEditingController(text: widget.record.reminderDate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
reminderController.dispose();
|
||||
dateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void changeTotal(int delta) {
|
||||
final minimum = completed > items.length ? completed : items.length;
|
||||
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
10,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SheetHandle(),
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
|
||||
const SizedBox(width: 10),
|
||||
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('已接种 / 总规划'),
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => changeTotal(-1),
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'$completed / $totalDoses',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: () => changeTotal(1),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: progress, minHeight: 9),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final done = item.status == VaccineStatus.completed;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: CheckboxListTile(
|
||||
value: done,
|
||||
title: Text(item.name),
|
||||
subtitle: Text(
|
||||
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
|
||||
),
|
||||
secondary: Icon(
|
||||
done ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: done ? AppColors.success : AppColors.muted,
|
||||
),
|
||||
onChanged: (_) {
|
||||
final today = DateTime.now();
|
||||
final date =
|
||||
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
|
||||
setState(() {
|
||||
items[index] = item.copyWith(
|
||||
status: done
|
||||
? VaccineStatus.pending
|
||||
: VaccineStatus.completed,
|
||||
date: done ? '待定' : date,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: reminderController,
|
||||
decoration: const InputDecoration(labelText: '下一针提醒'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: dateController,
|
||||
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD)'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
VaccineRecord(
|
||||
completedDoses: completed,
|
||||
totalDoses: totalDoses,
|
||||
items: items,
|
||||
reminderVaccine: reminderController.text.trim(),
|
||||
reminderDate: dateController.text.trim(),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('保存疫苗记录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetHandle extends StatelessWidget {
|
||||
const _SheetHandle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class PostDetailPage extends StatefulWidget {
|
||||
const PostDetailPage({
|
||||
required this.appState,
|
||||
required this.postId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
final String postId;
|
||||
|
||||
@override
|
||||
State<PostDetailPage> createState() => _PostDetailPageState();
|
||||
}
|
||||
|
||||
class _PostDetailPageState extends State<PostDetailPage> {
|
||||
final commentController = TextEditingController();
|
||||
bool isFollowing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
PostModel get post =>
|
||||
widget.appState.posts.firstWhere((item) => item.id == widget.postId);
|
||||
|
||||
void toggleLike() {
|
||||
final current = post;
|
||||
widget.appState.updatePost(
|
||||
current.copyWith(
|
||||
hasLiked: !current.hasLiked,
|
||||
likes: current.hasLiked ? current.likes - 1 : current.likes + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendComment() {
|
||||
final text = commentController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final current = post;
|
||||
final comment = CommentModel(
|
||||
id: 'comment_${DateTime.now().millisecondsSinceEpoch}',
|
||||
authorName: '萌宠新手',
|
||||
authorAvatar: userAvatar,
|
||||
content: text,
|
||||
time: '刚刚',
|
||||
);
|
||||
widget.appState.updatePost(
|
||||
current.copyWith(comments: [comment, ...current.comments]),
|
||||
);
|
||||
commentController.clear();
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.appState,
|
||||
builder: (context, _) {
|
||||
final current = post;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'社区动态',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '分享',
|
||||
onPressed: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('分享链接已准备好(演示)'))),
|
||||
icon: const Icon(Icons.ios_share_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: RemoteImage(url: current.mainImage),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: current.authorAvatar,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
current.authorName,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
current.breedTag,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
setState(() => isFollowing = !isFollowing),
|
||||
child: Text(isFollowing ? '已关注' : '关注'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(current.content),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: current.tags
|
||||
.map((tag) => TagPill('#$tag'))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'发布于 ${current.time}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: toggleLike,
|
||||
icon: Icon(
|
||||
current.hasLiked
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: current.hasLiked ? Colors.red : null,
|
||||
),
|
||||
label: Text('${current.likes}'),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: Text('${current.comments.length}'),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => widget.appState.updatePost(
|
||||
current.copyWith(
|
||||
hasBookmarked: !current.hasBookmarked,
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
current.hasBookmarked
|
||||
? Icons.bookmark
|
||||
: Icons.bookmark_border,
|
||||
color: current.hasBookmarked
|
||||
? AppColors.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
'评论 (${current.comments.length})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...current.comments.map(
|
||||
(comment) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: comment.authorAvatar,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SectionCard(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
comment.authorName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(comment.content),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
comment.time,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: commentController,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => sendComment(),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '写下你的评论…',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
tooltip: '发送评论',
|
||||
onPressed: sendComment,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({required this.appState, super.key});
|
||||
|
||||
final AppState appState;
|
||||
|
||||
static const menuItems = [
|
||||
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
|
||||
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
|
||||
(Icons.badge_outlined, '宠物健康卡包', '电子接种证与体检报告'),
|
||||
(Icons.location_on_outlined, '地址与定位管理', '管理家庭住址与常用医院'),
|
||||
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
|
||||
];
|
||||
|
||||
void showDemoMessage(BuildContext context, String name) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('「$name」功能为演示入口')));
|
||||
}
|
||||
|
||||
Future<void> reset(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('恢复演示数据'),
|
||||
content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('确认恢复'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await appState.resetDemoData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('演示数据已恢复')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.ink,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: userAvatar,
|
||||
width: 82,
|
||||
height: 82,
|
||||
borderRadius: BorderRadius.circular(41),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'萌宠新手(豆豆家长)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
'Patbond 社区创作达人',
|
||||
style: TextStyle(color: Color(0xFFA5B4FC), fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(color: Color(0xFF334155)),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
const _ProfileStat(value: '24', label: '关注我'),
|
||||
const _ProfileStat(value: '1.8k', label: '获赞'),
|
||||
_ProfileStat(
|
||||
value: '${appState.posts.length}',
|
||||
label: '我的作品',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
children: menuItems.map((item) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFFEEF2FF),
|
||||
child: Icon(item.$1, color: AppColors.primary),
|
||||
),
|
||||
title: Text(
|
||||
item.$2,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: Text(item.$3),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => showDemoMessage(context, item.$2),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
onPressed: () => reset(context),
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('恢复演示数据'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => showDemoMessage(context, '退出登录'),
|
||||
child: const Text('切换账号或退出登录'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileStat extends StatelessWidget {
|
||||
const _ProfileStat({required this.value, required this.label});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class ServicesPage extends StatefulWidget {
|
||||
const ServicesPage({
|
||||
required this.showPersonal,
|
||||
required this.locationWeather,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool showPersonal;
|
||||
final LocationWeather locationWeather;
|
||||
|
||||
@override
|
||||
State<ServicesPage> createState() => _ServicesPageState();
|
||||
}
|
||||
|
||||
class _ServicesPageState extends State<ServicesPage> {
|
||||
late bool personal;
|
||||
String query = '';
|
||||
String filter = '全部';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
personal = widget.showPersonal;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ServicesPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.showPersonal != widget.showPersonal) {
|
||||
personal = widget.showPersonal;
|
||||
}
|
||||
}
|
||||
|
||||
List<ServiceProviderModel> get providers {
|
||||
final keyword = query.trim().toLowerCase();
|
||||
var list = serviceProviders.where((provider) {
|
||||
final matchesType = personal
|
||||
? provider.kind == ProviderKind.personal
|
||||
: provider.kind != ProviderKind.personal;
|
||||
final matchesQuery =
|
||||
keyword.isEmpty ||
|
||||
provider.name.toLowerCase().contains(keyword) ||
|
||||
provider.description.toLowerCase().contains(keyword) ||
|
||||
provider.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
||||
return matchesType && matchesQuery;
|
||||
}).toList();
|
||||
|
||||
if (!personal) {
|
||||
if (filter == '距离优先') {
|
||||
list.sort(
|
||||
(a, b) => double.parse(
|
||||
a.distance.replaceAll('km', ''),
|
||||
).compareTo(double.parse(b.distance.replaceAll('km', ''))),
|
||||
);
|
||||
} else if (filter == '24H急诊') {
|
||||
list = list.where((item) => item.tags.contains('24H急诊')).toList();
|
||||
} else if (filter == '美容洗护') {
|
||||
list = list
|
||||
.where((item) => item.kind == ProviderKind.grooming)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> book(ServiceProviderModel provider) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认预约'),
|
||||
content: Text('预约 ${provider.name}\n服务价格:¥${provider.price} 起'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('已提交「${provider.name}」预约(演示)')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.apartment_outlined),
|
||||
label: Text('专业机构'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(Icons.person_pin_circle_outlined),
|
||||
label: Text('个人服务'),
|
||||
),
|
||||
],
|
||||
selected: {personal},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (value) => setState(() {
|
||||
personal = value.first;
|
||||
filter = '全部';
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.near_me_outlined,
|
||||
size: 17,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'正在展示 ${widget.locationWeather.displayArea} 附近的服务',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
onChanged: (value) => setState(() => query = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: personal ? '搜索遛狗、寄养或上门喂养…' : '搜索宠物医院、美容或服务…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: '筛选',
|
||||
onPressed: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('可使用下方快捷筛选条件'))),
|
||||
icon: const Icon(Icons.tune),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!personal) ...[
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: ['全部', '距离优先', '24H急诊', '美容洗护']
|
||||
.map(
|
||||
(value) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(value),
|
||||
selected: filter == value,
|
||||
onSelected: (_) => setState(() => filter = value),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (providers.isEmpty)
|
||||
const EmptyState(message: '没有找到符合条件的服务')
|
||||
else
|
||||
...providers.map(
|
||||
(provider) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: _ServiceCard(
|
||||
provider: provider,
|
||||
onBook: () => book(provider),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
const _ServiceCard({required this.provider, required this.onBook});
|
||||
|
||||
final ServiceProviderModel provider;
|
||||
final VoidCallback onBook;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: RemoteImage(url: provider.image),
|
||||
),
|
||||
if (provider.verified)
|
||||
const Positioned(
|
||||
left: 12,
|
||||
top: 12,
|
||||
child: TagPill('认证服务', color: AppColors.success),
|
||||
),
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 10,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(235),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 9,
|
||||
vertical: 5,
|
||||
),
|
||||
child: Text(
|
||||
'⭐ ${provider.rating}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
provider.distance,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(provider.description),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 7,
|
||||
children: provider.tags.map(TagPill.new).toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'¥${provider.price}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const Text(' 起', style: TextStyle(color: AppColors.muted)),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: onBook,
|
||||
icon: const Icon(Icons.calendar_month_outlined, size: 18),
|
||||
label: const Text('立即预约'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,4 +3,4 @@ import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const App());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
class RemoteImage extends StatelessWidget {
|
||||
const RemoteImage({
|
||||
required this.url,
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius = BorderRadius.zero,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Icon(Icons.pets, color: AppColors.muted, size: 34),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionCard extends StatelessWidget {
|
||||
const SectionCard({required this.child, super.key, this.padding});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(18),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TagPill extends StatelessWidget {
|
||||
const TagPill(this.label, {super.key, this.color = AppColors.primary});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({required this.message, super.key});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_off_rounded,
|
||||
size: 42,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
+134
-1
@@ -57,6 +57,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -75,6 +91,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -139,6 +160,102 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.27"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -208,6 +325,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
name: patbond_flutter
|
||||
description: "A new Flutter project."
|
||||
description: "Patbond pet community and health companion."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
@@ -34,6 +34,7 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
shared_preferences: ^2.5.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+13
-21
@@ -1,29 +1,21 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/app/app.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
testWidgets('Patbond renders the main navigation', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(const App());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('Patbond'), findsWidgets);
|
||||
expect(find.text('首页'), findsWidgets);
|
||||
expect(find.text('创作'), findsOneWidget);
|
||||
expect(find.text('档案'), findsOneWidget);
|
||||
expect(find.text('服务'), findsOneWidget);
|
||||
expect(find.text('我的'), findsOneWidget);
|
||||
expect(find.text('北京 · 朝阳区'), findsOneWidget);
|
||||
expect(find.text('28°C 晴'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user