diff --git a/.gitignore b/.gitignore index 3820a95..83eee95 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 8762bdc..b038c2b 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f7da009..c87bd33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + createState() => _AppState(); +} + +class _AppState extends State { + 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), ); } } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..573c62c --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -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, + ); + }), + ), + ); +} diff --git a/lib/data/demo_data.dart b/lib/data/demo_data.dart new file mode 100644 index 0000000..4b22afd --- /dev/null +++ b/lib/data/demo_data.dart @@ -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'), +]; diff --git a/lib/features/create/create_page.dart b/lib/features/create/create_page.dart new file mode 100644 index 0000000..c31fd4e --- /dev/null +++ b/lib/features/create/create_page.dart @@ -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 onPublished; + + @override + State createState() => _CreatePageState(); +} + +class _CreatePageState extends State { + 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 tags = ['可爱修勾', 'AI宠物']; + + @override + void dispose() { + titleController.dispose(); + contentController.dispose(); + super.dispose(); + } + + Future simulateUpload() async { + setState(() => uploading = true); + await Future.delayed(const Duration(milliseconds: 700)); + if (!mounted) return; + setState(() { + uploading = false; + uploaded = true; + resultUrl = null; + }); + } + + Future 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.delayed(const Duration(milliseconds: 650)); + if (!mounted) return; + setState(() => generationStep = step); + } + await Future.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 addTag() async { + final controller = TextEditingController(); + final value = await showDialog( + 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( + 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( + 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 values; + final String selected; + final ValueChanged 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]), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index cc74601..1d18649 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -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 onOpenPost; + final ValueChanged onOpenServices; + final VoidCallback onOpenCreate; + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + HomeSegment segment = HomeSegment.feed; + String query = ''; + String sort = '综合'; + + List 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 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 selectArea() async { + final selected = await showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (context) { + return _AreaPickerSheet(current: widget.appState.locationWeather); + }, + ); + if (selected != null) { + await widget.appState.updateLocationWeather(selected); + } + } + + Future showWeatherDetails() { + final weather = widget.appState.locationWeather; + return showModalBottomSheet( + 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.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( + 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 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), + ], + ), + ), ); } } diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index a78079c..55cd86d 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -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 createState() => _MainShellPageState(); @@ -10,72 +21,156 @@ class MainShellPage extends StatefulWidget { class _MainShellPageState extends State { 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( + 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: '我的', + ), + ], + ), + ); + }, + ); + } } diff --git a/lib/features/pets/pets_page.dart b/lib/features/pets/pets_page.dart new file mode 100644 index 0000000..147688b --- /dev/null +++ b/lib/features/pets/pets_page.dart @@ -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 editPet(BuildContext context) async { + final value = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => EditPetSheet(pet: appState.pet), + ); + if (value != null) await appState.updatePet(value); + } + + Future editVaccines(BuildContext context) async { + final value = await showModalBottomSheet( + 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 createState() => _EditPetSheetState(); +} + +class _EditPetSheetState extends State { + 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( + 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( + 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 createState() => _VaccineSheetState(); +} + +class _VaccineSheetState extends State { + late List 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.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), + ), + ), + ); + } +} diff --git a/lib/features/post/post_detail_page.dart b/lib/features/post/post_detail_page.dart new file mode 100644 index 0000000..c7180df --- /dev/null +++ b/lib/features/post/post_detail_page.dart @@ -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 createState() => _PostDetailPageState(); +} + +class _PostDetailPageState extends State { + 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), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart new file mode 100644 index 0000000..9751640 --- /dev/null +++ b/lib/features/profile/profile_page.dart @@ -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 reset(BuildContext context) async { + final confirmed = await showDialog( + 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), + ), + ], + ); + } +} diff --git a/lib/features/services/services_page.dart b/lib/features/services/services_page.dart new file mode 100644 index 0000000..f4d1333 --- /dev/null +++ b/lib/features/services/services_page.dart @@ -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 createState() => _ServicesPageState(); +} + +class _ServicesPageState extends State { + 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 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 book(ServiceProviderModel provider) async { + final confirmed = await showDialog( + 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( + 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('立即预约'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5c11cdd..7d105ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,4 +3,4 @@ import 'app/app.dart'; void main() { runApp(const App()); -} \ No newline at end of file +} diff --git a/lib/models/models.dart b/lib/models/models.dart new file mode 100644 index 0000000..7ab8886 --- /dev/null +++ b/lib/models/models.dart @@ -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 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 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 tags; + final String avatarUrl; + + PetProfile copyWith({ + String? name, + String? breed, + PetGender? gender, + String? birthday, + double? weight, + List? 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 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.from(json['tags'] as List), + avatarUrl: json['avatarUrl'] as String, + ); + } + + Map 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 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 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 items; + final String reminderVaccine; + final String reminderDate; + + factory VaccineRecord.fromJson(Map 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)) + .toList(), + reminderVaccine: json['reminderVaccine'] as String, + reminderDate: json['reminderDate'] as String, + ); + } + + Map 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 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 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 tags; + final List comments; + final bool hasLiked; + final bool hasBookmarked; + + PostModel copyWith({ + int? likes, + List? 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 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.from(json['tags'] as List), + comments: (json['comments'] as List) + .map((item) => CommentModel.fromJson(item as Map)) + .toList(), + hasLiked: json['hasLiked'] as bool? ?? false, + hasBookmarked: json['hasBookmarked'] as bool? ?? false, + ); + } + + Map 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 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; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart new file mode 100644 index 0000000..96f7468 --- /dev/null +++ b/lib/state/app_state.dart @@ -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 posts = List.from(initialPosts); + LocationWeather locationWeather = initialLocationWeather; + bool isReady = false; + + Future 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); + } + if (savedVaccines != null) { + vaccines = VaccineRecord.fromJson( + jsonDecode(savedVaccines) as Map, + ); + } + if (savedPosts != null) { + posts = (jsonDecode(savedPosts) as List) + .map((item) => PostModel.fromJson(item as Map)) + .toList(); + } + if (savedLocationWeather != null) { + locationWeather = LocationWeather.fromJson( + jsonDecode(savedLocationWeather) as Map, + ); + } + } catch (error, stackTrace) { + debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace'); + pet = initialPet; + vaccines = initialVaccines; + posts = List.from(initialPosts); + locationWeather = initialLocationWeather; + } finally { + isReady = true; + notifyListeners(); + } + } + + Future updatePet(PetProfile value) async { + pet = value; + notifyListeners(); + await _save(_petKey, value.toJson()); + } + + Future updateVaccines(VaccineRecord value) async { + vaccines = value; + notifyListeners(); + await _save(_vaccinesKey, value.toJson()); + } + + Future updatePost(PostModel value) async { + final index = posts.indexWhere((post) => post.id == value.id); + if (index == -1) return; + posts[index] = value; + posts = List.from(posts); + notifyListeners(); + await _savePosts(); + } + + Future publishPost(PostModel value) async { + posts = [value, ...posts]; + notifyListeners(); + await _savePosts(); + } + + Future updateLocationWeather(LocationWeather value) async { + locationWeather = value; + notifyListeners(); + await _save(_locationWeatherKey, value.toJson()); + } + + Future resetDemoData() async { + pet = initialPet; + vaccines = initialVaccines; + posts = List.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 _savePosts() { + return _save(_postsKey, posts.map((post) => post.toJson()).toList()); + } + + Future _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'); + } + } +} diff --git a/lib/widgets/common.dart b/lib/widgets/common.dart new file mode 100644 index 0000000..c3382f6 --- /dev/null +++ b/lib/widgets/common.dart @@ -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), + ], + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 582d01a..f986267 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index fab41a9..61cc510 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/widget_test.dart b/test/widget_test.dart index 2710c37..c869c96 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); }