From 6038901099c4355283d024b64a41eea7dd80e82e Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 17:59:21 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9AMaterial=20?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E7=BB=84=E4=BB=B6=E4=B8=AD=E6=96=87=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8C=82=20zh-CN=20=E4=B8=89=E4=BB=B6?= =?UTF-8?q?=E5=A5=97=20delegate=EF=BC=88M3.5-01=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 Linux 桌面实测反馈「日期选择器全英文」。根因是 MaterialApp 从一迭代 建起就没配 localizationsDelegates/supportedLocales/locale,pubspec 也没有 flutter_localizations;Flutter 在缺 delegate 时静默回退内置的 DefaultMaterialLocalizations(只有英文),于是业务自绘文案全中文、Material 内置组件全英文,同一弹窗里中英混排: Select date → 选择日期 OK / Cancel → 确定 / 取消 Enter Date → 输入日期 Switch to input → 切换到输入模式 Invalid format. / Out of range. → 格式无效。/ 超出范围。 mm/dd/yyyy → yyyy/mm/dd(zh 顺序年在前) September 2026 → 2026年9月 处置: - pubspec 加 flutter_localizations(sdk)与 intl(日期符号底座,显式直接 依赖以锁定与 SDK 一致的版本)。 - 新增 lib/app/app_localization.dart 把 Global{Material,Cupertino,Widgets} 三件套 delegate、supportedLocales、locale 收在一处常量:widget 测试若只 pumpWidget(MaterialApp(home: ...)) 而不挂 delegate,测到的「中文」是假的, 测试直接引用同一份常量,生产与测试不会各配一套而漂移。 - 单语言 zh-CN,不列 en:避免设备语言为英文时回退英文,再造混排。 - pets 域三份 widget 测试的 MaterialApp 同步挂上 delegate, tap(find.text('OK')) 相应改 '确定'——让测试与生产一致,而不是在英文兜底下测。 新增 test/app/app_localization_test.dart 守住这一行不被回删:断言根 MaterialApp 实际挂上三件套 + locale zh-CN,并从运行期 MaterialLocalizations 取回中文文案。 Co-Authored-By: Claude Fable 5 --- lib/app/app.dart | 6 +++ lib/app/app_localization.dart | 31 ++++++++++++ pubspec.lock | 13 +++++ pubspec.yaml | 7 +++ test/app/app_localization_test.dart | 49 +++++++++++++++++++ .../pets/care_reminders_page_test.dart | 8 ++- .../pets/vaccination_form_page_test.dart | 8 ++- .../pets/vaccination_records_page_test.dart | 8 ++- 8 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 lib/app/app_localization.dart create mode 100644 test/app/app_localization_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index fba1db6..b84eef5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -5,6 +5,7 @@ import 'package:patbond_flutter/analytics/analytics_route_observer.dart'; import 'package:patbond_flutter/analytics/analytics_service.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/analytics/session_tracker.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; @@ -268,6 +269,11 @@ class _AppState extends State { title: 'Patbond', debugShowCheckedModeBanner: false, theme: buildAppTheme(), + // M3.5-01:Material 内置组件中文化(此前未配 delegate,日期选择器等 + // 全部回退英文兜底)。单语言 zh-CN,不随设备语言回退英文。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, navigatorObservers: [_routeObserver], home: ListenableBuilder( listenable: sessionManager, diff --git a/lib/app/app_localization.dart b/lib/app/app_localization.dart new file mode 100644 index 0000000..38297fa --- /dev/null +++ b/lib/app/app_localization.dart @@ -0,0 +1,31 @@ +/// 应用本地化配置(M3.5-01)。 +/// +/// 根因备忘:M3 之前 [MaterialApp] 未配任何 `localizationsDelegates`, +/// Flutter 回退到内置的 `DefaultMaterialLocalizations`(仅英文),导致 +/// 一切 Material 内置组件(日期选择器标题「Select date」、确定/取消 +/// 「OK」/「Cancel」、模式切换 tooltip「Switch to input」、格式报错 +/// 「Invalid format.」)全英文,而业务自绘文案全中文——同一弹窗内中英混排。 +/// 挂上三件套后分别为「选择日期 / 确定 / 取消 / 切换到输入模式 / 格式无效。」。 +/// +/// 这里把三件套 delegate 与 locale 收在一处,供 `App` 与 widget 测试共用: +/// 测试若只 `pumpWidget(MaterialApp(home: ...))` 而不带 delegate,看到的仍是 +/// 英文兜底,与真机不一致;需要断言中文渲染的测试请一并挂上本文件的常量。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +/// Material / Cupertino / Widgets 三件套本地化 delegate。 +const List> appLocalizationsDelegates = + >[ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + +/// 简体中文(中国大陆)。M3.5 仅单语言,暂不做语言切换。 +const Locale appLocale = Locale('zh', 'CN'); + +/// 支持语言列表。仅 zh-CN——避免设备语言为英文时回退到英文, +/// 造成同一界面内业务中文 + 组件英文的混排。 +const List appSupportedLocales = [appLocale]; diff --git a/pubspec.lock b/pubspec.lock index f082fbd..8d806d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -235,6 +235,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -399,6 +404,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7d96c66..5337c29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,13 @@ dependencies: image_picker: ^1.2.0 # T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。 flutter_image_compress: ^2.4.0 + # M3.5-01 中文本地化:Material/Cupertino/Widgets 内置组件文案(日期选择器 + # 标题、确定/取消、输入模式提示与格式错误)由 Global*Localizations 提供。 + flutter_localizations: + sdk: flutter + # flutter_localizations 的日期符号/数字格式底座;显式直接依赖以锁定与 SDK + # 一致的版本(避免间接依赖升级悄悄改变格式化行为)。 + intl: ^0.20.2 dev_dependencies: flutter_test: diff --git a/test/app/app_localization_test.dart b/test/app/app_localization_test.dart new file mode 100644 index 0000000..edcee51 --- /dev/null +++ b/test/app/app_localization_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/auth_test_helpers.dart'; +import '../helpers/community_test_helpers.dart'; +import '../helpers/pet_test_helpers.dart'; + +/// M3.5-01:根 [MaterialApp] 实际挂上 zh-CN 本地化。 +/// +/// 用户桌面实测反馈「日期选择器全英文」的根因就在这里——delegate 一行没配, +/// Material 静默回退英文兜底。本测试守住这一行不被回删。 +void main() { + testWidgets('MaterialApp 挂三件套 delegate + 单语言 zh-CN', (tester) async { + SharedPreferences.setMockInitialValues({}); + final session = SessionManager(store: InMemoryTokenStore()) + ..markAuthenticated(); + + await tester.pumpWidget( + App( + sessionManager: session, + authRepository: FakeAuthRepository(), + petsRepository: FakePetsRepository(), + communityRepository: FakeCommunityRepository() + ..onFeed = (_, _) async => feedPage(const []), + ), + ); + await tester.pumpAndSettle(); + + final app = tester.widget(find.byType(MaterialApp)); + expect(app.localizationsDelegates, appLocalizationsDelegates); + expect(app.supportedLocales, appSupportedLocales); + expect(app.locale, const Locale('zh', 'CN')); + + // 运行期实际解析出的 Material 文案为中文(不是 Default* 英文兜底)。 + final context = tester.element(find.byType(NavigationBar)); + final l10n = MaterialLocalizations.of(context); + expect(l10n.okButtonLabel, '确定'); + expect(l10n.cancelButtonLabel, '取消'); + expect(l10n.datePickerHelpText, '选择日期'); + expect(l10n.dateInputLabel, '输入日期'); + expect(l10n.inputDateModeButtonLabel, '切换到输入模式'); + expect(l10n.invalidDateFormatLabel, '格式无效。'); + expect(l10n.formatMonthYear(DateTime(2026, 9, 10)), '2026年9月'); + }); +} diff --git a/test/features/pets/care_reminders_page_test.dart b/test/features/pets/care_reminders_page_test.dart index b32370f..1444011 100644 --- a/test/features/pets/care_reminders_page_test.dart +++ b/test/features/pets/care_reminders_page_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart'; @@ -39,6 +40,11 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate,日期选择器等内置组件文案 + // 才是用户真机看到的中文。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: const Scaffold(body: Text('详情基底')), ), ); @@ -206,7 +212,7 @@ void main() { ); await tester.tap(find.text('到期日期')); await tester.pumpAndSettle(); - await tester.tap(find.text('OK')); + await tester.tap(find.text('确定')); await tester.pumpAndSettle(); await tester.tap(find.text('保存提醒')); await tester.pumpAndSettle(); diff --git a/test/features/pets/vaccination_form_page_test.dart b/test/features/pets/vaccination_form_page_test.dart index c6cec22..09e395a 100644 --- a/test/features/pets/vaccination_form_page_test.dart +++ b/test/features/pets/vaccination_form_page_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; @@ -36,6 +37,11 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate,日期选择器等内置组件文案 + // 才是用户真机看到的中文。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: const Scaffold(body: Text('列表基底')), ), ); @@ -65,7 +71,7 @@ void main() { Future pickDate(WidgetTester tester, String tileLabel) async { await tester.tap(find.text(tileLabel)); await tester.pumpAndSettle(); - await tester.tap(find.text('OK')); + await tester.tap(find.text('确定')); await tester.pumpAndSettle(); } diff --git a/test/features/pets/vaccination_records_page_test.dart b/test/features/pets/vaccination_records_page_test.dart index f790c2b..7b034a4 100644 --- a/test/features/pets/vaccination_records_page_test.dart +++ b/test/features/pets/vaccination_records_page_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; @@ -39,6 +40,11 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate,日期选择器等内置组件文案 + // 才是用户真机看到的中文。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: const Scaffold(body: Text('详情基底')), ), ); @@ -166,7 +172,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('计划接种日期')); await tester.pumpAndSettle(); - await tester.tap(find.text('OK')); + await tester.tap(find.text('确定')); await tester.pumpAndSettle(); await tester.tap(find.text('保存登记')); await tester.pumpAndSettle(); -- 2.43.0 From 294fc4a781677c61a9dba706057baa7630686607 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 17:59:52 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E5=BD=95=E5=85=A5=E6=94=B6=E5=8F=A3=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=B1=82=E2=80=94=E2=80=94=E3=80=8C=E4=BB=8A=E5=A4=A9=E3=80=8D?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=20+=20=E5=93=81=E7=89=8C=E9=85=8D?= =?UTF-8?q?=E8=89=B2=20datePickerTheme=EF=BC=88M3.5-02=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < > 逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。 新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口 (改造后 lib/ 下 showDatePicker 只出现在该文件内部一次): - pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进 [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而 「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒; 中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。 - AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动 隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。 入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」 这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。 两条路都留着最省事。 「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作 (builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog 在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开, 把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。 各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件 不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来), 并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。 顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed 由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范 (iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部 surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong 实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、 越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px (默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。 Co-Authored-By: Claude Fable 5 --- lib/core/theme/app_theme.dart | 100 +++++ lib/core/widgets/app_date_picker.dart | 144 +++++++ .../pets/care_reminder_form_page.dart | 17 +- lib/features/pets/care_reminders_page.dart | 8 +- lib/features/pets/health_event_form_page.dart | 14 +- lib/features/pets/pet_form_page.dart | 14 +- lib/features/pets/vaccination_form_page.dart | 16 +- .../pets/vaccination_records_page.dart | 13 +- lib/features/pets/weight_form_page.dart | 14 +- test/core/widgets/app_date_picker_test.dart | 385 ++++++++++++++++++ .../pets/care_reminder_form_page_test.dart | 40 +- .../pets/health_event_form_page_test.dart | 82 ++++ 12 files changed, 824 insertions(+), 23 deletions(-) create mode 100644 lib/core/widgets/app_date_picker.dart create mode 100644 test/core/widgets/app_date_picker_test.dart diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 7311a09..f87332a 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -176,6 +176,7 @@ ThemeData buildAppTheme() { borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), ), ), + datePickerTheme: _datePickerTheme, navigationBarTheme: NavigationBarThemeData( backgroundColor: AppColors.surface, indicatorColor: AppColors.surfaceTint, @@ -194,3 +195,102 @@ ThemeData buildAppTheme() { ), ); } + +/// 日期选择器主题(M3.5-01)。 +/// +/// 根因备忘:此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed` +/// 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底), +/// 与全 app 品牌色脱节。这里只复用 05 号规范(iteration-2/05、iteration-3/05) +/// 已审计过的色对,不新造色值: +/// +/// | 位置 | 色对 | 对比度 | 来源 | +/// | --- | --- | --- | --- | +/// | 头部(帮助文字 + 标题 + 模式切换图标) | `surfaceTint` 底 + `primaryDark` | 7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7) | +/// | 选中日 / 选中年 | `primaryStrong` 底 + 白字 | 4.49:1 | FAB / 头像徽标同款(同 §2) | +/// | 今日(未选中)描边与文字 | 白底 + `primaryStrong` | 4.49:1 | 同上 | +/// | 未选中日 / 年 | 白底 + `ink` | ≥12:1 | 正文主色 | +/// | 星期表头 | 白底 + `inkSoft` | 6.59:1 | 承载信息的次级文字(§3 DEBT-2) | +/// | 越界不可选日 | 白底 + `muted` | 3.36:1 | 禁用态,DEBT-2 允许的 `muted` 用途 | +/// | 确定 / 取消按钮 | 白底 + `primaryStrong` 文字 | 4.49:1 | 可点击文字链接(§1.1 token 注释) | +final DatePickerThemeData _datePickerTheme = DatePickerThemeData( + backgroundColor: AppColors.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)), + side: BorderSide(color: AppColors.border), + ), + dividerColor: AppColors.border, + headerBackgroundColor: AppColors.surfaceTint, + headerForegroundColor: AppColors.primaryDark, + headerHelpStyle: const TextStyle( + color: AppColors.primaryDark, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + headerHeadlineStyle: const TextStyle( + color: AppColors.primaryDark, + // 22(默认 32 偏大):中文 `formatMediumDate` 是「9月10日周四」5~6 字, + // 横屏侧栏头部宽度下 26 起就会折行,22 一行放得下。 + fontSize: 22, + fontWeight: FontWeight.w800, + ), + // 年份下拉 / 上下月箭头一行:与头部同族深色,白底 8.74:1。 + subHeaderForegroundColor: AppColors.primaryDark, + weekdayStyle: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + dayStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + dayForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.ink; + }), + dayBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + dayOverlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.white24; + return AppColors.primary.withAlpha(31); + }), + todayForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.primaryStrong; + }), + todayBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + // 今日始终带描边(选中态由实底承载,未选中态靠 1.5px 描边定位): + // `primaryStrong` 白底 4.49:1,超过非文字元素 3:1 门槛。 + todayBorder: const BorderSide(color: AppColors.primaryStrong, width: 1.5), + yearStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + yearForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.ink; + }), + yearBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + yearOverlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.white24; + return AppColors.primary.withAlpha(31); + }), + cancelButtonStyle: TextButton.styleFrom( + foregroundColor: AppColors.inkSoft, + minimumSize: const Size(64, 44), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + confirmButtonStyle: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + minimumSize: const Size(64, 44), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), +); diff --git a/lib/core/widgets/app_date_picker.dart b/lib/core/widgets/app_date_picker.dart new file mode 100644 index 0000000..dae5ddb --- /dev/null +++ b/lib/core/widgets/app_date_picker.dart @@ -0,0 +1,144 @@ +/// 日期录入共享层(M3.5-02)。 +/// +/// 根因备忘:全仓 7 处 `showDatePicker` 各写一遍裸调用,用户实测暴露两个问题: +/// +/// 1. **月份只能靠 `< >` 逐月切**。Flutter 原生日历有年份网格、没有月份网格, +/// 从 9 月回到 4 月要点 5 次箭头。已实际导致误录——用户把当月(2026-09) +/// 的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是统计坏了。 +/// 2. **键盘输入模式在英文兜底下不可用**。原生 calendar 模式本就带一枚 +/// 切换到手输的铅笔按钮,但未配本地化时按钮 tooltip 是 +/// `Switch to input`、报错是 `Invalid format.`,中文用户看不懂也不敢用。 +/// M3.5-01 挂上 zh-CN delegate 后变成「切换到输入模式」/「格式无效。」/ +/// 「超出范围。」,输入框提示也从 `mm/dd/yyyy` 变成中文习惯的 +/// `yyyy/mm/dd`,这条路才真正走通。 +/// +/// 本文件的处置: +/// +/// - [pickAppDate] 收口 7 处调用:统一 calendar 首屏 + **保留**手输切换 +/// (明确不用 `calendarOnly`——那恰好会砍掉手输这条快路),并把 +/// `initialDate` 夹进 `[firstDate, lastDate]` 防越界断言。各调用点原有的 +/// 业务约束(如健康事件 `lastDate: now` 不许未来)由调用方原样传入,不改。 +/// - [AppDateFieldTrailing] 给 7 处日期行统一挂「今天」快捷键:绝大多数录入 +/// 就是「记今天的事」,一键落值比开弹窗再找今天更快,也把「月份导航」 +/// 这条容易走错的路整段绕开。原生 `showDatePicker` 无法注入自定义动作 +/// (`builder` 只能包裹整个 Dialog,拿不到它的内部选中态),所以快捷键 +/// 放在调用方表单行而非弹窗内。 +library; + +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 抹掉时分秒,只留年月日(日期选择器返回值与业务日期字段的统一口径)。 +DateTime dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); + +/// 今天(本地时区,仅日期)。 +DateTime today() => dateOnly(DateTime.now()); + +/// 判断 [date] 是否落在 `[firstDate, lastDate]` 闭区间内(按日粒度)。 +bool isDateSelectable({ + required DateTime date, + required DateTime firstDate, + required DateTime lastDate, +}) { + final day = dateOnly(date); + return !day.isBefore(dateOnly(firstDate)) && !day.isAfter(dateOnly(lastDate)); +} + +/// 全仓统一的日期选择入口。 +/// +/// [initialDate] 会被夹进 `[firstDate, lastDate]`——调用方常传「当前值 ?? 今天」, +/// 而部分表单的 `firstDate` 就是今天(如提醒到期日),历史值可能已越界。 +/// +/// 返回 `null` 表示用户取消;返回值已抹掉时分秒。 +Future pickAppDate({ + required BuildContext context, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, +}) async { + final first = dateOnly(firstDate); + final last = dateOnly(lastDate); + var initial = dateOnly(initialDate); + if (initial.isBefore(first)) initial = first; + if (initial.isAfter(last)) initial = last; + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: first, + lastDate: last, + // calendar 首屏 + 头部铅笔按钮切手输:日期不确定时翻日历,日期已知时 + // 一行敲完。不用 calendarOnly(砍掉手输)、不用 input 首屏(多数录入 + // 是「今天/最近几天」,日历一眼可点,手输反而更慢)。 + initialEntryMode: DatePickerEntryMode.calendar, + // 帮助文字/确定/取消/手输提示与格式报错全部交给 zh-CN 本地化 + // (appLocalizationsDelegates),不在此硬编码中文,避免两处文案漂移。 + ); + return picked == null ? null : dateOnly(picked); +} + +/// 日期行尾部(7 处日期 [ListTile] 统一形态):「今天」快捷键 + 日历图标。 +/// +/// - 「今天」直接把字段落到今天,不开弹窗;今天越界(业务不允许)时按钮 +/// 自动隐藏,只留日历图标。 +/// - 触控目标 44×44(项目最小触控口径),文字 `primaryStrong` 白底 4.49:1。 +class AppDateFieldTrailing extends StatelessWidget { + const AppDateFieldTrailing({ + required this.firstDate, + required this.lastDate, + required this.onToday, + super.key, + this.enabled = true, + }); + + /// 业务允许的最早日期(与 [pickAppDate] 同一约束)。 + final DateTime firstDate; + + /// 业务允许的最晚日期。 + final DateTime lastDate; + + /// 点「今天」的回调,入参为今天(仅日期)。 + final ValueChanged onToday; + + /// 表单提交中等禁用态。 + final bool enabled; + + @override + Widget build(BuildContext context) { + final now = today(); + final showToday = isDateSelectable( + date: now, + firstDate: firstDate, + lastDate: lastDate, + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showToday) + Tooltip( + message: '设为今天', + child: TextButton( + onPressed: enabled ? () => onToday(now) : null, + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + disabledForegroundColor: AppColors.muted, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + child: const Text('今天'), + ), + ), + const Icon( + Icons.calendar_month_outlined, + size: 20, + color: AppColors.muted, + ), + ], + ); + } +} diff --git a/lib/features/pets/care_reminder_form_page.dart b/lib/features/pets/care_reminder_form_page.dart index ead3a1b..8fd3dbe 100644 --- a/lib/features/pets/care_reminder_form_page.dart +++ b/lib/features/pets/care_reminder_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -265,14 +266,22 @@ class _CareReminderFormPageState extends State { _dueDate == null ? '未选择' : dateToJson(_dueDate!), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime.now(), + lastDate: DateTime(DateTime.now().year + 5), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() { + _dueDate = value; + _dueError = null; + }); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _dueDate ?? now, firstDate: now, diff --git a/lib/features/pets/care_reminders_page.dart b/lib/features/pets/care_reminders_page.dart index 1de52f5..81f6961 100644 --- a/lib/features/pets/care_reminders_page.dart +++ b/lib/features/pets/care_reminders_page.dart @@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; @@ -484,7 +485,7 @@ class _CompleteDialogState extends State<_CompleteDialog> { ), onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _date, firstDate: DateTime(1990), @@ -494,6 +495,11 @@ class _CompleteDialogState extends State<_CompleteDialog> { setState(() => _date = value); } }, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + onToday: (value) => setState(() => _date = value), + ), ), ], ), diff --git a/lib/features/pets/health_event_form_page.dart b/lib/features/pets/health_event_form_page.dart index baec899..446303d 100644 --- a/lib/features/pets/health_event_form_page.dart +++ b/lib/features/pets/health_event_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -269,14 +270,19 @@ class _HealthEventFormPageState extends State { dateToJson(_occurredDate), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _occurredDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _occurredDate, firstDate: DateTime(1990), diff --git a/lib/features/pets/pet_form_page.dart b/lib/features/pets/pet_form_page.dart index 8c9632f..febab81 100644 --- a/lib/features/pets/pet_form_page.dart +++ b/lib/features/pets/pet_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; @@ -659,14 +660,19 @@ class _PetFormPageState extends State { _birthDate == null ? '未填写' : dateToJson(_birthDate!), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _birthDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _birthDate ?? DateTime(now.year - 1, now.month), firstDate: DateTime(1990), diff --git a/lib/features/pets/vaccination_form_page.dart b/lib/features/pets/vaccination_form_page.dart index 4e3ae07..78f914c 100644 --- a/lib/features/pets/vaccination_form_page.dart +++ b/lib/features/pets/vaccination_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -503,14 +504,21 @@ class _VaccinationFormPageState extends State { value == null ? '未选择' : dateToJson(value), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: allowFuture + ? DateTime(DateTime.now().year + 5) + : DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + onPicked(value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final picked = await showDatePicker( + final picked = await pickAppDate( context: context, initialDate: value ?? now, firstDate: DateTime(1990), diff --git a/lib/features/pets/vaccination_records_page.dart b/lib/features/pets/vaccination_records_page.dart index b9c3521..c1219f1 100644 --- a/lib/features/pets/vaccination_records_page.dart +++ b/lib/features/pets/vaccination_records_page.dart @@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; @@ -514,9 +515,19 @@ class _CompleteVaccinationDialogState value == null ? '未选择' : dateToJson(value), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: allowFuture + ? DateTime(DateTime.now().year + 5) + : DateTime.now(), + onToday: (picked) => setState(() { + onPicked(picked); + _dateError = null; + }), + ), onTap: () async { final now = DateTime.now(); - final picked = await showDatePicker( + final picked = await pickAppDate( context: context, initialDate: value ?? now, firstDate: DateTime(1990), diff --git a/lib/features/pets/weight_form_page.dart b/lib/features/pets/weight_form_page.dart index ee6af84..8e8cd94 100644 --- a/lib/features/pets/weight_form_page.dart +++ b/lib/features/pets/weight_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -241,14 +242,19 @@ class _WeightFormPageState extends State { dateToJson(_measuredDate), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _measuredDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _measuredDate, firstDate: DateTime(1990), diff --git a/test/core/widgets/app_date_picker_test.dart b/test/core/widgets/app_date_picker_test.dart new file mode 100644 index 0000000..04f81d8 --- /dev/null +++ b/test/core/widgets/app_date_picker_test.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; + +/// M3.5-01/02:日期录入共享层。 +/// +/// 覆盖三条用户实测反馈:中文文案实际渲染、键盘输入模式可用、「今天」快捷键。 +void main() { + /// 挂真实 app 配置(主题 + zh-CN delegate)的宿主:不挂 delegate 的话 + /// Material 会回退英文兜底,测出来的「中文」是假的。 + Widget host(Widget child) => MaterialApp( + theme: buildAppTheme(), + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, + home: Scaffold(body: child), + ); + + group('dateOnly / today / isDateSelectable', () { + test('dateOnly 抹掉时分秒', () { + expect( + dateOnly(DateTime(2026, 4, 9, 23, 59, 58, 777)), + DateTime(2026, 4, 9), + ); + }); + + test('today 为本地当天零点', () { + final now = DateTime.now(); + expect(today(), DateTime(now.year, now.month, now.day)); + }); + + test('isDateSelectable 闭区间按日粒度判定', () { + final first = DateTime(2026, 9, 1); + final last = DateTime(2026, 9, 30, 8, 30); + expect( + isDateSelectable( + date: DateTime(2026, 9, 1), + firstDate: first, + lastDate: last, + ), + isTrue, + ); + // 边界当天含时分也算落在区间内(lastDate 的时分不该把当天挤出去)。 + expect( + isDateSelectable( + date: DateTime(2026, 9, 30, 23, 0), + firstDate: first, + lastDate: last, + ), + isTrue, + ); + expect( + isDateSelectable( + date: DateTime(2026, 8, 31), + firstDate: first, + lastDate: last, + ), + isFalse, + ); + expect( + isDateSelectable( + date: DateTime(2026, 10, 1), + firstDate: first, + lastDate: last, + ), + isFalse, + ); + }); + }); + + group('pickAppDate · 中文本地化实际渲染', () { + testWidgets('日历模式:标题/确定/取消全中文(此前为 Select date / OK / Cancel)', ( + tester, + ) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + expect(find.text('选择日期'), findsOneWidget); + expect(find.text('确定'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + expect(find.text('Select date'), findsNothing); + expect(find.text('OK'), findsNothing); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('保留手输切换按钮(不用 calendarOnly),切换后提示与标签为中文', (tester) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + // calendarOnly 会砍掉这枚铅笔按钮——手输是「录一个已知日期」的快路, + // 必须留着。 + final toggle = find.byIcon(Icons.edit_outlined); + expect(toggle, findsOneWidget); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + expect(find.text('输入日期'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + // 手输提示/标签由 zh-CN 本地化提供(不硬编码),不再是 mm/dd/yyyy。 + expect(field.decoration!.labelText, isNot(contains('Date'))); + expect(field.decoration!.hintText, isNot('mm/dd/yyyy')); + }); + + testWidgets('手输模式敲入已知日期即可返回(无需逐月点箭头)', (tester) async { + DateTime? picked; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + picked = await pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '2026/04/09'); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(picked, DateTime(2026, 4, 9)); + }); + + testWidgets('取消返回 null;确认返回值已抹掉时分秒', (tester) async { + DateTime? picked; + var calls = 0; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + calls++; + picked = await pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10, 15, 30), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(picked, isNull); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(picked, DateTime(2026, 9, 10)); + }); + + testWidgets('initialDate 越界自动夹进 [firstDate, lastDate](不触发原生断言)', ( + tester, + ) async { + DateTime? picked; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + // 历史值早于 firstDate:如「提醒到期日」firstDate 是今天, + // 而表单预填的是一个已过期的旧值。 + picked = await pickAppDate( + context: context, + initialDate: DateTime(2020, 1, 1), + firstDate: DateTime(2026, 9, 1), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(picked, DateTime(2026, 9, 1)); + }); + }); + + group('AppDateFieldTrailing · 「今天」快捷键', () { + testWidgets('点「今天」直接回调今天,不开弹窗', (tester) async { + DateTime? got; + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + onToday: (value) => got = value, + ), + ), + ); + + expect(find.text('今天'), findsOneWidget); + expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget); + + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(got, today()); + // 快捷键不经日期选择器(少两次点击、绕开月份导航)。 + expect(find.text('选择日期'), findsNothing); + }); + + testWidgets('今天越界时隐藏按钮,只留日历图标', (tester) async { + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(1999, 12, 31), + onToday: (_) {}, + ), + ), + ); + + expect(find.text('今天'), findsNothing); + expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget); + }); + + testWidgets('enabled=false(提交中)按钮禁用', (tester) async { + var calls = 0; + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + enabled: false, + onToday: (_) => calls++, + ), + ), + ); + + expect( + tester.widget(find.byType(TextButton)).onPressed, + isNull, + ); + await tester.tap(find.text('今天'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(calls, 0); + }); + + testWidgets('触控目标不小于 44×44', (tester) async { + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + onToday: (_) {}, + ), + ), + ); + + final size = tester.getSize(find.byType(TextButton)); + expect(size.width, greaterThanOrEqualTo(44)); + expect(size.height, greaterThanOrEqualTo(44)); + }); + }); + + group('datePickerTheme · 品牌配色(不再是 fromSeed 的暗红棕)', () { + test('选中日/选中年为 primaryStrong 实底白字,今日 primaryStrong 描边', () { + final theme = buildAppTheme().datePickerTheme; + const selected = {WidgetState.selected}; + + expect( + theme.dayBackgroundColor!.resolve(selected), + AppColors.primaryStrong, + ); + expect(theme.dayForegroundColor!.resolve(selected), Colors.white); + expect( + theme.yearBackgroundColor!.resolve(selected), + AppColors.primaryStrong, + ); + expect(theme.yearForegroundColor!.resolve(selected), Colors.white); + expect(theme.todayBorder!.color, AppColors.primaryStrong); + expect( + theme.todayForegroundColor!.resolve(const {}), + AppColors.primaryStrong, + ); + // 头部沿用「选中 chip」既有色对:surfaceTint 底 + primaryDark 字 7.98:1。 + expect(theme.headerBackgroundColor, AppColors.surfaceTint); + expect(theme.headerForegroundColor, AppColors.primaryDark); + expect(theme.backgroundColor, AppColors.surface); + // 越界不可选日走禁用态 muted(DEBT-2 允许的 muted 用途)。 + expect( + theme.dayForegroundColor!.resolve(const {WidgetState.disabled}), + AppColors.muted, + ); + }); + + testWidgets('实际渲染:选中日圆底取 primaryStrong', (tester) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + // 选中日的圆底由 Ink(decoration: ShapeDecoration) 承载,色值取自 + // datePickerTheme.dayBackgroundColor / todayBackgroundColor。 + final inkColors = tester + .widgetList(find.byType(Ink)) + .map((ink) => ink.decoration) + .whereType() + .map((d) => d.color) + .toList(); + expect( + inkColors, + contains(AppColors.primaryStrong), + reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)', + ); + }); + }); +} diff --git a/test/features/pets/care_reminder_form_page_test.dart b/test/features/pets/care_reminder_form_page_test.dart index 4d479da..8709533 100644 --- a/test/features/pets/care_reminder_form_page_test.dart +++ b/test/features/pets/care_reminder_form_page_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; @@ -34,6 +36,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: Builder( builder: (context) => Scaffold( body: Center( @@ -67,7 +73,7 @@ void main() { ); await tester.tap(find.text('到期日期')); await tester.pumpAndSettle(); - await tester.tap(find.text('OK')); + await tester.tap(find.text('确定')); await tester.pumpAndSettle(); } @@ -124,4 +130,36 @@ void main() { expect(failed[1]!['failureReason'], 'network_error'); expect(failed[1]!['attemptSeq'], 2); }); + + group('M3.5-02 · 到期日期录入(firstDate 就是今天的那一格)', () { + testWidgets('未选日期时也有「今天」快捷键;一键落今天并清掉必填校验错', (tester) async { + await pumpForm(tester); + + // 先触发必填校验错。 + await tester.tap(find.text('保存提醒')); + await tester.pumpAndSettle(); + expect(find.text('未选择'), findsOneWidget); + + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(find.text(dateToJson(DateTime.now())), findsOneWidget); + expect(find.text('未选择'), findsNothing); + expect(find.text('选择日期'), findsNothing); + }); + + testWidgets('业务约束不变:firstDate 今天(不许补记过去)、lastDate 五年后', (tester) async { + await pumpForm(tester); + + await tester.tap(find.widgetWithText(ListTile, '到期日期')); + await tester.pumpAndSettle(); + + final dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, dateOnly(DateTime.now())); + expect(dialog.lastDate, DateTime(DateTime.now().year + 5)); + expect(find.text('选择日期'), findsOneWidget); + }); + }); } diff --git a/test/features/pets/health_event_form_page_test.dart b/test/features/pets/health_event_form_page_test.dart index 9f0d920..e48cd91 100644 --- a/test/features/pets/health_event_form_page_test.dart +++ b/test/features/pets/health_event_form_page_test.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/features/pets/health_event_form_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; @@ -34,6 +38,11 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate,否则测里的日期选择器是 + // 英文兜底,测不出用户真机看到的东西。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: Builder( builder: (context) => Scaffold( body: Center( @@ -192,4 +201,77 @@ void main() { expect(failed[2]!['failureReason'], 'network_error'); expect(failed[2]!['attemptSeq'], 3); }); + + group('M3.5-02 · 日期录入(用户误录 2026-04 的那一格)', () { + testWidgets('点「今天」直接落今天,不开弹窗;同时算首次输入触发 started', (tester) async { + await pumpForm(tester); + + final expected = dateToJson(DateTime.now()); + // 先把日期改成远月,模拟用户翻错月份后的状态。 + await tester.tap(find.widgetWithText(ListTile, '发生日期')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.byType(TextField), + ), + '2026/04/09', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(find.text('2026-04-09'), findsOneWidget); + + // 一键回今天:不经日期选择器。 + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(find.text(expected), findsOneWidget); + expect(find.text('选择日期'), findsNothing); + expect(eventsOf('health_record_create_started'), hasLength(1)); + }); + + testWidgets('选择器为中文,且业务约束不变(firstDate 1990 / lastDate 今天,不许未来)', ( + tester, + ) async { + await pumpForm(tester); + + await tester.tap(find.widgetWithText(ListTile, '发生日期')); + await tester.pumpAndSettle(); + + expect(find.text('选择日期'), findsOneWidget); + expect(find.text('确定'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + // 手输快路可达(原生 calendarOnly 会砍掉它)。 + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + + final dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, DateTime(1990)); + expect(dialog.lastDate, dateOnly(DateTime.now())); + }); + + testWidgets('提交中禁用「今天」快捷键', (tester) async { + final gate = Completer(); + repository.createHealthEventHandler = (petId, req) => gate.future; + await pumpForm(tester); + await fillValid(tester); + + await tester.tap(find.text('保存记录')); + await tester.pump(); + + expect( + tester + .widget(find.widgetWithText(TextButton, '今天')) + .onPressed, + isNull, + ); + + gate.complete(buildHealthEvent('he-1')); + await tester.pumpAndSettle(); + }); + }); } -- 2.43.0 From 7d5c84d06dd052089c120709ddccf90c2b1d8638 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 18:00:21 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=8A=B1?= =?UTF-8?q?=E8=B4=B9=E5=8D=A1=E5=B1=95=E7=A4=BA=E5=AE=9E=E9=99=85=E6=9C=88?= =?UTF-8?q?=E4=BB=BD=20+=20=E5=9B=9B=E5=BC=A0=E6=95=B0=E6=8D=AE=E5=8D=A1?= =?UTF-8?q?=E8=A1=A5=E5=8F=AF=E7=82=B9=E6=8F=90=E7=A4=BA=EF=BC=88M3.5-03?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回 month (ISO year-month,按 tz 归月)。用户看不到实际月份,所以无法自证「我这条记录 到底落在哪个月」,把正确的 ¥0 当成统计故障。 已核实不改后端聚合:用户记录落在 2026-04-09、当天是 2026-09-10, 「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。 - health_record_display 新增纯函数 monthlyExpenseCardLabel(month, {now}): 同年「9 月花费」(窄卡一行放得下,故不取「2026-09 花费」);跨年(服务端 归月年份 ≠ 设备当前年份)「2026/12 花费」补年份消歧;串非法退回 「本月花费」不崩不显示脏值。 - 可点提示:_SummaryCard 在 onTap 非空时右上角补 chevron_right。四张卡本都 可点进明细页却无任何视觉提示(用户反馈不知道能点),提示形态沿用项目既有 可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 chevron_right,不自创。 - 整卡包 MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」而非两段 孤立文字。没有用 excludeSemantics——那会连带丢掉 InkWell 的可激活性。 既有测试口径调整:pet_detail_page_test 两处断言改为实际月份,且样本 monthlyExpense.month 改用当月串使断言不随年份漂移(跨年格式由纯函数单测覆盖)。 新增 integration_test/client_ux_live_test.dart(环境变量门控,默认 skip, 沿用 M3 既有 live 测试形态):compose 六容器 + 真实 App 走完三项修复—— 日期选择器全中文/品牌配色/手输录入/一键今天,以及花费卡实际月份 + chevron。 本机是 Wayland 会话、X11 import -window root 取不到根窗口,改为把整棵 App 包 一层 RepaintBoundary 后 toImage() 直出真实渲染像素落 build/ux-live/。 Co-Authored-By: Claude Fable 5 --- integration_test/client_ux_live_test.dart | 243 ++++++++++++++++++ lib/features/pets/health_record_display.dart | 21 ++ lib/features/pets/pet_detail_page.dart | 83 +++--- .../pets/health_record_display_test.dart | 26 ++ test/features/pets/pet_detail_page_test.dart | 67 ++++- 5 files changed, 405 insertions(+), 35 deletions(-) create mode 100644 integration_test/client_ux_live_test.dart diff --git a/integration_test/client_ux_live_test.dart b/integration_test/client_ux_live_test.dart new file mode 100644 index 0000000..9e1fbcf --- /dev/null +++ b/integration_test/client_ux_live_test.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show ImageByteFormat; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:patbond_flutter/app/app.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; +import 'package:patbond_flutter/features/pets/pet_form_page.dart'; + +/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。 +class _InMemoryTokenStore implements TokenStore { + final Map _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async => _values[key] = value; + + @override + Future delete(String key) async => _values.remove(key); +} + +/// M3.5-01/02/03 compose 真链路桌面实测(默认跳过,不计入常规测试套件): +/// +/// ```bash +/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再: +/// PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux +/// ``` +/// +/// 驱动**真实 App**(Linux 桌面 GTK 渲染管线 + 真实 HTTP)走用户实测那条路: +/// 注册 → UI 登录 → 档案页建档 → 打开生日选择器(校验全中文 + 品牌配色 + +/// 手输可用 + 一键今天)→ 保存进详情页(校验花费卡展示实际月份 + chevron)。 +/// 逐步截图落到 `build/ux-live/`:本机是 Wayland 会话,X11 的 +/// `import -window root` 取不到根窗口,改为把整棵 App 包一层 +/// [RepaintBoundary] 后 `toImage()` 直出真实渲染像素(含 Overlay 里的弹窗)。 +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + final enabled = Platform.environment['PATBOND_UX_LIVE'] == '1'; + const authBase = 'http://127.0.0.1:8081'; + const petBase = 'http://127.0.0.1:8083'; + final shotDir = Directory('build/ux-live'); + const rootKey = ValueKey('ux-live-root'); + + Future shot(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + find.byKey(rootKey), + ); + await tester.runAsync(() async { + if (!shotDir.existsSync()) shotDir.createSync(recursive: true); + final image = await boundary.toImage(pixelRatio: 1.5); + final bytes = await image.toByteData(format: ImageByteFormat.png); + final path = '${shotDir.path}/$name.png'; + File(path).writeAsBytesSync(bytes!.buffer.asUint8List()); + debugPrint('[shot] $name → $path (${bytes.lengthInBytes} bytes)'); + }); + } + + Future pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 25), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isNotEmpty) return; + } + fail('等待超时:$finder'); + } + + Future pumpUntilGone( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 25), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isEmpty) return; + } + fail('等待消失超时:$finder'); + } + + /// 直连服务端下单个 JSON 请求(种子数据走真实 HTTP,不绕 DB)。 + Future> postJson( + String url, + Map body, { + String? bearer, + }) async { + final client = HttpClient(); + final request = await client.postUrl(Uri.parse(url)); + request.headers.contentType = ContentType.json; + if (bearer != null) { + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer'); + } + request.add(utf8.encode(jsonEncode(body))); + final response = await request.close(); + final text = await response.transform(utf8.decoder).join(); + client.close(); + expect(response.statusCode, anyOf(200, 201), reason: 'POST $url 失败:$text'); + final envelope = jsonDecode(text) as Map; + expect(envelope['code'], 0, reason: 'POST $url 业务码非 0:$text'); + return envelope['data'] as Map; + } + + testWidgets('桌面真链路:日期选择器中文/品牌色/手输/今天 + 花费卡实际月份', (tester) async { + // ---- 注册一次性账号 + 种子数据(宠物 + 当月一笔就医支出)---- + final seed = DateTime.now().millisecondsSinceEpoch; + final username = 'uxlive$seed'; + final password = 'Live1234!$seed'; + await postJson('$authBase/api/v1/auth/register', { + 'username': username, + 'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}', + 'password': password, + }); + final tokens = await postJson('$authBase/api/v1/auth/login', { + 'username': username, + 'password': password, + }); + final accessToken = tokens['accessToken'] as String; + final pet = await postJson('$petBase/api/v1/pets', { + 'name': '实测豆豆', + 'species': 'dog', + 'sex': 'male', + 'customBreedName': '中华田园犬', + }, bearer: accessToken); + final petId = pet['id'] as String; + // 当月一笔 128.50 元就医支出:花费卡应显示「N 月花费 ¥128.50」—— + // 用户那次误录(记到 4 月)看到 ¥0 正是因为记录不在当月窗口内。 + await postJson('$petBase/api/v1/pets/$petId/health-events', { + 'eventType': 'medical', + 'occurredAt': DateTime.now().toUtc().toIso8601String(), + 'title': '皮肤检查', + 'amountCents': 12850, + }, bearer: accessToken); + + // ---- 启动真实 App ---- + await tester.pumpWidget( + RepaintBoundary( + key: rootKey, + child: App( + sessionManager: SessionManager(store: _InMemoryTokenStore()), + ), + ), + ); + await pumpUntil(tester, find.text('登录')); + + await tester.enterText(find.byType(TextField).at(0), username); + await tester.enterText(find.byType(TextField).at(1), password); + await tester.tap(find.text('登录')); + + // ---- 档案 Tab → 建档表单 ---- + await pumpUntil(tester, find.text('档案')); + await tester.tap(find.text('档案')); + // 列表已有种子宠物 → 走虚线「+ 添加宠物」卡(空态 CTA 文案不同)。 + await pumpUntil(tester, find.textContaining('添加宠物')); + await tester.tap(find.textContaining('添加宠物').first); + await pumpUntil(tester, find.byType(PetFormPage)); + await shot(tester, '01-pet-form'); + + // ---- 生日日期选择器:全中文 + 品牌配色 ---- + await tester.tap(find.widgetWithText(ListTile, '生日(可选)')); + await pumpUntil(tester, find.byType(DatePickerDialog)); + await tester.pump(const Duration(milliseconds: 600)); + await shot(tester, '02-date-picker-zh'); + + expect(find.text('选择日期'), findsOneWidget, reason: '标题非中文'); + expect(find.text('确定'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + expect(find.text('Select date'), findsNothing); + expect(find.text('OK'), findsNothing); + + final inkColors = tester + .widgetList(find.byType(Ink)) + .map((ink) => ink.decoration) + .whereType() + .map((d) => d.color) + .toList(); + expect( + inkColors, + contains(AppColors.primaryStrong), + reason: '选中日仍是 fromSeed 派生的暗红棕,未取品牌 primaryStrong', + ); + + // ---- 手输模式:中文标签 + 敲入已知日期 ---- + await tester.tap(find.byIcon(Icons.edit_outlined)); + await pumpUntil(tester, find.text('输入日期')); + await shot(tester, '03-date-input-zh'); + final field = find.descendant( + of: find.byType(DatePickerDialog), + matching: find.byType(TextField), + ); + await tester.enterText(field, '2024/03/15'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('确定')); + await pumpUntil(tester, find.text('2024-03-15')); + await shot(tester, '04-date-typed'); + + // ---- 「今天」快捷键:一键落今天,不开弹窗 ---- + final todayText = + '${DateTime.now().year}-' + '${DateTime.now().month.toString().padLeft(2, '0')}-' + '${DateTime.now().day.toString().padLeft(2, '0')}'; + await tester.tap(find.widgetWithText(TextButton, '今天')); + await pumpUntil(tester, find.text(todayText)); + expect(find.byType(DatePickerDialog), findsNothing, reason: '「今天」不该开弹窗'); + await shot(tester, '05-today-shortcut'); + + // ---- 退出建档表单(本单不验建档链路,宠物与支出已由 API 种下)---- + await tester.tap(find.byIcon(Icons.arrow_back)); + await pumpUntilGone(tester, find.byType(PetFormPage)); + + // ---- 详情页:花费卡展示实际月份 + chevron 可点提示 ---- + await pumpUntil(tester, find.text('实测豆豆')); + await shot(tester, '06-pets-list'); + await tester.tap(find.text('实测豆豆')); + await pumpUntil(tester, find.byType(PetDetailPage)); + final monthLabel = '${DateTime.now().month} 月花费'; + await pumpUntil(tester, find.text(monthLabel)); + await tester.pump(const Duration(milliseconds: 600)); + await shot(tester, '07-pet-detail-expense-card'); + + expect(find.text('本月花费'), findsNothing, reason: '仍是硬编码「本月花费」'); + // 当月真有支出 → 卡片给出金额(月份口径自证:记录落在当月才计入)。 + expect(find.text('¥128.50'), findsOneWidget); + expect( + find.ancestor( + of: find.text(monthLabel), + matching: find.widgetWithIcon(Card, Icons.chevron_right), + ), + findsOneWidget, + reason: '花费卡缺 chevron 可点提示', + ); + }, skip: !enabled); +} diff --git a/lib/features/pets/health_record_display.dart b/lib/features/pets/health_record_display.dart index d656cf5..c00b9a9 100644 --- a/lib/features/pets/health_record_display.dart +++ b/lib/features/pets/health_record_display.dart @@ -125,6 +125,27 @@ String healthEventMonthHeader(DateTime occurredAt) { return '${local.year} 年 ${local.month} 月'; } +/// 月度花费卡标签(M3.5-03)。 +/// +/// 根因备忘:卡片标签此前硬编码「本月花费」,用户无法自证「本月」到底是 +/// 哪个月——他把当月记录误录到 2026-04 后看到「本月花费 ¥0」,以为聚合坏了。 +/// 服务端 `summary.monthlyExpense.month` 本就返回 ISO year-month(如 `2026-09`, +/// 按 `tz` 归月),直接展示即可自查,无需改后端。 +/// +/// [month] 形如 `2026-09`。窄卡(一行四卡)只放得下 4~5 个字,故取「9 月花费」 +/// 而非「2026-09 花费」;跨年(服务端归月的年份与设备当前年份不一致,如设备 +/// 已跨到 1 月而窗口仍是去年 12 月)时补年份消歧。解析失败退回「本月花费」。 +String monthlyExpenseCardLabel(String month, {DateTime? now}) { + final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month); + if (match == null) return '本月花费'; + final year = int.parse(match.group(1)!); + final monthNo = int.parse(match.group(2)!); + if (monthNo < 1 || monthNo > 12) return '本月花费'; + final currentYear = (now ?? DateTime.now()).year; + if (year != currentYear) return '$year/$monthNo 花费'; + return '$monthNo 月花费'; +} + /// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`; /// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。 String tzOffsetQueryValue(Duration offset) { diff --git a/lib/features/pets/pet_detail_page.dart b/lib/features/pets/pet_detail_page.dart index 6849886..4be2788 100644 --- a/lib/features/pets/pet_detail_page.dart +++ b/lib/features/pets/pet_detail_page.dart @@ -532,7 +532,9 @@ class _PetDetailPageState extends State { // monthlyExpense 恒非 null(契约);金额整数分 → 元展示。 value: '¥${formatCentsAsYuan(expense.amountCents)}', emphasized: expense.amountCents > 0, - label: '本月花费', + // M3.5-03:标签展示服务端归月的实际月份(此前硬编码 + // 「本月花费」,用户无法自证记录落在哪个月)。 + label: monthlyExpenseCardLabel(expense.month), onTap: () => _openTimeline(pet), ), ), @@ -545,6 +547,13 @@ class _PetDetailPageState extends State { /// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft; /// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。 +/// +/// M3.5-03:可点卡([onTap] 非空)右上角补 `chevron_right`——四张卡本都可点进 +/// 明细页,但此前无任何视觉提示,用户实测反馈不知道能点。提示形态沿用项目 +/// 既有可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 `chevron_right`, +/// 不自创。同时 [MergeSemantics] 把「数值 + 标签」并进 InkWell 的 button 节点, +/// 读屏一次读全「¥0,9 月花费,按钮」,而不是两段孤立文字(tap 动作仍在 +/// InkWell 上,不用 `excludeSemantics` 以免连带丢掉可激活性)。 class _SummaryCard extends StatelessWidget { const _SummaryCard({ required this.icon, @@ -564,35 +573,51 @@ class _SummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.xl), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 18, color: iconColor), - const SizedBox(height: 8), - Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: emphasized ? AppColors.ink : AppColors.inkSoft, - fontSize: emphasized ? 15 : 13, - fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, + return MergeSemantics( + child: Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: iconColor), + const Spacer(), + if (onTap != null) + const Icon( + Icons.chevron_right, + size: 16, + color: AppColors.muted, + ), + ], ), - ), - const SizedBox(height: 4), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.inkSoft, fontSize: 11), - ), - ], + const SizedBox(height: 8), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: emphasized ? AppColors.ink : AppColors.inkSoft, + fontSize: emphasized ? 15 : 13, + fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 11, + ), + ), + ], + ), ), ), ), diff --git a/test/features/pets/health_record_display_test.dart b/test/features/pets/health_record_display_test.dart index 167b94e..604e4da 100644 --- a/test/features/pets/health_record_display_test.dart +++ b/test/features/pets/health_record_display_test.dart @@ -277,4 +277,30 @@ void main() { expect(reminderStatusColor(dismissed, now), AppColors.muted); }); }); + + group('M3.5-03 · monthlyExpenseCardLabel(花费卡展示实际月份)', () { + test('同年只给月份,卡片一行放得下(不再硬编码「本月花费」)', () { + final now = DateTime(2026, 9, 10); + expect(monthlyExpenseCardLabel('2026-09', now: now), '9 月花费'); + // 用户误录场景:记录落在 4 月,卡片就该明说是 4 月,而不是「本月」。 + expect(monthlyExpenseCardLabel('2026-04', now: now), '4 月花费'); + expect(monthlyExpenseCardLabel('2026-12', now: now), '12 月花费'); + expect(monthlyExpenseCardLabel('2026-01', now: now), '1 月花费'); + }); + + test('跨年(服务端归月年份 ≠ 设备当前年份)补年份消歧', () { + // 设备已跨到 2027-01,服务端 tz 窗口仍落在 2026-12。 + final now = DateTime(2027, 1, 1); + expect(monthlyExpenseCardLabel('2026-12', now: now), '2026/12 花费'); + }); + + test('月份串非法退回「本月花费」(不崩、不显示脏值)', () { + final now = DateTime(2026, 9, 10); + expect(monthlyExpenseCardLabel('', now: now), '本月花费'); + expect(monthlyExpenseCardLabel('2026-9', now: now), '本月花费'); + expect(monthlyExpenseCardLabel('2026-13', now: now), '本月花费'); + expect(monthlyExpenseCardLabel('2026-00', now: now), '本月花费'); + expect(monthlyExpenseCardLabel('2026-09-01', now: now), '本月花费'); + }); + }); } diff --git a/test/features/pets/pet_detail_page_test.dart b/test/features/pets/pet_detail_page_test.dart index 3458507..b1de2bf 100644 --- a/test/features/pets/pet_detail_page_test.dart +++ b/test/features/pets/pet_detail_page_test.dart @@ -188,12 +188,24 @@ void main() { }); group('T2-13 · 数据卡行接 summary', () { - testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传)', (tester) async { + testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 当月花费(tz 透传)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); String? capturedTz; + // M3.5-03:花费卡标签展示服务端归月的实际月份,故样本月份取「本月」, + // 断言不随年份漂移(跨年格式另由 monthlyExpenseCardLabel 单测覆盖)。 + final now = DateTime.now(); + final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; repository.getPetSummaryHandler = (petId, tz) async { capturedTz = tz; - return buildSummary(); + return buildSummary( + overrides: { + 'monthlyExpense': { + 'month': thisMonth, + 'timezone': 'Asia/Shanghai', + 'amountCents': 12850, + }, + }, + ); }; await pumpDetail(tester); @@ -208,12 +220,45 @@ void main() { expect(find.text('下一针·狂犬疫苗'), findsOneWidget); // T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。 expect(find.text('¥128.50'), findsOneWidget); - expect(find.text('本月花费'), findsOneWidget); + // M3.5-03:不再硬编码「本月花费」,展示实际月份便于用户自查。 + expect(find.text('${now.month} 月花费'), findsOneWidget); + expect(find.text('本月花费'), findsNothing); // T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。 expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset)); expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$'))); }); + testWidgets('M3.5-03 · 四张数据卡均有 chevron 可点提示', (tester) async { + repository.getPetHandler = (petId) async => buildPet('p-1'); + final now = DateTime.now(); + final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + repository.getPetSummaryHandler = (petId, tz) async => buildSummary( + overrides: { + 'monthlyExpense': { + 'month': thisMonth, + 'timezone': 'Asia/Shanghai', + 'amountCents': 0, + }, + }, + ); + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + // 四张 stat 卡都挂了 onTap,此前无任何视觉提示;现统一补 + // chevron_right(与宠物列表卡 / 健康提醒卡同款)。 + for (final label in ['最新体重', '疫苗进度', '下一针·狂犬疫苗', '${now.month} 月花费']) { + expect( + find.ancestor( + of: find.text(label), + matching: find.widgetWithIcon(Card, Icons.chevron_right), + ), + findsOneWidget, + reason: '「$label」卡缺可点提示', + ); + } + }); + testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetSummaryHandler = (petId, tz) async => buildSummary( @@ -302,12 +347,22 @@ void main() { }); group('T2-14 · 时间线入口', () { - testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async { + testWidgets('点当月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); var summaryCalls = 0; + final now = DateTime.now(); + final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; repository.getPetSummaryHandler = (petId, tz) async { summaryCalls++; - return buildSummary(); + return buildSummary( + overrides: { + 'monthlyExpense': { + 'month': thisMonth, + 'timezone': 'Asia/Shanghai', + 'amountCents': 12850, + }, + }, + ); }; repository.listHealthEventsHandler = (petId, limit, cursor) async => const CursorPage(items: [], nextCursor: null, hasMore: false); @@ -315,7 +370,7 @@ void main() { await pumpDetail(tester); await tester.pumpAndSettle(); - await tester.tap(find.text('本月花费')); + await tester.tap(find.text('${now.month} 月花费')); await tester.pumpAndSettle(); expect(find.byType(HealthEventsPage), findsOneWidget); -- 2.43.0 From a4a97c03c2cf677b16b0aae06af6c7d415421ddb Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 15:37:00 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=B5=84?= =?UTF-8?q?=E6=96=99=E9=A1=B5=E7=9C=9F=E5=AE=9E=E5=8C=96=20+=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E2=80=94=E2=80=94/me=20=E8=AF=BB=E5=86=99?= =?UTF-8?q?=E3=80=81PATCH=20=E4=B8=89=E6=80=81=E3=80=81=E8=8E=B7=E8=B5=9E?= =?UTF-8?q?=E4=B8=8E=E4=BD=9C=E5=93=81=E7=BB=9F=E8=AE=A1=EF=BC=88T3.5-08?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码 demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。 - `PatchField` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落 显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」 也编码成 null,用户只改昵称就会连头像一起被服务端清掉。 - `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username` **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值 预填,避免把展示约定固化成真实昵称。 - `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级 这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。 - 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口, 未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。 - 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐; 纯空白是校验失败而非隐式清空。 - `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet` (单图、预览确认后才交付 ready assetId,孤儿防护不变)。 - 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository` 此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被 触发;本单是第一个真实消费者,桌面实测即暴露。 测试 526 → 588(+62)。 Co-Authored-By: Claude Fable 5 --- lib/app/app.dart | 44 ++- lib/core/models/patch_field.dart | 65 ++++ lib/core/widgets/avatar_upload_sheet.dart | 271 ++++++++++++++ lib/features/auth/auth_models.dart | 69 +++- lib/features/auth/auth_repository.dart | 35 +- lib/features/community/community_models.dart | 31 +- .../community/community_repository.dart | 11 + lib/features/community/media_uploader.dart | 11 +- lib/features/main/main_shell_page.dart | 19 +- lib/features/profile/profile_controller.dart | 137 +++++++ lib/features/profile/profile_display.dart | 47 +++ lib/features/profile/profile_edit_page.dart | 335 ++++++++++++++++++ lib/features/profile/profile_page.dart | 277 +++++++++++++-- test/core/models/patch_field_test.dart | 71 ++++ .../widgets/avatar_upload_sheet_test.dart | 162 +++++++++ .../profile/profile_controller_test.dart | 200 +++++++++++ .../profile/profile_edit_page_test.dart | 295 +++++++++++++++ .../features/profile/profile_models_test.dart | 153 ++++++++ test/features/profile/profile_page_test.dart | 207 +++++++++++ test/helpers/auth_test_helpers.dart | 52 ++- test/helpers/community_test_helpers.dart | 41 ++- .../smoke/detail_interactions_smoke_test.dart | 3 + 22 files changed, 2489 insertions(+), 47 deletions(-) create mode 100644 lib/core/models/patch_field.dart create mode 100644 lib/core/widgets/avatar_upload_sheet.dart create mode 100644 lib/features/profile/profile_controller.dart create mode 100644 lib/features/profile/profile_display.dart create mode 100644 lib/features/profile/profile_edit_page.dart create mode 100644 test/core/models/patch_field_test.dart create mode 100644 test/core/widgets/avatar_upload_sheet_test.dart create mode 100644 test/features/profile/profile_controller_test.dart create mode 100644 test/features/profile/profile_edit_page_test.dart create mode 100644 test/features/profile/profile_models_test.dart create mode 100644 test/features/profile/profile_page_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index b84eef5..d1fe75a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -9,6 +9,7 @@ import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/features/auth/auth_repository.dart'; import 'package:patbond_flutter/features/auth/login_page.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; @@ -24,6 +25,7 @@ import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/state/app_state.dart'; class App extends StatefulWidget { @@ -34,6 +36,7 @@ class App extends StatefulWidget { this.petsRepository, this.communityRepository, this.mediaUploaderFactory, + this.avatarUploaderFactory, }); /// 测试注入口;生产默认走安全存储 + 真实 API。 @@ -45,6 +48,11 @@ class App extends StatefulWidget { /// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。 final MediaUploaderFactory? mediaUploaderFactory; + /// 头像上传器构造口(T3.5-08/09)。生产为 null → 走 + /// [defaultAvatarUploader];桌面实测与集成测试在此替换选图/压缩层 + /// (Linux 桌面无 image_picker / 压缩原生实现),网络三段仍是生产实现。 + final AvatarUploaderFactory? avatarUploaderFactory; + @override State createState() => _AppState(); } @@ -55,6 +63,8 @@ class _AppState extends State { late final AuthRepository authRepository; late final PetsController petsController; late final CommunityController communityController; + late final ProfileController profileController; + late final AvatarUploaderBuilder _avatarUploaderBuilder; late final PetAnalytics petAnalytics; late final HealthRecordAnalytics healthRecordAnalytics; late final FeedAnalytics feedAnalytics; @@ -108,10 +118,24 @@ class _AppState extends State { // T3-14:Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起 // 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。 interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent); + final communityRepository = + widget.communityRepository ?? _buildCommunityRepository(); communityController = CommunityController( - repository: widget.communityRepository ?? _buildCommunityRepository(), + repository: communityRepository, interactionAnalytics: interactionAnalytics, ); + // T3.5-08/10:资料与统计单例,资料 Tab 与首页问候语共用(一次 /me + // 供两个消费点,改昵称后两处同时变)。 + profileController = ProfileController( + authRepository: authRepository, + communityRepository: communityRepository, + ); + // 头像上传:purpose 由调用页给定(user_avatar / pet_avatar)。仓库复用 + // community 仓库——media 两步上传端点由 user 服务提供,已在 + // _buildCommunityRepository 里单独接线(mediaApi)。 + final avatarFactory = widget.avatarUploaderFactory ?? defaultAvatarUploader; + _avatarUploaderBuilder = (purpose) => + avatarFactory(communityRepository, purpose); petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); feedAnalytics = FeedAnalytics(_analytics.trackEvent); @@ -149,8 +173,19 @@ class _AppState extends State { session: sessionManager, refresher: refresher, ); + // `/api/v1/me` 由 user 服务(:8082)提供,auth(:8081)上没有该路由, + // 故单独接一条 user 线路(与 community 仓库的 mediaApi 同构)。 + final userApi = ApiClient( + dio: buildPatbondDio( + session: sessionManager, + baseUrl: patbondUserApiBaseUrl, + ), + session: sessionManager, + refresher: refresher, + ); return ApiAuthRepository( api: api, + userApi: userApi, session: sessionManager, refresher: refresher, analytics: _analytics, @@ -205,10 +240,12 @@ class _AppState extends State { } void _reportAuthStateChange() { - // 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。 + // 登出即清宠物档案、社区 Feed 与本人资料内存副本(跨账号不泄漏; + // 重登后重新拉取)。 if (sessionManager.status == AuthStatus.unauthenticated) { petsController.reset(); communityController.reset(); + profileController.reset(); } // 认证状态机切页补点(03 §3.2 非路由曝光 1/2) final page = switch (sessionManager.status) { @@ -227,6 +264,7 @@ class _AppState extends State { appState.dispose(); petsController.dispose(); communityController.dispose(); + profileController.dispose(); if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); } @@ -250,6 +288,7 @@ class _AppState extends State { appState: appState, petsController: petsController, communityController: communityController, + profileController: profileController, currentUserId: sessionManager.userId, petAnalytics: petAnalytics, healthRecordAnalytics: healthRecordAnalytics, @@ -257,6 +296,7 @@ class _AppState extends State { interactionAnalytics: interactionAnalytics, postAnalytics: postAnalytics, mediaUploaderFactory: widget.mediaUploaderFactory, + avatarUploaderBuilder: _avatarUploaderBuilder, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/core/models/patch_field.dart b/lib/core/models/patch_field.dart new file mode 100644 index 0000000..555d319 --- /dev/null +++ b/lib/core/models/patch_field.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +/// PATCH 请求的**三态字段**:契约 v1.4.0 对「昵称 / 头像」两类天生可选的 +/// 字段定型为三态语义(`UpdateMeRequest` 与 `UpdatePetRequest.avatarAssetId`): +/// +/// | 态 | JSON 表现 | 服务端语义 | +/// | --- | --- | --- | +/// | [PatchField.absent] | **键不出现** | 不改 | +/// | [PatchField.clear] | 键出现且值为 `null` | 清空 | +/// | [PatchField.value] | 键出现且有值 | 设置 | +/// +/// Dart 的 `String?` 只有两态(有值 / null),无法区分「不改」与「清空」—— +/// 若把「不改」也编码为 `null`,未改动的字段会被服务端当成清空指令执行 +/// (用户只改昵称,头像就被顺手删了)。故三态必须由类型承载, +/// 不能靠 `T?` 加约定。 +/// +/// 序列化一律经 [writeTo]:它是「absent 不落键」这条纪律的唯一实现处, +/// 各请求 DTO 不自行拼 map,避免某处漏写 `isPresent` 判断。 +@immutable +class PatchField { + /// 不改:键不出现在 JSON 里。 + const PatchField.absent() : _present = false, _value = null; + + /// 清空:键出现且值为 `null`。 + const PatchField.clear() : _present = true, _value = null; + + /// 设置为 [value]。 + const PatchField.value(T value) : _present = true, _value = value; + + final bool _present; + final T? _value; + + /// 本次 PATCH 是否触及该字段(决定键是否落进 JSON)。 + bool get isPresent => _present; + + /// 是否为「显式清空」(present 且值为 null)。 + bool get isClear => _present && _value == null; + + /// present 且有值时的值;absent 与 clear 均为 null(两者不可由此区分)。 + T? get valueOrNull => _value; + + /// 按三态把自己写进 [json]:absent 不落键;clear 落 `null`; + /// 有值时落 [encode] 的产物(缺省原样写入)。 + void writeTo( + Map json, + String key, { + Object? Function(T value)? encode, + }) { + if (!_present) return; + final value = _value; + json[key] = value == null ? null : (encode?.call(value) ?? value); + } + + @override + bool operator ==(Object other) => + other is PatchField && + other._present == _present && + other._value == _value; + + @override + int get hashCode => Object.hash(_present, _value); + + @override + String toString() => _present ? 'PatchField($_value)' : 'PatchField.absent()'; +} diff --git a/lib/core/widgets/avatar_upload_sheet.dart b/lib/core/widgets/avatar_upload_sheet.dart new file mode 100644 index 0000000..007d6fe --- /dev/null +++ b/lib/core/widgets/avatar_upload_sheet.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; + +/// 页面侧的构造口:调用页只知道「用途」,仓库与选图/压缩层的装配由 +/// `app.dart` 在装配处完成。 +/// +/// 缺省为 null 时页面**不渲染头像上传入口**:意为「本次构建未装配上传能力」, +/// 而不是「有入口但点了没反应」。生产装配恒注入(见 `app.dart`)。 +typedef AvatarUploaderBuilder = MediaUploader Function(MediaPurpose purpose); + +/// App 级注入口(沿 `MediaUploaderFactory` 先例:工厂拿到已装配的仓库)。 +/// +/// 生产缺省 [defaultAvatarUploader];**桌面实测与集成测试**在此替换选图与 +/// 压缩两层——Linux 桌面既无 image_picker 也无 flutter_image_compress 的 +/// 原生实现,而 createUpload / 预签名 PUT 直传 / confirm 三段仍走生产实现。 +typedef AvatarUploaderFactory = + MediaUploader Function( + CommunityRepository repository, + MediaPurpose purpose, + ); + +/// 生产缺省头像上传器:单图、单并发(头像没有批量语义)。 +MediaUploader defaultAvatarUploader( + CommunityRepository repository, + MediaPurpose purpose, +) => MediaUploader( + repository: repository, + purpose: purpose, + maxImages: 1, + maxConcurrentUploads: 1, +); + +/// 弹出头像上传流程,返回**ready 的 assetId**;用户取消或未走到 ready +/// 即 null(孤儿防护:未 confirm 的 asset 绝不外露,见 [MediaUploader])。 +Future showAvatarUploadSheet( + BuildContext context, { + required AvatarUploaderBuilder builder, + required MediaPurpose purpose, + String title = '更换头像', +}) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => + AvatarUploadSheet(uploader: builder(purpose), title: title), + ); +} + +/// 头像上传 sheet:复用发布页的 [MediaUploader] 六态编排(queued / +/// compressing / uploading / confirming / ready / failed),单图上限。 +/// +/// 与九宫格的差异只在呈现:这里一次只有一张图,故用整幅预览 + 线性进度 +/// 条,而不是格内 [UploadProgressOverlay];状态机、凭据过期换新、失败可 +/// 重试、孤儿防护全部沿用编排器,本组件不复制任何上传逻辑。 +/// +/// **ready 后仍需用户点「使用这张」**:上传成功不等于用户满意这张图, +/// 自动关闭会剥夺预览确认的机会(头像是长期可见的身份标识)。 +class AvatarUploadSheet extends StatefulWidget { + const AvatarUploadSheet({ + required this.uploader, + required this.title, + super.key, + }); + + /// 本次会话专属的上传器(purpose 与 maxImages=1 由构造口设定); + /// 关闭 sheet 即 dispose,在途请求经 [MediaUploader.reset] 作废。 + final MediaUploader uploader; + + final String title; + + @override + State createState() => _AvatarUploadSheetState(); +} + +class _AvatarUploadSheetState extends State { + MediaUploader get _uploader => widget.uploader; + + @override + void initState() { + super.initState(); + _uploader.addListener(_onChanged); + // 打开即拉起选择器:sheet 的唯一目的就是选一张图,多一次「选择图片」 + // 点击是纯摩擦。用户在系统选择器里取消后回落到空态(可再次选择)。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _uploader.pickAndAdd(); + }); + } + + void _onChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _uploader.removeListener(_onChanged); + // 在途任务按 cancelled 收敛(未 confirm 的服务端 asset 弃引用, + // 由服务端超时清理兜底)。 + _uploader + ..reset() + ..dispose(); + super.dispose(); + } + + Future _reselect() async { + // 换图前先清空:maxImages=1 时不清空则 remainingSlots=0,选择器不会拉起。 + _uploader.reset(); + await _uploader.pickAndAdd(); + } + + @override + Widget build(BuildContext context) { + final items = _uploader.items; + final item = items.isEmpty ? null : items.first; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + if (item != null) _preview(item), + if (item != null) const SizedBox(height: 14), + ..._body(item), + ], + ), + ); + } + + /// 预览:原图字节直接解码(不落盘),圆形裁切以贴合头像最终形态。 + Widget _preview(MediaUploadItem item) { + return Center( + child: ClipOval( + child: Image.memory( + item.previewBytes, + width: 132, + height: 132, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) => Container( + width: 132, + height: 132, + color: AppColors.surfaceTint, + child: const Icon(Icons.image_outlined, color: AppColors.muted), + ), + ), + ), + ); + } + + List _body(MediaUploadItem? item) { + if (item == null) { + // 选择器拉起中 / 用户取消后的空态。 + return _uploader.isPicking + ? const [_BusyLine(label: '正在打开相册…')] + : [ + const Text( + '还没有选择图片', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _reselect, + icon: const Icon(Icons.photo_library_outlined, size: 18), + label: const Text('选择图片'), + ), + ]; + } + switch (item.phase) { + case MediaItemPhase.queued: + case MediaItemPhase.compressing: + return const [_BusyLine(label: '正在处理图片…')]; + case MediaItemPhase.uploading: + return [ + _ProgressLine(progress: item.progress), + const SizedBox(height: 10), + Text( + '上传中 ${(item.progress * 100).round()}%', + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ]; + case MediaItemPhase.confirming: + return const [ + _ProgressLine(progress: 1), + SizedBox(height: 10), + Text( + '正在确认…', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ]; + case MediaItemPhase.ready: + return [ + FilledButton( + onPressed: () => Navigator.of(context).pop(item.assetId), + child: const Text('使用这张'), + ), + const SizedBox(height: 8), + TextButton(onPressed: _reselect, child: const Text('重新选择')), + ]; + case MediaItemPhase.failed: + return [ + Text( + item.errorMessage ?? '上传失败', + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.errorDark, fontSize: 13), + ), + const SizedBox(height: 12), + // 不可重试(如压缩后仍超 10 MB)只给「重新选择」——重试同一张 + // 必然再失败,给重试钮是误导。 + if (item.retryable) + FilledButton( + onPressed: () => _uploader.retry(item.localId), + child: const Text('重试'), + ), + if (item.retryable) const SizedBox(height: 8), + TextButton(onPressed: _reselect, child: const Text('重新选择')), + ]; + } + } +} + +class _BusyLine extends StatelessWidget { + const _BusyLine({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ], + ); + } +} + +class _ProgressLine extends StatelessWidget { + const _ProgressLine({required this.progress}); + + final double progress; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: AppColors.surfaceTint, + color: AppColors.primary, + ), + ); + } +} diff --git a/lib/features/auth/auth_models.dart b/lib/features/auth/auth_models.dart index 223ba2f..1a65758 100644 --- a/lib/features/auth/auth_models.dart +++ b/lib/features/auth/auth_models.dart @@ -1,4 +1,10 @@ /// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。 +library; + +import 'package:patbond_flutter/core/models/patch_field.dart'; + +export 'package:patbond_flutter/core/models/patch_field.dart'; + class AuthTokens { const AuthTokens({ required this.userId, @@ -32,11 +38,16 @@ class AuthTokens { final DateTime refreshTokenExpiresAt; } -/// `GET /api/v1/me` 的用户资料。 +/// `GET` / `PATCH /api/v1/me` 的用户资料(契约 v1.4.0 恰好这 6 个字段)。 +/// +/// **不含 `avatarAssetId`**:客户端对头像 asset 只写不读,「有头像」等价于 +/// [avatarUrl] 非 null。 class UserProfile { const UserProfile({ required this.userId, required this.username, + required this.nickname, + required this.avatarUrl, required this.phone, required this.createdAt, }); @@ -45,6 +56,11 @@ class UserProfile { return UserProfile( userId: json['userId'] as String, username: json['username'] as String, + // DB 原值:服务端**刻意不做 username 回退**(v1.4.0 定型,理由见 + // [displayName]),未设置即 null。 + nickname: json['nickname'] as String?, + // 时效性预签名 GET URL,每次响应现签;不得持久化、过期即重取。 + avatarUrl: json['avatarUrl'] as String?, // 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。 phone: json['phone'] as String?, createdAt: DateTime.parse(json['createdAt'] as String), @@ -53,6 +69,57 @@ class UserProfile { final String userId; final String username; + + /// 昵称 DB 原值,未设置为 null。**编辑态预填只能用它**,不能用 + /// [displayName]——否则保存时会把展示回退值固化成真实昵称。 + final String? nickname; + + /// 头像预签名 GET URL(会过期,禁止入本地存储);无头像 / asset 非 + /// ready / 对象存储未配置三种情况均为 null。 + final String? avatarUrl; + final String? phone; final DateTime createdAt; + + /// 本人视角的展示名:`nickname ?? username`。 + /// + /// **回退刻意做在客户端展示层**:服务端 `/me` 返回 DB 原值不回退(契约 + /// v1.4.0 §Me),因为 `/me` 是本人编辑态——若服务端回退,编辑页会把 + /// `llx` 预填进昵称框,用户误以为设过昵称,下次保存即把这个展示约定 + /// **固化成真实数据**,`/internal/users/profiles` 的 SQL 回退链从此再不 + /// 触发。他人视角(Feed 作者名)的回退由服务端 SQL 层承担,客户端不重复。 + String get displayName => nickname ?? username; + + /// 是否已有头像(响应不回显 avatarAssetId,判定只看 URL 是否现签成功)。 + bool get hasAvatar => avatarUrl != null; +} + +/// `PATCH /api/v1/me` 请求体(契约 v1.4.0,**三态语义**: +/// 键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置)。 +/// +/// 两字段都 absent 即**空 patch**——服务端答 400/40000 而非静默 200, +/// 故调用前应以 [isEmpty] 短路(无变更不发请求)。 +/// +/// 昵称的服务端校验:btrim 后长度按**码点**计 1~32;纯空白或空串是 +/// 400/40000 而**不是**隐式清空(清空只走 [PatchField.clear] 一条路)。 +class UpdateMeRequest { + const UpdateMeRequest({ + this.nickname = const PatchField.absent(), + this.avatarAssetId = const PatchField.absent(), + }); + + final PatchField nickname; + + /// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。 + final PatchField avatarAssetId; + + /// 本次 patch 未触及任何字段(发出去必得 400/40000)。 + bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent; + + Map toJson() { + final json = {}; + nickname.writeTo(json, 'nickname'); + avatarAssetId.writeTo(json, 'avatarAssetId'); + return json; + } } diff --git a/lib/features/auth/auth_repository.dart b/lib/features/auth/auth_repository.dart index 735d453..85df26a 100644 --- a/lib/features/auth/auth_repository.dart +++ b/lib/features/auth/auth_repository.dart @@ -28,18 +28,33 @@ abstract class AuthRepository { Future restoreSession(); Future me(); + + /// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。 + /// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。 + Future updateMe(UpdateMeRequest request); } +/// 基于 [ApiClient] 的实现。 +/// +/// **端口线路**(ADR-002 无网关,分端口直连):注册/登录/登出/刷新走 auth +/// 服务(:8081);`GET` 与 `PATCH /api/v1/me` 由 **user 服务**(:8082 +/// `MeController`)提供,故经 [userApi] 直连——auth 上没有 `/api/v1/me` +/// 路由,走主客户端会得到 404。未提供 [userApi] 时回落主客户端(既有测试 +/// 桩场景)。同一线路分离手法与 `ApiCommunityRepository` 的 `mediaApi` 一致。 class ApiAuthRepository implements AuthRepository { ApiAuthRepository({ - required this._api, + required ApiClient api, required this._session, required this._refresher, + ApiClient? userApi, this._analytics, this._uuid = const Uuid(), - }); + }) : _api = api, + // 缺省回落主客户端:既有测试桩只注入一个 ApiClient。 + _userApi = userApi ?? api; final ApiClient _api; + final ApiClient _userApi; final SessionManager _session; final TokenRefresher _refresher; final AnalyticsService? _analytics; @@ -132,11 +147,25 @@ class ApiAuthRepository implements AuthRepository { @override Future me() async { - final data = await _api.request( + final data = await _userApi.request( '/api/v1/me', method: 'GET', requiresAuth: true, ); return UserProfile.fromJson(data! as Map); } + + @override + Future updateMe(UpdateMeRequest request) async { + // 无乐观锁、无幂等键(契约定型:唯一合法写者 + 列级选择性 UPDATE, + // 同 body 重放天然幂等)。三态由 request.toJson 表达——「不改」的字段 + // 根本不出现在 body 里,绝不发成 null。 + final data = await _userApi.request( + '/api/v1/me', + method: 'PATCH', + body: request.toJson(), + requiresAuth: true, + ); + return UserProfile.fromJson(data! as Map); + } } diff --git a/lib/features/community/community_models.dart b/lib/features/community/community_models.dart index fbe7462..5746fb8 100644 --- a/lib/features/community/community_models.dart +++ b/lib/features/community/community_models.dart @@ -60,9 +60,14 @@ enum MediaKind { _enumFromJson(values, value, 'kind'); } -/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。 +/// 上传用途白名单(契约 v1.4.0 三值;决定 objectKey 前缀)。 +/// +/// **用途即引用侧的类型检查**:引用时服务端校验 purpose 相符,故帖图不能 +/// 当头像、两种头像也互不通用(不符者 404/40405)。 enum MediaPurpose { - postImage('post_image'); + postImage('post_image'), + userAvatar('user_avatar'), + petAvatar('pet_avatar'); const MediaPurpose(this.wire); @@ -628,3 +633,25 @@ class FollowStats { final int followingCount; final bool followedByMe; } + +/// 我的社区数字(`GET /api/v1/me/community-stats`,契约 v1.4.0 新增)。 +/// +/// 两数同一集合:本人的、已发布的、未软删的帖(草稿 / 软删 / hidden / +/// archived 均不计;自己赞自己**计入**,与帖子详情的 likeCount 同口径)。 +/// 空数据为 `0` 而非 null,且该端点**永不 404**——任何已认证用户都有 stats。 +class CommunityStats { + const CommunityStats({ + required this.receivedLikeCount, + required this.publishedPostCount, + }); + + factory CommunityStats.fromJson(Map json) { + return CommunityStats( + receivedLikeCount: json['receivedLikeCount'] as int, + publishedPostCount: json['publishedPostCount'] as int, + ); + } + + final int receivedLikeCount; + final int publishedPostCount; +} diff --git a/lib/features/community/community_repository.dart b/lib/features/community/community_repository.dart index e4be060..db6fa9d 100644 --- a/lib/features/community/community_repository.dart +++ b/lib/features/community/community_repository.dart @@ -55,6 +55,9 @@ abstract class CommunityRepository { Future followUser(String userId); Future unfollowUser(String userId); Future getFollowStats(String userId); + + // ---- 我的社区数字(主体恒为调用者,路径上无 userId)---- + Future getMyCommunityStats(); } /// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token @@ -301,4 +304,12 @@ class ApiCommunityRepository implements CommunityRepository { final data = await _request('/api/v1/users/$userId/follow-stats'); return FollowStats.fromJson(_asMap(data)); } + + // ---- 我的社区数字 ---- + + @override + Future getMyCommunityStats() async { + final data = await _request('/api/v1/me/community-stats'); + return CommunityStats.fromJson(_asMap(data)); + } } diff --git a/lib/features/community/media_uploader.dart b/lib/features/community/media_uploader.dart index 82437c7..166f21d 100644 --- a/lib/features/community/media_uploader.dart +++ b/lib/features/community/media_uploader.dart @@ -131,6 +131,7 @@ class MediaUploader extends ChangeNotifier { MediaImageCompressor? compressor, MediaDirectUploadClient? directUpload, this._analytics, + this.purpose = MediaPurpose.postImage, this.maxImages = 9, this.maxConcurrentUploads = 2, this.maxByteSize = 10 * 1024 * 1024, @@ -151,7 +152,13 @@ class MediaUploader extends ChangeNotifier { final DateTime Function() _now; - /// 九宫格上限(05 号规范 §3.2)。 + /// 上传用途(决定服务端 objectKey 前缀,也是引用侧的类型检查依据): + /// 发布页 `post_image`、资料页头像 `user_avatar`、宠物头像 `pet_avatar`。 + /// 用途不符的 asset 在引用时被答 404/40405,所以这个值必须由调用方 + /// 按场景显式给对(M3.5-08/09 起本类不再只服务发布页)。 + final MediaPurpose purpose; + + /// 九宫格上限(05 号规范 §3.2);头像场景传 1。 final int maxImages; final int maxConcurrentUploads; @@ -467,7 +474,7 @@ class MediaUploader extends ChangeNotifier { return _repository.createMediaUpload( CreateMediaUploadRequest( kind: MediaKind.image, - purpose: MediaPurpose.postImage, + purpose: purpose, mimeType: compressed.mimeType, byteSize: compressed.byteSize, ), diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 6bcf175..bbdcabf 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; @@ -17,6 +18,7 @@ import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pets_controller.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_controller.dart'; import 'package:patbond_flutter/features/profile/profile_page.dart'; import 'package:patbond_flutter/features/services/services_page.dart'; import 'package:patbond_flutter/state/app_state.dart'; @@ -27,6 +29,7 @@ class MainShellPage extends StatefulWidget { required this.appState, required this.petsController, required this.communityController, + required this.profileController, super.key, this.currentUserId, this.petAnalytics, @@ -35,6 +38,7 @@ class MainShellPage extends StatefulWidget { this.interactionAnalytics, this.postAnalytics, this.mediaUploaderFactory, + this.avatarUploaderBuilder, this.pageViewTracker, this.onLogout, }); @@ -47,6 +51,9 @@ class MainShellPage extends StatefulWidget { /// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。 final CommunityController communityController; + /// 本人资料与社区数字(T3.5-08);资料 Tab 与首页问候语共用。 + final ProfileController profileController; + /// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。 final String? currentUserId; @@ -68,6 +75,9 @@ class MainShellPage extends StatefulWidget { /// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。 final MediaUploaderFactory? mediaUploaderFactory; + /// 头像上传构造口(T3.5-08/09:资料编辑页与宠物详情页)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -167,6 +177,7 @@ class _MainShellPageState extends State { HomePage( appState: widget.appState, communityController: widget.communityController, + profileController: widget.profileController, feedAnalytics: widget.feedAnalytics, isActive: currentIndex == 0, onOpenServices: openServices, @@ -182,12 +193,18 @@ class _MainShellPageState extends State { controller: widget.petsController, analytics: widget.petAnalytics, healthAnalytics: widget.healthRecordAnalytics, + avatarUploaderBuilder: widget.avatarUploaderBuilder, ), ServicesPage( showPersonal: showPersonalServices, locationWeather: widget.appState.locationWeather, ), - ProfilePage(appState: widget.appState, onLogout: widget.onLogout), + ProfilePage( + appState: widget.appState, + controller: widget.profileController, + avatarUploaderBuilder: widget.avatarUploaderBuilder, + onLogout: widget.onLogout, + ), ]; return Scaffold( diff --git a/lib/features/profile/profile_controller.dart b/lib/features/profile/profile_controller.dart new file mode 100644 index 0000000..e4a1a94 --- /dev/null +++ b/lib/features/profile/profile_controller.dart @@ -0,0 +1,137 @@ +import 'package:flutter/foundation.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; + +/// 资料主链路四态(沿 pets/community 两域惯例)。 +enum ProfileLoadPhase { initial, loading, ready, error } + +/// 统计块的独立三态:`/me` 成功而统计失败时**只降级这一块**,不把整页 +/// 打成 error——昵称与头像已经拿到了,为两个数字丢掉整页是过度反应。 +enum ProfileStatsPhase { loading, ready, error } + +/// 「我的资料」状态控制器(T3.5-08)。 +/// +/// 数据源三处: +/// - `GET /api/v1/me` → 昵称 / 头像(主链路,失败即页面 error 态) +/// - `GET /api/v1/me/community-stats` → 获赞总数 / 作品数 +/// - `GET /api/v1/users/{me}/follow-stats` → 粉丝数 / 关注数 +/// +/// 首页问候语(T3.5-10)与资料页共用本控制器的 [displayName], +/// 保证两处展示名同源、改昵称后一起变(一次 `/me` 供两个消费点)。 +class ProfileController extends ChangeNotifier { + ProfileController({ + required AuthRepository authRepository, + required CommunityRepository communityRepository, + }) : _auth = authRepository, + _community = communityRepository; + + final AuthRepository _auth; + final CommunityRepository _community; + + ProfileLoadPhase _phase = ProfileLoadPhase.initial; + ProfileStatsPhase _statsPhase = ProfileStatsPhase.loading; + UserProfile? _profile; + CommunityStats? _communityStats; + FollowStats? _followStats; + ApiException? _lastError; + bool _disposed = false; + + ProfileLoadPhase get phase => _phase; + ProfileStatsPhase get statsPhase => _statsPhase; + + /// 本人资料(ready 态非 null)。 + UserProfile? get profile => _profile; + + CommunityStats? get communityStats => _communityStats; + FollowStats? get followStats => _followStats; + ApiException? get lastError => _lastError; + + /// 展示名 `nickname ?? username`;资料未到手时为 null(调用方不造假名)。 + String? get displayName => _profile?.displayName; + + /// 加载 / 重试。主链路错误收敛为 error 态供页面渲染 + 重试,不外抛。 + /// + /// 已有资料副本时刷新失败**保留副本**(`_phase` 停在 ready):与详情页 + /// 「有副本即不打断阅读」的既有取舍一致。 + Future refresh() async { + if (_profile == null) { + _phase = ProfileLoadPhase.loading; + } + _lastError = null; + _notify(); + try { + _profile = await _auth.me(); + _phase = ProfileLoadPhase.ready; + } on ApiException catch (error) { + _lastError = error; + if (_profile == null) { + _phase = ProfileLoadPhase.error; + _notify(); + return; + } + } + _notify(); + await refreshStats(); + } + + /// 只重取两块统计(统计块的「重试」入口;主链路资料不动)。 + Future refreshStats() async { + final userId = _profile?.userId; + if (userId == null) return; + _statsPhase = ProfileStatsPhase.loading; + _notify(); + try { + // 顺序取(同一服务、量级极小):任一失败即整块 error。两个数字并列 + // 在同一张卡上,只有一半是数字、另一半是「—」比整块「加载失败 + + // 重试」更费解。 + final community = await _community.getMyCommunityStats(); + final follow = await _community.getFollowStats(userId); + _communityStats = community; + _followStats = follow; + _statsPhase = ProfileStatsPhase.ready; + } on ApiException { + _statsPhase = ProfileStatsPhase.error; + } + _notify(); + } + + /// 提交资料变更(三态语义见 [UpdateMeRequest])。 + /// + /// **空 patch 直接短路不发请求**:服务端对空 patch 答 400/40000(刻意不 + /// 静默 200),客户端没有理由去撞这一枪。 + /// 成功即以服务端回显的全量资料替换本地副本(`avatarUrl` 现签, + /// 不做本地拼装)。失败按类型化异常外抛给编辑页分层呈现。 + Future save(UpdateMeRequest request) async { + if (request.isEmpty) return _profile; + final updated = await _auth.updateMe(request); + _profile = updated; + _phase = ProfileLoadPhase.ready; + _notify(); + return updated; + } + + /// 登出清空:回 initial 并清资料,避免上一账号昵称/头像跨会话泄漏 + /// (沿 `PetsController.reset` 先例)。 + void reset() { + _phase = ProfileLoadPhase.initial; + _statsPhase = ProfileStatsPhase.loading; + _profile = null; + _communityStats = null; + _followStats = null; + _lastError = null; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/features/profile/profile_display.dart b/lib/features/profile/profile_display.dart new file mode 100644 index 0000000..78ce263 --- /dev/null +++ b/lib/features/profile/profile_display.dart @@ -0,0 +1,47 @@ +import 'package:patbond_flutter/core/network/api_exception.dart'; + +/// 资料页/编辑页的错误文案映射(沿 `petLoadErrorMessage` 先例: +/// 服务端原始 message 一律不上屏)。 +String profileLoadErrorMessage(ApiException? error) => switch (error) { + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '加载失败,请稍后重试', +}; + +/// 保存资料的失败文案,按契约 v1.4.0 的 `PATCH /api/v1/me` 错误谱分层: +/// +/// | 码 | 成因 | 文案取向 | +/// | --- | --- | --- | +/// | 40000 | 昵称长度/纯空白、空 patch、非法 UUID | 指向输入本身可改 | +/// | 40405 | 头像 asset 不存在/非本人/已删/用途不符 | 引导重新上传 | +/// | 42203 | 本人头像 asset 仍在 uploading/failed | 明说「还没传完」,可重试 | +/// +/// 40000 的三种成因在客户端已各自前置拦截(长度校验 / 空 patch 短路 / +/// assetId 来自服务端),真收到 40000 说明校验与服务端有偏差, +/// 故文案不写死「昵称」,只指向「填写内容」。 +String profileSaveErrorMessage(ApiException? error) => switch (error) { + ApiBusinessException(code: ApiCodes.paramError) => '填写内容不符合要求,请检查后重试', + ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传', + ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试', + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '保存失败,请稍后重试', +}; + +/// 昵称的客户端校验,与后端 `ck_users_nickname`(btrim + 1~32)对齐。 +/// +/// **长度按码点计**(`String.characters` 的等价物 `runes`):数据库 +/// `char_length` 数的是码点,若按 UTF-16 `String.length` 校验,32 个 emoji +/// 的合法昵称(UTF-16 长度 64)会被客户端误拒。 +/// +/// 返回 null 即通过;非空即字段级 errorText。 +String? validateNickname(String raw) { + // btrim 对齐:服务端先 btrim 再判长度,故校验也先 trim。 + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + // 纯空白是 400/40000 而非隐式清空——清空走「清除昵称」显式入口。 + return '昵称不能为空,如需清除请用下方「清除昵称」'; + } + if (trimmed.runes.length > 32) return '昵称最多 32 个字'; + return null; +} diff --git a/lib/features/profile/profile_edit_page.dart b/lib/features/profile/profile_edit_page.dart new file mode 100644 index 0000000..781bc14 --- /dev/null +++ b/lib/features/profile/profile_edit_page.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +/// 资料编辑页(T3.5-08):昵称 + 头像的唯一写入口。 +/// +/// **PATCH 三态在本页成形**(契约 v1.4.0 的 `UpdateMeRequest`): +/// +/// | 用户动作 | 提交表现 | +/// | --- | --- | +/// | 没碰昵称 | `nickname` **键不出现**(不改) | +/// | 改了昵称 | `nickname: "新值"` | +/// | 点「清除昵称」 | `nickname: null`(清空) | +/// | 没碰头像 | `avatarAssetId` **键不出现** | +/// | 传了新头像 | `avatarAssetId: ""` | +/// | 点「清除头像」 | `avatarAssetId: null` | +/// +/// 「不改」与「清空」在 JSON 上是**键缺省 vs 显式 null** 两回事:若把未改 +/// 字段也发成 null,用户只改昵称就会连头像一起被清掉。故本页不维护 +/// 「当前值 → 直接整体提交」的表单模型,而是维护**三态意图**([PatchField]), +/// 与初值比对后只让真正变化的字段落键。 +class ProfileEditPage extends StatefulWidget { + const ProfileEditPage({ + required this.controller, + super.key, + this.avatarUploaderBuilder, + }); + + final ProfileController controller; + + /// 头像上传编排器构造口;null 即本次构建未装配上传能力,隐藏头像入口 + /// (生产装配恒注入,见 `app.dart`)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + + @override + State createState() => _ProfileEditPageState(); +} + +class _ProfileEditPageState extends State { + late final TextEditingController _nickname; + + /// 页面进入时的资料快照:差量比对的基准(服务端回显的权威值)。 + late final UserProfile _initial; + + /// 昵称的三态意图:null 表示「跟随输入框与初值的比对结果」, + /// 非 null 表示用户已显式点过「清除昵称」。 + bool _nicknameCleared = false; + + /// 头像的三态意图(absent / clear / 新 assetId)。 + PatchField _avatarIntent = const PatchField.absent(); + + /// 新上传头像的本地预览(仅本页会话内有效,不落盘)。 + String? _pendingAvatarUrl; + + String? _nicknameError; + bool _saving = false; + ApiException? _saveError; + + @override + void initState() { + super.initState(); + _initial = widget.controller.profile!; + // **预填只用 DB 原值**,不用 displayName:若把回退出来的 username + // 预填进来,用户会以为自己设过昵称,一保存就把展示约定固化成数据 + // (服务端 /me 刻意不回退,正是为了留住这个区别)。 + _nickname = TextEditingController(text: _initial.nickname ?? ''); + _nickname.addListener(_onNicknameChanged); + } + + @override + void dispose() { + _nickname.dispose(); + super.dispose(); + } + + void _onNicknameChanged() { + // 每次输入都重建:保存钮的可用性由 `_hasChanges` 现算,不重建就会出现 + // 「已经打了字但保存钮还是灰的」。同时用户重新打字即撤销「清除」意图, + // 并清掉上一次的字段级错误。 + setState(() { + if (_nicknameCleared && _nickname.text.isNotEmpty) { + _nicknameCleared = false; + } + _nicknameError = null; + }); + } + + /// 昵称三态:显式清除 > 与初值不同即设置 > 否则不改。 + PatchField get _nicknamePatch { + if (_nicknameCleared) { + // 本来就没有昵称时「清除」是空操作,不落键(避免制造空 patch 之外 + // 的无意义写入)。 + return _initial.nickname == null + ? const PatchField.absent() + : const PatchField.clear(); + } + final trimmed = _nickname.text.trim(); + if (trimmed.isEmpty) return const PatchField.absent(); + if (trimmed == _initial.nickname) return const PatchField.absent(); + return PatchField.value(trimmed); + } + + UpdateMeRequest get _request => + UpdateMeRequest(nickname: _nicknamePatch, avatarAssetId: _avatarIntent); + + bool get _hasChanges => !_request.isEmpty; + + /// 当前应展示的头像:新传的 > 清除意图(无图) > 服务端现值。 + String? get _shownAvatarUrl { + if (_avatarIntent.isClear) return null; + return _pendingAvatarUrl ?? _initial.avatarUrl; + } + + bool get _hasAvatarNow => _shownAvatarUrl != null; + + Future _pickAvatar() async { + final builder = widget.avatarUploaderBuilder; + if (builder == null) return; + final assetId = await showAvatarUploadSheet( + context, + builder: builder, + purpose: MediaPurpose.userAvatar, + ); + if (assetId == null || !mounted) return; + setState(() { + _avatarIntent = PatchField.value(assetId); + // 预览沿用上传器给的可访问 URL?—— 没有:complete 响应的 url 与 + // /me 的 avatarUrl 同为预签名,但本页不缓存它,改为保存后由服务端 + // 回显。上传成功到保存之间以「已选择新头像」文案示意。 + _pendingAvatarUrl = null; + }); + } + + void _clearAvatar() { + setState(() { + _avatarIntent = const PatchField.clear(); + _pendingAvatarUrl = null; + }); + } + + void _clearNickname() { + setState(() { + _nicknameCleared = true; + _nicknameError = null; + _nickname.clear(); + }); + } + + Future _save() async { + // 有内容的昵称才校验:空输入框意为「不改」(清空走显式入口)。 + final raw = _nickname.text; + if (!_nicknameCleared && raw.trim().isNotEmpty) { + final error = validateNickname(raw); + if (error != null) { + setState(() => _nicknameError = error); + return; + } + } + final request = _request; + if (request.isEmpty) { + // 空 patch 服务端答 400/40000(刻意不静默 200);客户端不去撞这一枪。 + Navigator.of(context).pop(false); + return; + } + setState(() { + _saving = true; + _saveError = null; + }); + try { + await widget.controller.save(request); + if (!mounted) return; + Navigator.of(context).pop(true); + } on ApiException catch (error) { + if (!mounted) return; + setState(() { + _saving = false; + _saveError = error; + }); + } + } + + @override + Widget build(BuildContext context) { + final canUploadAvatar = widget.avatarUploaderBuilder != null; + return Scaffold( + appBar: AppBar(title: const Text('编辑资料')), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 30), + children: [ + if (_saveError != null) ...[ + InlineErrorBanner(message: profileSaveErrorMessage(_saveError)), + const SizedBox(height: 16), + ], + if (canUploadAvatar) ...[ + Center( + child: Column( + children: [ + _AvatarPreview( + url: _shownAvatarUrl, + pendingAssetId: _avatarIntent.valueOrNull, + onTap: _saving ? null : _pickAvatar, + ), + const SizedBox(height: 10), + TextButton.icon( + onPressed: _saving ? null : _pickAvatar, + icon: const Icon(Icons.photo_camera_outlined, size: 18), + label: const Text('更换头像'), + ), + if (_hasAvatarNow) + TextButton( + onPressed: _saving ? null : _clearAvatar, + child: const Text( + '清除头像', + style: TextStyle(color: AppColors.errorDark), + ), + ), + if (_avatarIntent.isClear) + const Text( + '保存后将移除头像', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + AppTextField( + label: '昵称', + controller: _nickname, + enabled: !_saving, + errorText: _nicknameError, + // 未设昵称时点明「现在别人看到的是用户名」——展示回退是客户端 + // 行为,不写进输入框(否则一保存就变成真昵称)。 + helperText: _initial.nickname == null + ? '未设置,当前展示为用户名「${_initial.username}」' + : '1~32 个字', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _saving ? null : _save(), + ), + if (_initial.nickname != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _saving ? null : _clearNickname, + child: const Text( + '清除昵称', + style: TextStyle(color: AppColors.errorDark), + ), + ), + ), + if (_nicknameCleared) + const Text( + '保存后将清除昵称,展示名回退为用户名', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + const SizedBox(height: 24), + PrimaryButton( + label: '保存', + isLoading: _saving, + // 无变更时禁用:避免用户以为「点了保存但什么都没发生」。 + onPressed: _hasChanges ? _save : null, + ), + ], + ), + ); + } +} + +/// 编辑页头像预览:有 URL 走 [RemoteImage](缓存 key 已剥签名参数); +/// 刚上传但尚未保存时无可用 URL,以「已选择新头像」占位说明。 +class _AvatarPreview extends StatelessWidget { + const _AvatarPreview({ + required this.url, + required this.pendingAssetId, + this.onTap, + }); + + final String? url; + final String? pendingAssetId; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final Widget content; + if (url != null) { + content = RemoteImage( + url: url!, + width: 96, + height: 96, + borderRadius: BorderRadius.circular(48), + ); + } else if (pendingAssetId != null) { + content = const ColoredBox( + color: AppColors.surfaceTint, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: AppColors.primary, size: 24), + SizedBox(height: 4), + Text( + '已选择\n新头像', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.primaryDark, fontSize: 10), + ), + ], + ), + ), + ); + } else { + content = const ColoredBox( + color: AppColors.surfaceTint, + child: Icon(Icons.person_outline, color: AppColors.muted, size: 40), + ); + } + return Semantics( + label: '更换头像', + button: onTap != null, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: SizedBox(width: 96, height: 96, child: ClipOval(child: content)), + ), + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index feaa375..758aeee 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -1,17 +1,49 @@ 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/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.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, this.onLogout}); +/// 「我的资料」Tab(T3.5-08 真实化)。 +/// +/// 头部三项(展示名 / 头像 / 四个数字)自此全部来自服务端: +/// `GET /me`(昵称与头像)+ `GET /me/community-stats`(获赞 / 作品)+ +/// `GET /users/{me}/follow-stats`(粉丝 / 关注)。M3 之前的硬编码 +/// 「萌宠新手(豆豆家长)」/「24 / 1.8k / 2」已退役。 +/// +/// 四态:loading(居中转圈)/ error(横幅 + 重试)/ ready。**没有独立空态** +/// ——任何已认证用户都有资料,`/me/community-stats` 契约上「永不 404、空数据 +/// 返回 0」,故「新用户什么都没有」的形态就是 ready 态里的一排 0, +/// 而不是另一个页面态。统计块另有独立三态(见 [ProfileStatsPhase])。 +class ProfilePage extends StatefulWidget { + const ProfilePage({ + required this.appState, + required this.controller, + super.key, + this.avatarUploaderBuilder, + this.onLogout, + }); final AppState appState; + /// 资料与统计数据源(Tab 级单例,`app.dart` 装配注入;首页问候语同源)。 + final ProfileController controller; + + /// 头像上传编排器构造口(透传编辑页)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + /// 真实退出登录入口;未接线时保持演示提示。 final Future Function()? onLogout; + /// 刻意保留的 demo 入口(ADR-022 未纳入本迭代):预约订单与健康卡包属 + /// M5 服务域;地址定位需外部服务;设置与关于待有实际可设项。 + /// 「我的收藏与草稿」的后端能力已就位(`/me/bookmarks`、`/me/posts`), + /// 但列表页本单未做(见 05 号报告遗留),故仍走演示提示。 static const menuItems = [ (Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'), (Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'), @@ -20,18 +52,63 @@ class ProfilePage extends StatelessWidget { (Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'), ]; + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + ProfileController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + // 主壳挂载即预取(IndexedStack 各 Tab 同时构建,沿 pets/home 先例); + // 重登后控制器已 reset 回 initial,会重新拉取。首页问候语也消费这一次。 + // + // **预取推到帧末**:`refresh()` 起手就同步 notify 一次(切 loading 态), + // 而同一控制器的另一个监听者(首页问候语)在本页 initState 时已构建完成 + // ——在 initState 里同步通知它会命中 Flutter「build 期间 setState」断言。 + // pets/home 各自只有自己一个监听者,故它们在 initState 里直取无妨。 + if (_controller.phase == ProfileLoadPhase.initial) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.phase == ProfileLoadPhase.initial) { + _controller.refresh(); + } + }); + } + } + void showDemoMessage(BuildContext context, String name) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('「$name」功能为演示入口'))); } + Future _openEdit() async { + if (_controller.profile == null) return; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileEditPage( + controller: _controller, + avatarUploaderBuilder: widget.avatarUploaderBuilder, + ), + ), + ); + if (saved == true && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('资料已更新'))); + } + } + + /// 刻意保留的 demo 家具:AppState 仍承载首页天气/本地服务的演示数据, + /// 这个入口是它唯一的复位口(不影响服务端真实资料)。 Future reset(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('恢复演示数据'), - content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态。'), + content: const Text('首页天气与本地服务的演示内容会恢复为初始状态(不影响账号资料与宠物档案)。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -45,7 +122,7 @@ class ProfilePage extends StatelessWidget { ), ); if (confirmed == true) { - await appState.resetDemoData(); + await widget.appState.resetDemoData(); if (context.mounted) { ScaffoldMessenger.of( context, @@ -56,6 +133,43 @@ class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + switch (_controller.phase) { + case ProfileLoadPhase.initial: + case ProfileLoadPhase.loading: + return const Center(child: CircularProgressIndicator()); + case ProfileLoadPhase.error: + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InlineErrorBanner( + message: profileLoadErrorMessage(_controller.lastError), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _controller.refresh, + child: const Text('重试'), + ), + ], + ), + ), + ); + case ProfileLoadPhase.ready: + return RefreshIndicator( + onRefresh: _controller.refresh, + child: _content(_controller.profile!), + ); + } + }, + ); + } + + Widget _content(UserProfile profile) { return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), children: [ @@ -67,43 +181,38 @@ class ProfilePage extends StatelessWidget { ), child: Column( children: [ - RemoteImage( - url: userAvatar, - width: 82, - height: 82, - borderRadius: BorderRadius.circular(41), - ), + _ProfileAvatar(url: profile.avatarUrl, onTap: _openEdit), const SizedBox(height: 12), - const Text( - '萌宠新手(豆豆家长)', - style: TextStyle( + Text( + // 展示回退在客户端:`nickname ?? username`(服务端 /me 返回 + // DB 原值不回退,见 UserProfile.displayName 的理由)。 + profile.displayName, + style: const TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 5), - const Text( - 'Patbond 社区创作达人', - style: TextStyle(color: AppColors.accent, fontSize: 12), + Text( + '@${profile.username}', + style: const TextStyle(color: AppColors.accent, fontSize: 12), ), - const SizedBox(height: 20), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + visualDensity: VisualDensity.compact, + ), + onPressed: _openEdit, + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑资料'), + ), + const SizedBox(height: 16), const Divider(color: Colors.white24), const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const _ProfileStat(value: '24', label: '关注我'), - const _ProfileStat(value: '1.8k', label: '获赞'), - _ProfileStat( - // 「我的资料」头部整体仍是 demo 家具(M5 范围):三项 - // 统计同源 demo 常量;AppState.posts 随 T3-17 退役后 - // 本项直读 demo 列表长度,不伪装真实数据。 - value: '${initialPosts.length}', - label: '我的作品', - ), - ], - ), + _statsRow(), ], ), ), @@ -111,7 +220,7 @@ class ProfilePage extends StatelessWidget { SectionCard( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( - children: menuItems.map((item) { + children: ProfilePage.menuItems.map((item) { return ListTile( leading: CircleAvatar( backgroundColor: AppColors.surfaceTint, @@ -139,12 +248,112 @@ class ProfilePage extends StatelessWidget { ), const SizedBox(height: 10), TextButton( - onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'), + onPressed: widget.onLogout ?? () => showDemoMessage(context, '退出登录'), child: const Text('切换账号或退出登录'), ), ], ); } + + /// 统计块三态:loading 占位「—」+ 转圈 / error 提示 + 重试 / ready 实数。 + /// + /// 数字**不做 1.8k 式压缩**:获赞总数要能与帖子详情的 likeCount 逐一对上 + /// (同一口径,含自赞),压缩会让「对不上」变成常态。 + Widget _statsRow() { + switch (_controller.statsPhase) { + case ProfileStatsPhase.loading: + return const SizedBox( + height: 46, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + case ProfileStatsPhase.error: + return SizedBox( + height: 46, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '统计加载失败', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + TextButton( + onPressed: _controller.refreshStats, + child: const Text( + '重试', + style: TextStyle(color: AppColors.accent), + ), + ), + ], + ), + ); + case ProfileStatsPhase.ready: + final follow = _controller.followStats; + final community = _controller.communityStats; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _ProfileStat(value: '${follow?.followerCount ?? 0}', label: '关注我'), + _ProfileStat(value: '${follow?.followingCount ?? 0}', label: '我关注'), + _ProfileStat( + value: '${community?.receivedLikeCount ?? 0}', + label: '获赞', + ), + _ProfileStat( + value: '${community?.publishedPostCount ?? 0}', + label: '我的作品', + ), + ], + ); + } + } +} + +/// 资料页头像:有 URL 走 [RemoteImage](缓存 key 剥签名参数,URL 不落盘); +/// 无头像时本地占位(不显示破图)。 +class _ProfileAvatar extends StatelessWidget { + const _ProfileAvatar({required this.url, required this.onTap}); + + final String? url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '编辑资料', + button: true, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: SizedBox( + width: 82, + height: 82, + child: ClipOval( + child: url == null + ? const ColoredBox( + color: AppColors.surfaceTint, + child: Icon( + Icons.person_outline, + color: AppColors.muted, + size: 36, + ), + ) + : RemoteImage( + url: url!, + width: 82, + height: 82, + borderRadius: BorderRadius.circular(41), + ), + ), + ), + ), + ); + } } class _ProfileStat extends StatelessWidget { diff --git a/test/core/models/patch_field_test.dart b/test/core/models/patch_field_test.dart new file mode 100644 index 0000000..0c7893d --- /dev/null +++ b/test/core/models/patch_field_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/models/patch_field.dart'; + +/// PATCH 三态的 JSON 表现(契约 v1.4.0 的 `UpdateMeRequest` / +/// `UpdatePetRequest.avatarAssetId`)。 +/// +/// 本文件守住的红线只有一条:**absent 绝不落键**。若 absent 落成 `null`, +/// 用户「只改昵称」会把头像一起清掉(服务端把显式 null 当清空指令)。 +void main() { + test('absent 不落键 / clear 落 null / value 落值', () { + final json = {}; + const PatchField.absent().writeTo(json, 'a'); + expect(json.containsKey('a'), isFalse, reason: 'absent 必须连键都不出现'); + + const PatchField.clear().writeTo(json, 'b'); + expect(json.containsKey('b'), isTrue); + expect(json['b'], isNull); + + const PatchField.value('x').writeTo(json, 'c'); + expect(json['c'], 'x'); + }); + + test('absent 与 clear 的 valueOrNull 同为 null,只能靠 isPresent 区分', () { + const absent = PatchField.absent(); + const clear = PatchField.clear(); + expect(absent.valueOrNull, isNull); + expect(clear.valueOrNull, isNull); + expect(absent.isPresent, isFalse); + expect(clear.isPresent, isTrue); + expect(absent.isClear, isFalse); + expect(clear.isClear, isTrue); + expect(const PatchField.value('x').isClear, isFalse); + }); + + test('encode 只作用于有值态(clear 不调 encode,避免 null 解引用)', () { + final json = {}; + var encodeCalls = 0; + const PatchField.clear().writeTo( + json, + 'n', + encode: (value) { + encodeCalls += 1; + return value.toString(); + }, + ); + expect(json['n'], isNull); + expect(encodeCalls, 0); + + const PatchField.value(7).writeTo( + json, + 'm', + encode: (value) { + encodeCalls += 1; + return value.toString(); + }, + ); + expect(json['m'], '7'); + expect(encodeCalls, 1); + }); + + test('值相等即相等(absent 与 clear 不相等)', () { + expect( + const PatchField.value('a'), + const PatchField.value('a'), + ); + expect( + const PatchField.absent(), + isNot(const PatchField.clear()), + ); + }); +} diff --git a/test/core/widgets/avatar_upload_sheet_test.dart b/test/core/widgets/avatar_upload_sheet_test.dart new file mode 100644 index 0000000..0afde3f --- /dev/null +++ b/test/core/widgets/avatar_upload_sheet_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/media_direct_upload.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; + +import '../../helpers/community_test_helpers.dart'; +import '../../helpers/media_test_helpers.dart'; + +/// 头像上传 sheet 的六态呈现(状态机本身由 MediaUploader 单测覆盖, +/// 本文件只验「哪个阶段给用户看什么、能点什么」)。 +void main() { + late FakeCommunityRepository community; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + + setUp(() { + community = FakeCommunityRepository(); + picker = FakeMediaImagePicker([decodablePickedImage()]); + compressor = FakeMediaCompressor(); + direct = FakeDirectUploadClient(); + community.onCreateMediaUpload = (_) async => credentials(); + community.onCompleteMediaUpload = (assetId) async => + readyAsset(assetId: assetId); + }); + + MediaUploader build(MediaPurpose purpose) => MediaUploader( + repository: community, + purpose: purpose, + picker: picker, + compressor: compressor, + directUpload: direct, + maxImages: 1, + maxConcurrentUploads: 1, + ); + + /// 挂一个按钮拉起 sheet,并把 pop 结果记录下来。 + Future> pumpSheet( + WidgetTester tester, { + MediaPurpose purpose = MediaPurpose.userAvatar, + }) async { + final results = []; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + results.add( + await showAvatarUploadSheet( + context, + builder: build, + purpose: purpose, + ), + ); + }, + child: const Text('打开'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pump(); + return results; + } + + testWidgets('打开即自动拉起选择器(无需多点一次「选择图片」)', (tester) async { + picker.gate = Completer(); + await pumpSheet(tester); + await tester.pump(); + + expect(find.text('正在打开相册…'), findsOneWidget); + expect(picker.limits, [1], reason: 'maxImages=1 → 只取一张'); + + picker.gate!.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('上传中:线性进度 + 百分比;ready 后给预览与「使用这张」', (tester) async { + direct.manual = true; + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.byType(LinearProgressIndicator), findsOneWidget); + final call = direct.calls.single; + call.emitProgress(30, 100); + await tester.pump(); + expect(find.text('上传中 30%'), findsOneWidget); + + call.succeed(); + await tester.pumpAndSettle(); + + expect(find.text('使用这张'), findsOneWidget); + expect(find.byType(Image), findsOneWidget, reason: 'ready 态给整幅预览'); + + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + expect(results.single, 'a-1', reason: '只交付 ready 态的 assetId(孤儿防护)'); + }); + + testWidgets('purpose 透传到 createUpload(宠物头像 = pet_avatar)', (tester) async { + await pumpSheet(tester, purpose: MediaPurpose.petAvatar); + await tester.pumpAndSettle(); + + expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar); + expect(community.lastMediaUploadRequest!.purpose.wire, 'pet_avatar'); + }); + + testWidgets('可重试失败:给「重试」+「重新选择」,重试成功后可确认', (tester) async { + direct.scriptedOutcomes.add( + const MediaDirectUploadException(message: '断网'), + ); + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('网络中断,上传失败'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect(find.text('重新选择'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('使用这张'), findsOneWidget); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + expect(results.single, 'a-1'); + }); + + testWidgets('不可重试失败(压缩后仍超限):只给「重新选择」,不给「重试」', (tester) async { + compressor.sizePerQuality = {80: 20 * 1024 * 1024, 60: 15 * 1024 * 1024}; + await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('图片过大,压缩后仍超过 10 MB'), findsOneWidget); + expect(find.text('重试'), findsNothing, reason: '重试同一张必然再失败,给重试钮是误导'); + expect(find.text('重新选择'), findsOneWidget); + }); + + testWidgets('用户在系统选择器里取消 → 空态可再次选择,pop 结果为 null', (tester) async { + picker = FakeMediaImagePicker(const []); + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('还没有选择图片'), findsOneWidget); + expect(find.text('选择图片'), findsOneWidget); + + // 关闭 sheet:未走到 ready,不交付任何 assetId。 + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(results.single, isNull); + }); +} diff --git a/test/features/profile/profile_controller_test.dart b/test/features/profile/profile_controller_test.dart new file mode 100644 index 0000000..fff60f7 --- /dev/null +++ b/test/features/profile/profile_controller_test.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository community; + + setUp(() { + community = FakeCommunityRepository(); + }); + + ProfileController build(FakeAuthRepository auth) => + ProfileController(authRepository: auth, communityRepository: community); + + test('四态:initial → loading → ready(资料 + 两块统计齐备)', () async { + final gate = Completer(); + final controller = build(FakeAuthRepository(meHandler: () => gate.future)); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + + expect(controller.phase, ProfileLoadPhase.initial); + final pending = controller.refresh(); + expect(controller.phase, ProfileLoadPhase.loading); + + gate.complete(buildProfile(nickname: '小柴')); + await pending; + + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.statsPhase, ProfileStatsPhase.ready); + expect(controller.displayName, '小柴'); + expect(controller.communityStats!.receivedLikeCount, 128); + expect(controller.followStats!.followerCount, 24); + }); + + test('/me 失败且无副本 → error 态 + 可重试;不外抛', () async { + var attempts = 0; + final auth = FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException('断网'); + return buildProfile(nickname: '小柴'); + }, + ); + final controller = build(auth); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.error); + expect(controller.lastError, isA()); + expect(controller.profile, isNull); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.displayName, '小柴'); + }); + + test('已有副本时刷新失败保留副本(不打断阅读,停在 ready)', () async { + var attempts = 0; + final controller = build( + FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) return buildProfile(nickname: '小柴'); + throw const ApiNetworkException('断网'); + }, + ), + ); + + await controller.refresh(); + await controller.refresh(); + + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.displayName, '小柴'); + expect(controller.lastError, isA()); + }); + + test('统计失败只降级统计块,资料仍 ready;refreshStats 可单独重试', () async { + var statsAttempts = 0; + community.onGetMyCommunityStats = () async { + statsAttempts += 1; + if (statsAttempts == 1) throw const ApiNetworkException('断网'); + return CommunityStats.fromJson(sampleCommunityStatsJson()); + }; + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + final controller = build(FakeAuthRepository()); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.statsPhase, ProfileStatsPhase.error); + expect(controller.communityStats, isNull); + + await controller.refreshStats(); + expect(controller.statsPhase, ProfileStatsPhase.ready); + expect(controller.phase, ProfileLoadPhase.ready); + }); + + test('空数据:两个数字为 0(不是 null、不是 404)', () async { + community.onGetMyCommunityStats = () async => CommunityStats.fromJson( + sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0), + ); + community.onGetFollowStats = (_) async => FollowStats.fromJson( + sampleFollowStatsJson(followerCount: 0, followingCount: 0), + ); + final controller = build(FakeAuthRepository()); + + await controller.refresh(); + + expect(controller.communityStats!.receivedLikeCount, 0); + expect(controller.communityStats!.publishedPostCount, 0); + }); + + test('save:空 patch 直接短路,不发请求(服务端对空 patch 答 400)', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => buildProfile(nickname: '不该被调用'), + ); + final controller = build(auth); + await controller.refresh(); + + await controller.save(const UpdateMeRequest()); + + expect(auth.updateMePayloads, isEmpty); + }); + + test('save 成功即以服务端回显替换副本(含 avatarUrl 现签值)', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => buildProfile( + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=new', + ), + ); + final controller = build(auth); + await controller.refresh(); + expect(controller.displayName, 'llx'); + + await controller.save( + const UpdateMeRequest(nickname: PatchField.value('小柴')), + ); + + expect(controller.displayName, '小柴'); + expect(controller.profile!.avatarUrl, contains('X-Amz-Signature=new')); + expect(auth.updateMePayloads.single, {'nickname': '小柴'}); + }); + + test('save 失败按类型化异常外抛(供编辑页分层呈现),副本不动', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => throw const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'not ready', + ), + ); + final controller = build(auth); + await controller.refresh(); + + await expectLater( + controller.save( + const UpdateMeRequest(avatarAssetId: PatchField.value('a-1')), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + ApiCodes.mediaNotReady, + ), + ), + ); + expect(controller.displayName, 'llx'); + }); + + test('reset:登出清空资料与统计,回 initial(跨账号不泄漏)', () async { + final controller = build(FakeAuthRepository()); + await controller.refresh(); + expect(controller.profile, isNotNull); + + controller.reset(); + + expect(controller.phase, ProfileLoadPhase.initial); + expect(controller.profile, isNull); + expect(controller.displayName, isNull); + expect(controller.communityStats, isNull); + expect(controller.followStats, isNull); + }); + + test('follow-stats 用的是本人 userId(路径主体正确)', () async { + final controller = build( + FakeAuthRepository(meHandler: () async => buildProfile(userId: 'u-42')), + ); + await controller.refresh(); + expect(community.calls, contains('followStats:u-42')); + expect(community.calls, contains('communityStats')); + }); +} diff --git a/test/features/profile/profile_edit_page_test.dart b/test/features/profile/profile_edit_page_test.dart new file mode 100644 index 0000000..ee9ada6 --- /dev/null +++ b/test/features/profile/profile_edit_page_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; +import '../../helpers/media_test_helpers.dart'; + +/// T3.5-08 编辑页:**PATCH 三态载荷**是本文件的重点。 +/// +/// 每个成功用例都断言一次「未改字段的键不出现在 JSON 里」——这是三态与 +/// 两态的唯一可观测差别,也是「只改昵称却把头像清掉」这类事故的唯一防线。 + +/// 昵称输入框(AppTextField 内层 TextFormField)。 +Finder get nicknameField => find.byType(TextFormField); + +void main() { + late FakeCommunityRepository community; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + + setUp(() { + community = FakeCommunityRepository(); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + picker = FakeMediaImagePicker([decodablePickedImage()]); + compressor = FakeMediaCompressor(); + direct = FakeDirectUploadClient(); + community.onCreateMediaUpload = (_) async => credentials(); + community.onCompleteMediaUpload = (assetId) async => + readyAsset(assetId: assetId); + }); + + MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader( + repository: community, + purpose: purpose, + picker: picker, + compressor: compressor, + directUpload: direct, + maxImages: 1, + maxConcurrentUploads: 1, + ); + + /// 直接 push 编辑页(资料页链路另有测试;这里只测编辑页本身)。 + Future pumpEdit( + WidgetTester tester, { + String? nickname, + String? avatarUrl, + bool withUploader = true, + ApiException? saveError, + }) async { + tester.view.physicalSize = const Size(700, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final auth = FakeAuthRepository( + meHandler: () async => buildProfile( + username: 'llx', + nickname: nickname, + avatarUrl: avatarUrl, + ), + updateMeHandler: (_) async { + if (saveError != null) throw saveError; + return buildProfile(username: 'llx', nickname: nickname); + }, + ); + final controller = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(controller.dispose); + await controller.refresh(); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: ProfileEditPage( + controller: controller, + avatarUploaderBuilder: withUploader ? buildUploader : null, + ), + ), + ); + await tester.pumpAndSettle(); + return auth; + } + + group('三态载荷', () { + testWidgets('只改昵称 → 载荷只有 nickname,avatarAssetId 键不出现', (tester) async { + final auth = await pumpEdit( + tester, + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload, {'nickname': '小柴'}); + expect( + payload.containsKey('avatarAssetId'), + isFalse, + reason: '发成 avatarAssetId: null 会把用户已有头像一起清掉', + ); + }); + + testWidgets('点「清除昵称」 → nickname 显式 null,头像键不出现', (tester) async { + final auth = await pumpEdit( + tester, + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.tap(find.text('清除昵称')); + await tester.pumpAndSettle(); + expect(find.text('保存后将清除昵称,展示名回退为用户名'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload.containsKey('nickname'), isTrue); + expect(payload['nickname'], isNull); + expect(payload.containsKey('avatarAssetId'), isFalse); + }); + + testWidgets('点「清除头像」 → avatarAssetId 显式 null,nickname 键不出现', ( + tester, + ) async { + final auth = await pumpEdit( + tester, + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.tap(find.text('清除头像')); + await tester.pumpAndSettle(); + expect(find.text('保存后将移除头像'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload.containsKey('avatarAssetId'), isTrue); + expect(payload['avatarAssetId'], isNull); + expect( + payload.containsKey('nickname'), + isFalse, + reason: '昵称没碰过就不该出现在载荷里(更不该是 null)', + ); + }); + + testWidgets('输入与原昵称相同 → 视为未改,保存钮禁用、不发请求', (tester) async { + final auth = await pumpEdit(tester, nickname: '小柴'); + + // 预填即原值。 + expect(find.text('小柴'), findsOneWidget); + final button = tester.widget( + find.widgetWithText(FilledButton, '保存'), + ); + expect(button.onPressed, isNull); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('无昵称时预填空串(不预填 username,避免把回退固化成真昵称)', (tester) async { + await pumpEdit(tester); + + final field = tester.widget(nicknameField); + expect(field.controller!.text, ''); + expect(find.text('未设置,当前展示为用户名「llx」'), findsOneWidget); + // 无昵称时不给「清除昵称」入口(清一个不存在的东西无意义)。 + expect(find.text('清除昵称'), findsNothing); + }); + }); + + group('校验与失败语义', () { + testWidgets('33 码点昵称:字段级 errorText,不发请求', (tester) async { + final auth = await pumpEdit(tester); + + await tester.enterText(nicknameField, '柴' * 33); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('昵称最多 32 个字'), findsOneWidget); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('纯空格昵称:不是隐式清空,提示走显式清除入口', (tester) async { + final auth = await pumpEdit(tester, nickname: '小柴'); + + await tester.enterText(nicknameField, ' '); + await tester.pump(); + // 空白输入被判为「不改」,保存钮禁用(不会误发清空)。 + final button = tester.widget( + find.widgetWithText(FilledButton, '保存'), + ); + expect(button.onPressed, isNull); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('42203 头像未就绪 → 横幅提示,页面留在编辑态可重试', (tester) async { + await pumpEdit( + tester, + saveError: const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'asset uploading', + ), + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget); + expect(find.byType(ProfileEditPage), findsOneWidget); + }); + + testWidgets('40405 头像 asset 失效 → 提示重新上传', (tester) async { + await pumpEdit( + tester, + saveError: const ApiBusinessException( + code: ApiCodes.mediaNotFound, + message: 'gone', + ), + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('头像已失效,请重新上传'), findsOneWidget); + }); + }); + + group('头像上传接线', () { + testWidgets('上传全链路:purpose=user_avatar,ready 后确认 → 载荷带新 assetId', ( + tester, + ) async { + final auth = await pumpEdit(tester); + + await tester.tap(find.text('更换头像')); + await tester.pumpAndSettle(); + + // 上传成功后 sheet 给预览 + 「使用这张」。 + expect(find.text('使用这张'), findsOneWidget); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + // 用途即引用侧的类型检查:必须是 user_avatar。 + final request = community.lastMediaUploadRequest!; + expect(request.purpose, MediaPurpose.userAvatar); + expect(request.purpose.wire, 'user_avatar'); + + expect(find.text('已选择\n新头像'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload, {'avatarAssetId': 'a-1'}); + expect(payload.containsKey('nickname'), isFalse); + }); + + testWidgets('上传后再改昵称 → 两键齐出(一次 PATCH 改两样)', (tester) async { + final auth = await pumpEdit(tester); + + await tester.tap(find.text('更换头像')); + await tester.pumpAndSettle(); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(auth.updateMePayloads.single, { + 'nickname': '小柴', + 'avatarAssetId': 'a-1', + }); + }); + }); +} diff --git a/test/features/profile/profile_models_test.dart b/test/features/profile/profile_models_test.dart new file mode 100644 index 0000000..c8ec3a3 --- /dev/null +++ b/test/features/profile/profile_models_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + group('展示名回退 nickname ?? username', () { + test('有昵称:展示昵称,nickname 字段保留 DB 原值', () { + final profile = buildProfile(username: 'llx', nickname: '小柴'); + expect(profile.displayName, '小柴'); + expect(profile.nickname, '小柴'); + }); + + test('无昵称:展示 username,但 nickname 仍为 null(不被回退值污染)', () { + final profile = buildProfile(username: 'llx'); + expect(profile.displayName, 'llx'); + expect( + profile.nickname, + isNull, + reason: '回退只在展示层发生;nickname 必须保持 null,否则编辑页预填后会被固化成真昵称', + ); + }); + + test('hasAvatar 只看 avatarUrl(响应不外露 avatarAssetId)', () { + expect(buildProfile().hasAvatar, isFalse); + expect( + buildProfile(avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s').hasAvatar, + isTrue, + ); + }); + }); + + group('UpdateMeRequest 三态载荷', () { + test('什么都没碰 → isEmpty 且 JSON 为空对象', () { + const request = UpdateMeRequest(); + expect(request.isEmpty, isTrue); + expect(request.toJson(), isEmpty); + }); + + test('只改昵称:avatarAssetId 键**不出现**(不改,而不是清空)', () { + const request = UpdateMeRequest(nickname: PatchField.value('小柴')); + final json = request.toJson(); + expect(json, {'nickname': '小柴'}); + expect( + json.containsKey('avatarAssetId'), + isFalse, + reason: '发成 avatarAssetId: null 会把用户头像一起清掉', + ); + }); + + test('只清昵称:nickname 显式 null,头像键仍不出现', () { + const request = UpdateMeRequest(nickname: PatchField.clear()); + final json = request.toJson(); + expect(json.containsKey('nickname'), isTrue); + expect(json['nickname'], isNull); + expect(json.containsKey('avatarAssetId'), isFalse); + }); + + test('一次改两样:两键齐出', () { + const request = UpdateMeRequest( + nickname: PatchField.value('小柴'), + avatarAssetId: PatchField.value('asset-9'), + ); + expect(request.toJson(), {'nickname': '小柴', 'avatarAssetId': 'asset-9'}); + expect(request.isEmpty, isFalse); + }); + + test('只清头像:nickname 键不出现', () { + const request = UpdateMeRequest( + avatarAssetId: PatchField.clear(), + ); + final json = request.toJson(); + expect(json.containsKey('nickname'), isFalse); + expect(json['avatarAssetId'], isNull); + }); + }); + + group('昵称校验与 ck_users_nickname 对齐', () { + test('1 码点通过;32 CJK 通过;33 拒', () { + expect(validateNickname('柴'), isNull); + expect(validateNickname('柴' * 32), isNull); + expect(validateNickname('柴' * 33), isNotNull); + }); + + test('32 个 emoji(UTF-16 长度 64)通过——长度按码点计而非 String.length', () { + const emoji = '🐕'; + final name = emoji * 32; + expect(name.length, 64, reason: '前提:UTF-16 长度确为 64'); + expect( + validateNickname(name), + isNull, + reason: '按 String.length 校验会误拒数据库能存的 32 emoji 昵称', + ); + expect(validateNickname(emoji * 33), isNotNull); + }); + + test('btrim 对齐:前后空格不计入长度', () { + expect(validateNickname(' 豆豆 '), isNull); + expect(validateNickname(' ${'柴' * 32} '), isNull); + }); + + test('纯空白不是隐式清空,而是校验失败并指向显式清除入口', () { + final message = validateNickname(' '); + expect(message, isNotNull); + expect(message, contains('清除昵称')); + }); + }); + + group('错误文案分层', () { + test('保存失败按 PATCH /me 错误谱分层', () { + expect( + profileSaveErrorMessage( + const ApiBusinessException(code: ApiCodes.paramError, message: 'x'), + ), + '填写内容不符合要求,请检查后重试', + ); + expect( + profileSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotFound, + message: 'x', + ), + ), + '头像已失效,请重新上传', + ); + expect( + profileSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'x', + ), + ), + '头像还没上传完,请稍后重试', + ); + expect( + profileSaveErrorMessage(const ApiNetworkException()), + '网络异常,请检查网络后重试', + ); + }); + + test('加载失败文案不透出服务端 message', () { + expect( + profileLoadErrorMessage( + const ApiNetworkException('Connection refused'), + ), + '网络异常,请检查网络后重试', + ); + expect(profileLoadErrorMessage(null), '加载失败,请稍后重试'); + }); + }); +} diff --git a/test/features/profile/profile_page_test.dart b/test/features/profile/profile_page_test.dart new file mode 100644 index 0000000..9667f28 --- /dev/null +++ b/test/features/profile/profile_page_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; +import 'package:patbond_flutter/features/profile/profile_page.dart'; +import 'package:patbond_flutter/state/app_state.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository community; + + setUp(() { + community = FakeCommunityRepository(); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + }); + + Future pumpProfile( + WidgetTester tester, { + required FakeAuthRepository auth, + }) async { + // 资料页头部 + 五行菜单 + 两个底部按钮,加高视口保证全在栏内。 + tester.view.physicalSize = const Size(700, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final controller = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: ProfilePage(appState: AppState(), controller: controller), + ), + ), + ); + return controller; + } + + testWidgets('loading 态:居中转圈,无 demo 文案', (tester) async { + final gate = Completer(); + await pumpProfile( + tester, + auth: FakeAuthRepository(meHandler: () => gate.future), + ); + // 预取推到帧末,故先走一帧再断言。 + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('萌宠新手(豆豆家长)'), findsNothing); + expect(find.text('Patbond 社区创作达人'), findsNothing); + + gate.complete(buildProfile()); + await tester.pumpAndSettle(); + }); + + testWidgets('ready + 有昵称:展示昵称与 @username,四个数字来自服务端', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget); + expect(find.text('@llx'), findsOneWidget); + // 24 关注我 / 7 我关注 / 128 获赞 / 12 作品(helpers 样本)。 + expect(find.text('24'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('128'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('获赞'), findsOneWidget); + expect(find.text('我的作品'), findsOneWidget); + // demo 硬编码彻底退役。 + expect(find.text('1.8k'), findsNothing); + expect(find.text('萌宠新手(豆豆家长)'), findsNothing); + }); + + testWidgets('ready + 无昵称:展示名回退 username(回退在客户端展示层)', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + ), + ); + await tester.pumpAndSettle(); + + // 展示名与 @username 两处都是 llx。 + expect(find.text('llx'), findsOneWidget); + expect(find.text('@llx'), findsOneWidget); + }); + + testWidgets('空数据:四个数字全 0(不是空白、不是错误态)', (tester) async { + community.onGetMyCommunityStats = () async => CommunityStats.fromJson( + sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0), + ); + community.onGetFollowStats = (_) async => FollowStats.fromJson( + sampleFollowStatsJson(followerCount: 0, followingCount: 0), + ); + await pumpProfile(tester, auth: FakeAuthRepository()); + await tester.pumpAndSettle(); + + expect(find.text('0'), findsNWidgets(4)); + expect(find.text('统计加载失败'), findsNothing); + }); + + testWidgets('error 态:横幅 + 重试;重试成功进 ready', (tester) async { + var attempts = 0; + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException('断网'); + return buildProfile(nickname: '小柴'); + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget); + expect(find.byType(InlineErrorBanner), findsNothing); + }); + + testWidgets('统计块独立降级:资料照常显示,统计给「加载失败 + 重试」', (tester) async { + var statsAttempts = 0; + community.onGetMyCommunityStats = () async { + statsAttempts += 1; + if (statsAttempts == 1) throw const ApiNetworkException('断网'); + return CommunityStats.fromJson(sampleCommunityStatsJson()); + }; + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(nickname: '小柴'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget, reason: '统计失败不该拖垮整页'); + expect(find.text('统计加载失败'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('128'), findsOneWidget); + expect(find.text('统计加载失败'), findsNothing); + }); + + testWidgets('点「编辑资料」进编辑页;保存成功回资料页并提示', (tester) async { + final auth = FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + updateMeHandler: (_) async => + buildProfile(username: 'llx', nickname: '小柴'), + ); + await pumpProfile(tester, auth: auth); + await tester.pumpAndSettle(); + + await tester.tap(find.text('编辑资料')); + await tester.pumpAndSettle(); + expect(find.byType(ProfileEditPage), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.byType(ProfileEditPage), findsNothing); + expect(find.text('资料已更新'), findsOneWidget); + // 头部随之更新为新昵称。 + expect(find.text('小柴'), findsOneWidget); + }); + + testWidgets('未装配上传能力时编辑页不渲染头像入口', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository(meHandler: () async => buildProfile()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('编辑资料')); + await tester.pumpAndSettle(); + + expect(find.text('更换头像'), findsNothing); + expect(find.byType(TextFormField), findsOneWidget, reason: '昵称输入仍在'); + }); +} diff --git a/test/helpers/auth_test_helpers.dart b/test/helpers/auth_test_helpers.dart index a2bb24b..b5bdff1 100644 --- a/test/helpers/auth_test_helpers.dart +++ b/test/helpers/auth_test_helpers.dart @@ -26,15 +26,23 @@ class FakeAuthRepository implements AuthRepository { this.loginHandler, this.registerHandler, this.restoreHandler, + this.meHandler, + this.updateMeHandler, }); final Future Function()? loginHandler; final Future Function()? registerHandler; final Future Function()? restoreHandler; + final Future Function()? meHandler; + final Future Function(UpdateMeRequest)? updateMeHandler; int loginCalls = 0; int registerCalls = 0; int logoutCalls = 0; + int meCalls = 0; + + /// 收到的 PATCH 载荷(三态断言用:**未改字段不应出现在 map 里**)。 + final List> updateMePayloads = []; @override Future login({required String username, required String password}) { @@ -64,9 +72,51 @@ class FakeAuthRepository implements AuthRepository { } @override - Future me() async => throw UnimplementedError(); + Future me() async { + meCalls += 1; + // 缺省给一个「无昵称无头像」的样本(回退到 username 的形态): + // 主壳级测试不必逐个注入。 + return meHandler?.call() ?? Future.value(buildProfile()); + } + + @override + Future updateMe(UpdateMeRequest request) async { + updateMePayloads.add(request.toJson()); + if (updateMeHandler == null) throw UnimplementedError(); + return updateMeHandler!(request); + } } +/// `/me` 响应样本(契约 v1.4.0 六字段全字段)。 +Map sampleMeJson({ + String userId = 'user-1', + String username = 'llx', + String? nickname, + String? avatarUrl, + String? phone = '+8613800138000', +}) => { + 'userId': userId, + 'username': username, + 'nickname': nickname, + 'avatarUrl': avatarUrl, + 'phone': phone, + 'createdAt': '2026-09-01T10:00:00+08:00', +}; + +UserProfile buildProfile({ + String userId = 'user-1', + String username = 'llx', + String? nickname, + String? avatarUrl, +}) => UserProfile.fromJson( + sampleMeJson( + userId: userId, + username: username, + nickname: nickname, + avatarUrl: avatarUrl, + ), +); + /// mock dio:用假 HttpClientAdapter 返回预设响应并记录请求。 class FakeHttpAdapter implements HttpClientAdapter { FakeHttpAdapter(this.handler); diff --git a/test/helpers/community_test_helpers.dart b/test/helpers/community_test_helpers.dart index 38387e0..89c3f20 100644 --- a/test/helpers/community_test_helpers.dart +++ b/test/helpers/community_test_helpers.dart @@ -201,6 +201,7 @@ class FakeCommunityRepository implements CommunityRepository { Future Function(String commentId)? onDeleteComment; Future Function(String userId, bool target)? onFollowToggle; Future Function(String userId)? onGetFollowStats; + Future Function()? onGetMyCommunityStats; Future Function(CreateMediaUploadRequest request)? onCreateMediaUpload; Future Function(String assetId)? onCompleteMediaUpload; @@ -213,6 +214,9 @@ class FakeCommunityRepository implements CommunityRepository { /// createPost 收到的幂等键顺序(同键重放断言用)。 final List idempotencyKeys = []; + /// 最近一次 createMediaUpload 的请求(purpose 断言用)。 + CreateMediaUploadRequest? lastMediaUploadRequest; + @override Future> getFeed({int? limit, String? cursor}) { calls.add('feed:cursor=$cursor'); @@ -254,6 +258,7 @@ class FakeCommunityRepository implements CommunityRepository { CreateMediaUploadRequest request, ) { calls.add('createUpload:${request.mimeType}:${request.byteSize}'); + lastMediaUploadRequest = request; return onCreateMediaUpload!(request); } @@ -338,10 +343,44 @@ class FakeCommunityRepository implements CommunityRepository { @override Future getFollowStats(String userId) { calls.add('followStats:$userId'); - return onGetFollowStats!(userId); + return onGetFollowStats?.call(userId) ?? + Future.value(FollowStats.fromJson(sampleFollowStatsJson())); + } + + @override + Future getMyCommunityStats() { + calls.add('communityStats'); + return onGetMyCommunityStats?.call() ?? + // 缺省零值(契约:空数据返回 0 而非 null,且永不 404)。 + Future.value( + CommunityStats.fromJson( + sampleCommunityStatsJson( + receivedLikeCount: 0, + publishedPostCount: 0, + ), + ), + ); } @override Future> listMyBookmarks({int? limit, String? cursor}) => throw UnimplementedError(); } + +Map sampleCommunityStatsJson({ + int receivedLikeCount = 128, + int publishedPostCount = 12, +}) => { + 'receivedLikeCount': receivedLikeCount, + 'publishedPostCount': publishedPostCount, +}; + +Map sampleFollowStatsJson({ + int followerCount = 24, + int followingCount = 7, + bool followedByMe = false, +}) => { + 'followerCount': followerCount, + 'followingCount': followingCount, + 'followedByMe': followedByMe, +}; diff --git a/test/smoke/detail_interactions_smoke_test.dart b/test/smoke/detail_interactions_smoke_test.dart index 86ae478..9ce510e 100644 --- a/test/smoke/detail_interactions_smoke_test.dart +++ b/test/smoke/detail_interactions_smoke_test.dart @@ -252,6 +252,9 @@ class _SwitchableRepository implements CommunityRepository { Future> listMyBookmarks({int? limit, String? cursor}) => target.listMyBookmarks(limit: limit, cursor: cursor); + @override + Future getMyCommunityStats() => target.getMyCommunityStats(); + @override Future followUser(String userId) => target.followUser(userId); -- 2.43.0 From eff352684016bed1579369cee1037696a9228f47 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 15:37:25 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=AE=A0?= =?UTF-8?q?=E7=89=A9=E5=A4=B4=E5=83=8F=E6=8E=A5=E7=BA=BF=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=93=85=E7=AC=94=E8=A7=92=E6=A0=87=E6=8E=A5=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E3=80=81=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6=E6=83=85=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E7=9C=9F=E5=AE=9E=E5=A4=B4=E5=83=8F=EF=BC=88T3.5-09?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退 爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。 - 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有 头像时先给「更换 / 移除」二选一,移除即三态显式 null。 - 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像, viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发 `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严 的一半定档,caregiver 立刻 403。 - `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持 M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。 - 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的 覆盖操作,不该自动生效两次)。 测试 588 → 597(+9)。 Co-Authored-By: Claude Fable 5 --- lib/core/widgets/pet_avatar.dart | 11 +- lib/features/pets/pet_detail_page.dart | 119 ++++++- lib/features/pets/pet_display.dart | 18 + lib/features/pets/pet_models.dart | 61 +++- lib/features/pets/pets_page.dart | 9 +- .../features/pets/pet_avatar_wiring_test.dart | 323 ++++++++++++++++++ test/helpers/pet_test_helpers.dart | 3 +- 7 files changed, 519 insertions(+), 25 deletions(-) create mode 100644 test/features/pets/pet_avatar_wiring_test.dart diff --git a/lib/core/widgets/pet_avatar.dart b/lib/core/widgets/pet_avatar.dart index 309a4f3..ec5eaab 100644 --- a/lib/core/widgets/pet_avatar.dart +++ b/lib/core/widgets/pet_avatar.dart @@ -28,9 +28,12 @@ enum PetAvatarSize { /// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典 /// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。 /// -/// M2 首版不做头像上传(ADR-010 / D2-1 裁决),[url] 为 null 时渲染 -/// 本地占位形态(`surfaceTint` 底 + `Icons.pets`);有图时经 -/// [RemoteImage] 加载(loading / 失败兜底由其内置)。 +/// M2 首版不做头像上传(ADR-010 / D2-1 裁决);T3.5-09 起 [url] 由服务端 +/// `Pet.avatarUrl`(预签名 GET)驱动,[url] 为 null 时仍渲染本地占位形态 +/// (`surfaceTint` 底 + `Icons.pets`)——签一个必然 404 的 URL 比返回 null +/// 更糟,故服务端对非 ready 的 asset 直接给 null,客户端只有一种占位逻辑。 +/// 有图时经 [RemoteImage] 加载(缓存 key 已剥签名参数;loading / 失败兜底 +/// 由其内置)。 class PetAvatar extends StatelessWidget { const PetAvatar({ required this.size, @@ -45,7 +48,7 @@ class PetAvatar extends StatelessWidget { final PetAvatarSize size; - /// 头像图 URL;null 为本地占位形态(M2 默认)。 + /// 头像图 URL;null 为本地占位形态(无头像 / asset 非 ready / 存储未配置)。 final String? url; /// 可点击时整体 [InkWell] 圆形 ripple。 diff --git a/lib/features/pets/pet_detail_page.dart b/lib/features/pets/pet_detail_page.dart index 4be2788..ea7ed1a 100644 --- a/lib/features/pets/pet_detail_page.dart +++ b/lib/features/pets/pet_detail_page.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/pets/care_reminders_page.dart'; import 'package:patbond_flutter/features/pets/health_events_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; @@ -43,6 +45,7 @@ class PetDetailPage extends StatefulWidget { super.key, this.analytics, this.healthAnalytics, + this.avatarUploaderBuilder, }); final PetsController controller; @@ -50,6 +53,10 @@ class PetDetailPage extends StatefulWidget { final PetAnalytics? analytics; final HealthRecordAnalytics? healthAnalytics; + /// 头像上传编排器构造口(T3.5-09);null 即本次构建未装配上传能力, + /// 隐藏头像编辑入口(生产装配恒注入,见 `app.dart`)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + @override State createState() => _PetDetailPageState(); } @@ -186,6 +193,109 @@ class _PetDetailPageState extends State { /// 记录写入权限档 WRITE(owner + caregiver);viewer 隐藏录入入口。 bool get _canWriteRecords => _pet?.myRole != PetRole.viewer; + /// 头像写入权限档 **WRITE**(ADR-022 决策 D3.5-3:头像属日常照护信息, + /// 与体重/疫苗同档,owner + caregiver 均可改;viewer 只读)。 + /// + /// 注意这与 [_canEdit](MANAGE,仅 owner)**刻意不同**:服务端按「本次 + /// 请求触及了哪些字段」定档——只带 avatarAssetId 走 WRITE,碰任一资料 + /// 字段即 MANAGE。故这里只发纯头像 PATCH,不夹带任何资料字段。 + bool get _canEditAvatar => + _phase == _DetailPhase.ready && + _pet?.myRole != PetRole.viewer && + widget.avatarUploaderBuilder != null; + + /// 头像入口:已有头像时先给「更换 / 移除」二选一,没有则直接拉起上传。 + Future _onAvatarTap() async { + final pet = _pet; + if (pet == null || !_canEditAvatar) return; + if (pet.avatarUrl == null) { + await _uploadAvatar(); + return; + } + final action = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('更换头像'), + onTap: () => Navigator.pop(context, 'replace'), + ), + ListTile( + leading: const Icon( + Icons.delete_outline, + color: AppColors.errorDark, + ), + title: const Text( + '移除头像', + style: TextStyle(color: AppColors.errorDark), + ), + onTap: () => Navigator.pop(context, 'clear'), + ), + ], + ), + ), + ); + if (!mounted || action == null) return; + if (action == 'replace') { + await _uploadAvatar(); + } else { + // 三态「显式 null」= 清除头像(缺省是「不改」,两者在 JSON 上不同)。 + await _patchAvatar(const PatchField.clear(), '已移除头像'); + } + } + + Future _uploadAvatar() async { + final builder = widget.avatarUploaderBuilder; + if (builder == null) return; + final assetId = await showAvatarUploadSheet( + context, + builder: builder, + // 用途即引用侧的类型检查:帖图或用户头像挂到宠物上会被答 404/40405。 + purpose: MediaPurpose.petAvatar, + ); + if (assetId == null || !mounted) return; + await _patchAvatar(PatchField.value(assetId), '头像已更新'); + } + + /// 纯头像 PATCH:请求体只有 `version` + `avatarAssetId`,**不带任何资料 + /// 字段**——夹带资料字段会把权限档从 WRITE 抬到 MANAGE,caregiver 立刻 + /// 403(服务端刻意按更严的一半判,堵「夹带改名」)。 + Future _patchAvatar(PatchField intent, String okMessage) async { + final pet = _pet; + if (pet == null) return; + try { + final updated = await widget.controller.updatePet( + pet.id, + UpdatePetRequest(version: pet.version, avatarAssetId: intent), + ); + if (!mounted) return; + setState(() { + _pet = updated; + _phase = _DetailPhase.ready; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(okMessage))); + } on PetVersionConflictException catch (error) { + // 40902:他人已改过这行。重取档案拿新 version,让用户自行决定是否重来 + // (不静默重放——头像是用户可见的覆盖操作,不该自动生效两次)。 + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error)))); + await _load(); + } on ApiException catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error)))); + } + } + Future _openWeights(Pet pet) async { await Navigator.of(context).push( // 列表页页名不在字典 v2 枚举内(06 §5.2 验收 4:字典外不上报), @@ -306,11 +416,14 @@ class _PetDetailPageState extends State { children: [ Column( children: [ + // T3.5-09:头像展示真实预签名 URL(无图回退爪印占位);铅笔角标 + // 自此有功能——接 MediaUploader 上传 pet_avatar,WRITE 档可见。 PetAvatar( size: PetAvatarSize.xl, - showEditBadge: _canEdit, - onTap: _canEdit ? _openEdit : null, - semanticLabel: _canEdit ? '编辑宠物资料' : null, + url: pet.avatarUrl, + showEditBadge: _canEditAvatar, + onTap: _canEditAvatar ? _onAvatarTap : null, + semanticLabel: _canEditAvatar ? '更换宠物头像' : null, ), const SizedBox(height: 12), Text(pet.name, style: Theme.of(context).textTheme.headlineSmall), diff --git a/lib/features/pets/pet_display.dart b/lib/features/pets/pet_display.dart index 2f416b9..1ab84d8 100644 --- a/lib/features/pets/pet_display.dart +++ b/lib/features/pets/pet_display.dart @@ -54,3 +54,21 @@ String petLoadErrorMessage(ApiException? error) => switch (error) { ApiRateLimitException _ => '请求过于频繁,请稍后再试', _ => '加载失败,请稍后重试', }; + +/// 宠物头像写入失败的文案(T3.5-09)。按契约 v1.4.0 为 +/// `PATCH /pets/{petId}` 新增的两格分层: +/// +/// - 404/40405:asset 不存在 / 非本人 / 已删 / **用途不符**(帖图或用户头像 +/// 当宠物头像)→ 引导重新上传,而不是「宠物不存在」(40401 才是那个)。 +/// - 422/42203:本人的 `pet_avatar` asset 仍在 uploading/failed → 可重试。 +/// - 403/40300:viewer 改头像(入口本已按 myRole 隐藏,此处兜底防越权构造)。 +/// - 409/40902:版本冲突 → 由调用方重取档案后重试。 +String petAvatarSaveErrorMessage(ApiException? error) => switch (error) { + ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传', + ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试', + ApiBusinessException(code: ApiCodes.petAccessDenied) => '你没有修改该宠物头像的权限', + ApiBusinessException(code: ApiCodes.versionConflict) => '档案已被更新,请重新操作', + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '头像保存失败,请稍后重试', +}; diff --git a/lib/features/pets/pet_models.dart b/lib/features/pets/pet_models.dart index 8e51e14..68e76e5 100644 --- a/lib/features/pets/pet_models.dart +++ b/lib/features/pets/pet_models.dart @@ -1,8 +1,11 @@ -/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0, +/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.4.0, /// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException] /// 以便契约漂移在测试期暴露而非静默吞掉)。 library; +import 'package:patbond_flutter/core/models/patch_field.dart'; + +export 'package:patbond_flutter/core/models/patch_field.dart'; export 'package:patbond_flutter/core/models/cursor_page.dart'; /// 物种(创建即定,不可修改)。 @@ -143,6 +146,7 @@ class Pet { required this.microchipNo, required this.sterilizedOn, required this.status, + required this.avatarUrl, required this.myRole, required this.createdAt, required this.updatedAt, @@ -164,6 +168,9 @@ class Pet { microchipNo: json['microchipNo'] as String?, sterilizedOn: _dateOrNull(json['sterilizedOn']), status: PetStatus.fromJson(json['status'] as String), + // 契约 v1.4.0 新增:时效性预签名 GET URL,每次响应现签; + // 不得持久化、过期即重取。响应**不含 avatarAssetId**(只写不读)。 + avatarUrl: json['avatarUrl'] as String?, myRole: PetRole.fromJson(json['myRole'] as String), createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), @@ -184,6 +191,11 @@ class Pet { final String? microchipNo; final DateTime? sterilizedOn; final PetStatus status; + + /// 头像预签名 GET URL;无头像 / asset 非 ready / 对象存储未配置均为 null。 + /// 「有头像」等价于本字段非 null(契约不外露 assetId)。 + final String? avatarUrl; + final PetRole myRole; final DateTime createdAt; final DateTime updatedAt; @@ -230,8 +242,17 @@ class CreatePetRequest { }; } -/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null; -/// 品种对整体替换;species 不可改;version 乐观锁必填)。 +/// 更新宠物请求(部分更新:缺席字段不变;品种对整体替换;species 不可改; +/// version 乐观锁必填)。 +/// +/// **两态与三态并存**:除 [avatarAssetId] 外的字段沿 M2 两态语义(缺省或 +/// null 皆为「不改」,不支持清空回 null——它们的 CHECK 约束本就不允许空值); +/// [avatarAssetId] 是本 schema **唯一的三态字段**(契约 v1.4.0),因为 +/// 「删掉我设的那张头像」是一等公民操作,两态根本无法表达。 +/// +/// **权限随本次触及的字段变档**(ADR-022 + 服务端 `requiredLevel`): +/// 只带 [avatarAssetId] 走 WRITE(owner + caregiver 均可);碰任一资料字段 +/// 即 MANAGE(仅 owner);混合请求取更严的一半(防「夹带改名」)。 class UpdatePetRequest { const UpdatePetRequest({ required this.version, @@ -245,6 +266,7 @@ class UpdatePetRequest { this.microchipNo, this.sterilizedOn, this.status, + this.avatarAssetId = const PatchField.absent(), }); final int version; @@ -259,19 +281,26 @@ class UpdatePetRequest { final DateTime? sterilizedOn; final PetStatus? status; - Map toJson() => { - 'version': version, - if (name != null) 'name': name, - if (breedId != null) 'breedId': breedId, - if (customBreedName != null) 'customBreedName': customBreedName, - if (sex != null) 'sex': sex!.name, - if (birthDate != null) 'birthDate': dateToJson(birthDate!), - if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated, - if (personality != null) 'personality': personality, - if (microchipNo != null) 'microchipNo': microchipNo, - if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!), - if (status != null) 'status': status!.name, - }; + /// 宠物头像 asset(两步上传产物,`purpose` 须为 `pet_avatar`)。三态。 + final PatchField avatarAssetId; + + Map toJson() { + final json = { + 'version': version, + if (name != null) 'name': name, + if (breedId != null) 'breedId': breedId, + if (customBreedName != null) 'customBreedName': customBreedName, + if (sex != null) 'sex': sex!.name, + if (birthDate != null) 'birthDate': dateToJson(birthDate!), + if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated, + if (personality != null) 'personality': personality, + if (microchipNo != null) 'microchipNo': microchipNo, + if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!), + if (status != null) 'status': status!.name, + }; + avatarAssetId.writeTo(json, 'avatarAssetId'); + return json; + } } /// 品种目录项(只读字典)。 diff --git a/lib/features/pets/pets_page.dart b/lib/features/pets/pets_page.dart index 0cd3d49..f2cb8fb 100644 --- a/lib/features/pets/pets_page.dart +++ b/lib/features/pets/pets_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; @@ -25,6 +26,7 @@ class PetsPage extends StatefulWidget { super.key, this.analytics, this.healthAnalytics, + this.avatarUploaderBuilder, }); final PetsController controller; @@ -33,6 +35,9 @@ class PetsPage extends StatefulWidget { /// health_record 域埋点(T2-13,记录页面族透传)。 final HealthRecordAnalytics? healthAnalytics; + /// 头像上传编排器构造口(T3.5-09,透传详情页)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + @override State createState() => _PetsPageState(); } @@ -75,6 +80,7 @@ class _PetsPageState extends State { petId: pet.id, analytics: widget.analytics, healthAnalytics: widget.healthAnalytics, + avatarUploaderBuilder: widget.avatarUploaderBuilder, ), settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName), ), @@ -189,7 +195,8 @@ class _PetCard extends StatelessWidget { padding: const EdgeInsets.all(14), child: Row( children: [ - const PetAvatar(size: PetAvatarSize.lg), + // T3.5-09:列表卡展示真实头像(预签名 URL,无图回退爪印占位)。 + PetAvatar(size: PetAvatarSize.lg, url: pet.avatarUrl), const SizedBox(width: 12), Expanded( child: Column( diff --git a/test/features/pets/pet_avatar_wiring_test.dart b/test/features/pets/pet_avatar_wiring_test.dart new file mode 100644 index 0000000..b7c1ce8 --- /dev/null +++ b/test/features/pets/pet_avatar_wiring_test.dart @@ -0,0 +1,323 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; +import 'package:patbond_flutter/features/pets/pet_display.dart'; +import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_controller.dart'; +import 'package:patbond_flutter/features/pets/pets_page.dart'; + +import '../../helpers/community_test_helpers.dart'; +import '../../helpers/media_test_helpers.dart'; +import '../../helpers/pet_test_helpers.dart'; + +/// T3.5-09 宠物头像接线。 +/// +/// 两条不变量在这里守住: +/// 1. **权限按 WRITE 档呈现**(owner + caregiver 有入口,viewer 没有),与 +/// 资料编辑(MANAGE,仅 owner)不同档。 +/// 2. **纯头像 PATCH 不夹带资料字段**——夹带会把服务端定档抬到 MANAGE, +/// caregiver 立刻 403。 +void main() { + const signedAvatar = + 'https://minio.local/patbond-media/pet_avatar/2026/09/a-1' + '?X-Amz-Signature=sig'; + + late FakePetsRepository pets; + late PetsController controller; + late FakeCommunityRepository community; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + + setUp(() { + pets = FakePetsRepository(); + controller = PetsController(repository: pets); + community = FakeCommunityRepository(); + picker = FakeMediaImagePicker([decodablePickedImage()]); + compressor = FakeMediaCompressor(); + direct = FakeDirectUploadClient(); + community.onCreateMediaUpload = (_) async => credentials(); + community.onCompleteMediaUpload = (assetId) async => + readyAsset(assetId: assetId); + }); + + MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader( + repository: community, + purpose: purpose, + picker: picker, + compressor: compressor, + directUpload: direct, + maxImages: 1, + maxConcurrentUploads: 1, + ); + + Future pumpDetail( + WidgetTester tester, { + bool withUploader = true, + }) async { + tester.view.physicalSize = const Size(700, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const Scaffold(body: Text('列表基底')), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => PetDetailPage( + controller: controller, + petId: 'p-1', + avatarUploaderBuilder: withUploader ? buildUploader : null, + ), + ), + ), + ); + await tester.pump(); + } + + group('契约字段', () { + test('Pet.avatarUrl 解析(无头像为 null;不外露 avatarAssetId)', () { + expect(buildPet('p-1').avatarUrl, isNull); + expect( + buildPet('p-1', overrides: {'avatarUrl': signedAvatar}).avatarUrl, + signedAvatar, + ); + }); + + test('UpdatePetRequest:只带头像时载荷仅 version + avatarAssetId', () { + const request = UpdatePetRequest( + version: 3, + avatarAssetId: PatchField.value('a-1'), + ); + expect(request.toJson(), {'version': 3, 'avatarAssetId': 'a-1'}); + }); + + test('UpdatePetRequest:清除头像为显式 null;缺省则键不出现', () { + expect( + const UpdatePetRequest( + version: 3, + avatarAssetId: PatchField.clear(), + ).toJson(), + {'version': 3, 'avatarAssetId': null}, + ); + final absent = const UpdatePetRequest(version: 3, name: '豆豆').toJson(); + expect(absent, {'version': 3, 'name': '豆豆'}); + expect( + absent.containsKey('avatarAssetId'), + isFalse, + reason: '改名请求不该顺手把头像清掉', + ); + }); + + test('头像写入失败文案分层(40405 / 42203 与 40401 区分开)', () { + expect( + petAvatarSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotFound, + message: 'x', + ), + ), + '头像已失效,请重新上传', + ); + expect( + petAvatarSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'x', + ), + ), + '头像还没上传完,请稍后重试', + ); + expect( + petAvatarSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.petAccessDenied, + message: 'x', + ), + ), + '你没有修改该宠物头像的权限', + ); + }); + }); + + group('权限呈现(WRITE 档)', () { + testWidgets('owner:头像可点 + 铅笔角标', (tester) async { + pets.getPetHandler = (_) async => buildPet('p-1'); + await pumpDetail(tester); + await tester.pumpAndSettle(); + + final avatar = tester.widget(find.byType(PetAvatar)); + expect(avatar.showEditBadge, isTrue); + expect(avatar.onTap, isNotNull); + expect(avatar.semanticLabel, '更换宠物头像'); + }); + + testWidgets('caregiver:头像入口在(WRITE 档),但资料编辑入口不在(MANAGE 档)', (tester) async { + pets.getPetHandler = (_) async => + buildPet('p-1', overrides: {'myRole': 'caregiver'}); + await pumpDetail(tester); + await tester.pumpAndSettle(); + + final avatar = tester.widget(find.byType(PetAvatar)); + expect(avatar.showEditBadge, isTrue, reason: 'ADR-022:头像属 WRITE 档'); + expect(avatar.onTap, isNotNull); + expect(find.text('编辑资料'), findsNothing, reason: '改档案仍是 MANAGE 档'); + }); + + testWidgets('viewer:头像不可点、无角标', (tester) async { + pets.getPetHandler = (_) async => + buildPet('p-1', overrides: {'myRole': 'viewer'}); + await pumpDetail(tester); + await tester.pumpAndSettle(); + + final avatar = tester.widget(find.byType(PetAvatar)); + expect(avatar.showEditBadge, isFalse); + expect(avatar.onTap, isNull); + }); + + testWidgets('未装配上传能力:owner 也不渲染头像入口', (tester) async { + pets.getPetHandler = (_) async => buildPet('p-1'); + await pumpDetail(tester, withUploader: false); + await tester.pumpAndSettle(); + + final avatar = tester.widget(find.byType(PetAvatar)); + expect(avatar.showEditBadge, isFalse); + expect(avatar.onTap, isNull); + }); + }); + + group('上传与清除', () { + testWidgets('无头像 → 点头像直接上传 → PATCH 只带 version + avatarAssetId', ( + tester, + ) async { + pets.getPetHandler = (_) async => buildPet('p-1', version: 3); + UpdatePetRequest? captured; + pets.updatePetHandler = (petId, request) async { + captured = request; + return buildPet( + 'p-1', + version: 4, + overrides: {'avatarUrl': signedAvatar}, + ); + }; + await pumpDetail(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PetAvatar)); + await tester.pumpAndSettle(); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar); + expect(captured!.toJson(), {'version': 3, 'avatarAssetId': 'a-1'}); + expect(find.text('头像已更新'), findsOneWidget); + // 详情页头像随即换成服务端回显的现签 URL。 + expect( + tester.widget(find.byType(PetAvatar)).url, + signedAvatar, + ); + }); + + testWidgets('已有头像 → 点头像给「更换 / 移除」;移除发显式 null', (tester) async { + pets.getPetHandler = (_) async => + buildPet('p-1', version: 5, overrides: {'avatarUrl': signedAvatar}); + UpdatePetRequest? captured; + pets.updatePetHandler = (petId, request) async { + captured = request; + return buildPet('p-1', version: 6); + }; + await pumpDetail(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PetAvatar)); + await tester.pumpAndSettle(); + expect(find.text('更换头像'), findsOneWidget); + expect(find.text('移除头像'), findsOneWidget); + + await tester.tap(find.text('移除头像')); + await tester.pumpAndSettle(); + + final payload = captured!.toJson(); + expect(payload.containsKey('avatarAssetId'), isTrue); + expect(payload['avatarAssetId'], isNull); + expect(payload['version'], 5); + expect(payload.keys.length, 2, reason: '不得夹带任何资料字段(否则抬到 MANAGE 档)'); + expect(find.text('已移除头像'), findsOneWidget); + expect(tester.widget(find.byType(PetAvatar)).url, isNull); + }); + + testWidgets('40902 版本冲突:提示 + 自动重取档案(不静默重放)', (tester) async { + var getCalls = 0; + pets.getPetHandler = (_) async { + getCalls += 1; + return buildPet('p-1', version: getCalls == 1 ? 3 : 9); + }; + pets.updatePetHandler = (petId, request) async => + throw const PetVersionConflictException(message: 'stale'); + await pumpDetail(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PetAvatar)); + await tester.pumpAndSettle(); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + expect(find.text('档案已被更新,请重新操作'), findsOneWidget); + expect(getCalls, 2, reason: '冲突后重取档案拿新 version'); + }); + + testWidgets('42203 头像未就绪:SnackBar 提示,不改动头像', (tester) async { + pets.getPetHandler = (_) async => buildPet('p-1', version: 3); + pets.updatePetHandler = (petId, request) async => + throw const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'uploading', + ); + await pumpDetail(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PetAvatar)); + await tester.pumpAndSettle(); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget); + expect(tester.widget(find.byType(PetAvatar)).url, isNull); + }); + }); + + group('列表展示', () { + testWidgets('列表卡展示真实头像;无头像回退占位', (tester) async { + pets.listPetsHandler = () async => [ + buildPet('p-1', name: '豆豆', overrides: {'avatarUrl': signedAvatar}), + buildPet('p-2', name: '花花'), + ]; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: PetsPage(controller: controller)), + ), + ); + await tester.pumpAndSettle(); + + final avatars = tester + .widgetList(find.byType(PetAvatar)) + .toList(); + expect(avatars.length, 2); + expect(avatars[0].url, signedAvatar); + expect(avatars[1].url, isNull, reason: '无头像回退爪印占位(不显示破图)'); + }); + }); +} diff --git a/test/helpers/pet_test_helpers.dart b/test/helpers/pet_test_helpers.dart index 221a1f1..fe072db 100644 --- a/test/helpers/pet_test_helpers.dart +++ b/test/helpers/pet_test_helpers.dart @@ -1,4 +1,4 @@ -/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON) +/// pets 域测试样本(契约 openapi.yaml v1.4.0 各 schema 全字段 JSON) /// 与共享假仓库。 library; @@ -27,6 +27,7 @@ Map samplePetJson({ 'microchipNo': null, 'sterilizedOn': null, 'status': 'active', + 'avatarUrl': null, 'myRole': 'owner', 'createdAt': '2026-09-01T10:00:00+08:00', 'updatedAt': '2026-09-02T10:00:00+08:00', -- 2.43.0 From 694543647ff8436a393bed07f3df28dafdad94a7 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 15:37:48 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E9=97=AE=E5=80=99=E8=AF=AD=E7=9C=9F=E5=AE=9E=E5=8C=96?= =?UTF-8?q?=20+=20=E5=88=BB=E6=84=8F=E4=BF=9D=E7=95=99=E7=9A=84=20demo=20?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E6=98=BE=E5=BC=8F=E7=99=BB=E8=AE=B0=EF=BC=88?= =?UTF-8?q?T3.5-10=EF=BC=8CADR-022=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「下午好,豆豆」改真实展示名(`nickname ?? username`,与资料页共用同一个 ProfileController:一次 /me 供两个消费点,改昵称后两处一起变)。资料未到手 时退化为不称名的「下午好 👋」,不编造假名。 - 按 ADR-022 决策 D3.5-1 钉死范围,**其余首页 demo 一律不动**,并在代码里逐项 标注为「刻意保留的 demo 占位」:天气/位置(需接外部服务)、圈子=话题 (ADR-018 剪出)、促销卡(M5 服务域)、搜索与本地服务段。 widget 测试同时钉住「保留项仍在」,避免后续以「顺手清理」越出拍板范围。 - 新增 compose 真链路桌面实测脚本 `integration_test/profile_avatar_live_test.dart` (默认跳过,`PATBOND_PROFILE_LIVE=1 ... -d linux` 开启):UI 注册 → 登录 → 资料真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像,逐步截图。 只有选图与压缩两层是桌面替身,createUpload / 预签名 PUT / confirm / PATCH 全为生产实现。两处实测坑写进注释:Feed 作者名有服务端 60s 缓存滞后; 1×1 的极小图能上传能下载但 Flutter 解码器拒绝,会被误判成「没传上」。 Co-Authored-By: Claude Fable 5 --- .../profile_avatar_live_test.dart | 471 ++++++++++++++++++ lib/features/home/home_page.dart | 58 ++- test/features/home/home_greeting_test.dart | 144 ++++++ 3 files changed, 667 insertions(+), 6 deletions(-) create mode 100644 integration_test/profile_avatar_live_test.dart create mode 100644 test/features/home/home_greeting_test.dart diff --git a/integration_test/profile_avatar_live_test.dart b/integration_test/profile_avatar_live_test.dart new file mode 100644 index 0000000..9e032fd --- /dev/null +++ b/integration_test/profile_avatar_live_test.dart @@ -0,0 +1,471 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' show ImageByteFormat; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:patbond_flutter/app/app.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/core/widgets/post_card.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:patbond_flutter/features/community/media_compression.dart'; +import 'package:patbond_flutter/features/community/media_picking.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; + +/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。 +class _InMemoryTokenStore implements TokenStore { + final Map _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async => _values[key] = value; + + @override + Future delete(String key) async => _values.remove(key); +} + +/// 固定测试图:16×16 棋盘 PNG(87 字节)。 +/// +/// **刻意不用 1×1 的极小图**:1×1 的 PNG/JPEG 服务端照收、curl 也能原样取回, +/// 但走 `NetworkImage` 的解码路径会抛「Codec failed to produce an image」, +/// UI 于是落到 `RemoteImage` 的兜底占位——本机实测踩过这一格,很容易被误判 +/// 成「头像根本没传上去」。用一张最小的**多像素**图即可走通到真实像素。 +final Uint8List _pngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAHklEQVR42mOo0RV6Fc' + 'FDPMlAkmogyTBqw6gNQ8YGAKJxBJBXv4+TAAAAAElFTkSuQmCC', +); + +/// 桌面替身:选图直接给上面那张 JPEG(Linux 无 image_picker 原生实现)。 +class _DesktopPicker implements MediaImagePicker { + int calls = 0; + + @override + Future> pickImages({required int limit}) async { + calls += 1; + return [PickedMediaImage(bytes: _pngBytes, name: 'avatar.png')]; + } +} + +/// 桌面替身:压缩原样透传(Linux 无 flutter_image_compress 原生实现)。 +class _PassthroughCompressor implements MediaImageCompressor { + const _PassthroughCompressor(); + + @override + Future compress( + PickedMediaImage source, { + required int quality, + }) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png'); +} + +/// M3.5-08/09/10 compose 真链路桌面实测(默认跳过,不计入常规测试套件): +/// +/// ```bash +/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再: +/// PATBOND_PROFILE_LIVE=1 \ +/// flutter test integration_test/profile_avatar_live_test.dart -d linux +/// ``` +/// +/// 驱动**真实 App**(Linux 桌面 GTK 渲染 + 真实 HTTP + 真实 MinIO)走一遍: +/// UI 注册 → 首页问候语显示用户名 → 资料页显示真实用户名与零值统计 → +/// 设昵称 → 首页问候语与资料页同步变昵称 → Feed 作者名同步(验证服务端 +/// `/internal/users/profiles` 的 SQL 回退链)→ 传用户头像 → 宠物详情传头像。 +/// +/// 只有**选图与压缩两层**是桌面替身;createUpload / 预签名 PUT 直传 / +/// confirm / PATCH 全为生产实现。逐步截图落到 `build/profile-live/`。 +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + final enabled = Platform.environment['PATBOND_PROFILE_LIVE'] == '1'; + const authBase = 'http://127.0.0.1:8081'; + const petBase = 'http://127.0.0.1:8083'; + const communityBase = 'http://127.0.0.1:8084'; + final shotDir = Directory('build/profile-live'); + const rootKey = ValueKey('profile-live-root'); + + Future shot(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + find.byKey(rootKey), + ); + await tester.runAsync(() async { + if (!shotDir.existsSync()) shotDir.createSync(recursive: true); + final image = await boundary.toImage(pixelRatio: 1.5); + final bytes = await image.toByteData(format: ImageByteFormat.png); + File( + '${shotDir.path}/$name.png', + ).writeAsBytesSync(bytes!.buffer.asUint8List()); + debugPrint('[shot] $name (${bytes.lengthInBytes} bytes)'); + }); + } + + Future pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isNotEmpty) return; + } + fail('等待超时:$finder'); + } + + Future pumpUntilGone( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isEmpty) return; + } + fail('等待消失超时:$finder'); + } + + /// 让出**真实时间**:`pump(Duration)` 只推进一帧(把动画时钟往前拨, + /// 并不真的等 5 秒),等网络/等服务端缓存过期必须走 [WidgetTester.runAsync]。 + Future settleReal( + WidgetTester tester, { + Duration wait = const Duration(seconds: 2), + }) async { + await tester.runAsync(() => Future.delayed(wait)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + } + + /// 在指定滚动容器里向下滚到目标可见(资料页/列表页的底部入口都需要)。 + Future scrollTo( + WidgetTester tester, + Finder list, + Finder target, { + int maxDrags = 10, + }) async { + for (var i = 0; i < maxDrags && target.evaluate().isEmpty; i++) { + await tester.drag(list, const Offset(0, -300)); + await tester.pump(const Duration(milliseconds: 300)); + } + } + + Future> postJson( + String url, + Map body, { + String? bearer, + }) async { + final client = HttpClient(); + final request = await client.postUrl(Uri.parse(url)); + request.headers.contentType = ContentType.json; + request.headers.set('Idempotency-Key', DateTime.now().toIso8601String()); + if (bearer != null) { + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer'); + } + request.add(utf8.encode(jsonEncode(body))); + final response = await request.close(); + final text = await response.transform(utf8.decoder).join(); + client.close(); + expect(response.statusCode, anyOf(200, 201), reason: 'POST $url:$text'); + final envelope = jsonDecode(text) as Map; + expect(envelope['code'], 0, reason: 'POST $url 业务码非 0:$text'); + return envelope['data'] as Map; + } + + String greetingPrefix() { + final hour = DateTime.now().hour; + if (hour < 6) return '夜深了'; + if (hour < 11) return '早上好'; + if (hour < 14) return '中午好'; + if (hour < 18) return '下午好'; + return '晚上好'; + } + + testWidgets('桌面真链路:注册 → 资料真实化 → 设昵称 → 传头像 → Feed 作者名 → 宠物头像', (tester) async { + final picker = _DesktopPicker(); + final seed = DateTime.now().millisecondsSinceEpoch; + final username = 'plive$seed'; + final password = 'Live1234!$seed'; + final phone = '+86137${(seed % 100000000).toString().padLeft(8, '0')}'; + // 昵称与帖文都带 seed:Feed 是全局的,同名会让断言认错卡片。 + final nickname = '实测柴${seed % 1000000}'; + final postContent = '资料页实测帖 $seed'; + + await tester.pumpWidget( + RepaintBoundary( + key: rootKey, + child: App( + sessionManager: SessionManager(store: _InMemoryTokenStore()), + avatarUploaderFactory: (repository, purpose) => MediaUploader( + repository: repository, + purpose: purpose, + maxImages: 1, + maxConcurrentUploads: 1, + picker: picker, + compressor: const _PassthroughCompressor(), + ), + ), + ), + ); + + // ---- 步骤 1:UI 注册(注册面不收昵称,ADR-022 决策 D3.5-5)---- + await pumpUntil(tester, find.text('立即注册')); + await tester.tap(find.text('立即注册')); + await pumpUntil(tester, find.text('创建账号')); + // live binding 下首个 enterText 常被丢(焦点还没建立),故每格「点入 → + // 输入 → 验值 → 必要时重输」,本机实测踩过这一格。 + for (final entry in [ + (0, username), + (1, phone.substring(3)), + (2, password), + (3, password), + ]) { + final field = find.byType(TextField).at(entry.$1); + for (var attempt = 0; attempt < 3; attempt++) { + await tester.tap(field); + await tester.pump(const Duration(milliseconds: 200)); + await tester.enterText(field, entry.$2); + await tester.pump(const Duration(milliseconds: 300)); + if (tester.widget(field).controller?.text == entry.$2) break; + } + expect( + tester.widget(field).controller?.text, + entry.$2, + reason: '注册表单第 ${entry.$1} 格未收到输入', + ); + } + await shot(tester, '01-register'); + await tester.tap(find.widgetWithText(FilledButton, '注册')); + + // ---- 步骤 2:主壳挂载 → 等 /me 到手(资料页与首页共用这一次请求)---- + await pumpUntil(tester, find.text('我的')); + await pumpUntil(tester, find.text('${greetingPrefix()},$username 👋')); + await shot(tester, '02-home-greeting-username'); + + // 借真实会话种一篇帖与一只宠物(UI 发帖/建档已有各自实测覆盖)。 + final tokens = await tester.runAsync( + () => postJson('$authBase/api/v1/auth/login', { + 'username': username, + 'password': password, + }), + ); + final accessToken = tokens!['accessToken'] as String; + await tester.runAsync( + () => postJson('$communityBase/api/v1/posts', { + 'category': 'general', + 'content': postContent, + 'status': 'published', + }, bearer: accessToken), + ); + final pet = await tester.runAsync( + () => postJson('$petBase/api/v1/pets', { + 'name': '实测豆豆', + 'species': 'dog', + 'sex': 'male', + 'customBreedName': '中华田园犬', + }, bearer: accessToken), + ); + + // ---- 步骤 2.5:登出 → UI 登录 ---- + // 覆盖登录链路,同时让三个控制器重新预取:登出即 reset 是「跨账号不 + // 泄漏」的既有纪律(app.dart),重登后各 Tab 从 initial 重新拉取, + // 于是上面用 API 种下的帖与宠物才会进入内存副本。 + await tester.tap(find.text('我的')); + await pumpUntil(tester, find.text('@$username')); + final profileList = find + .ancestor(of: find.text('@$username'), matching: find.byType(ListView)) + .first; + await scrollTo(tester, profileList, find.text('切换账号或退出登录')); + await pumpUntil(tester, find.text('切换账号或退出登录')); + await tester.tap(find.text('切换账号或退出登录')); + await pumpUntil(tester, find.text('立即注册')); + for (final entry in [(0, username), (1, password)]) { + final field = find.byType(TextField).at(entry.$1); + for (var attempt = 0; attempt < 3; attempt++) { + await tester.tap(field); + await tester.pump(const Duration(milliseconds: 200)); + await tester.enterText(field, entry.$2); + await tester.pump(const Duration(milliseconds: 300)); + if (tester.widget(field).controller?.text == entry.$2) break; + } + } + await tester.tap(find.widgetWithText(FilledButton, '登录')); + await pumpUntil(tester, find.text('我的')); + await pumpUntil(tester, find.text('${greetingPrefix()},$username 👋')); + + // ---- 步骤 3:资料页展示真实用户名 + 零值统计(不是 demo 的 24/1.8k/2)---- + await tester.tap(find.text('我的')); + await pumpUntil(tester, find.text('@$username')); + await shot(tester, '03-profile-real-username'); + expect(find.text('萌宠新手(豆豆家长)'), findsNothing); + expect(find.text('1.8k'), findsNothing); + expect(find.text('获赞'), findsOneWidget); + // 统计来自服务端:刚发了 1 帖、没人赞 → 作品 1 / 获赞 0。 + expect( + find.descendant( + of: find + .ancestor(of: find.text('我的作品'), matching: find.byType(Column)) + .first, + matching: find.text('1'), + ), + findsOneWidget, + reason: '作品数应为服务端聚合的 1', + ); + + // ---- 步骤 4:设昵称 ---- + await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料')); + await pumpUntil(tester, find.byType(ProfileEditPage)); + expect( + find.text('未设置,当前展示为用户名「$username」'), + findsOneWidget, + reason: '编辑页不该把 username 预填进昵称框', + ); + await shot(tester, '04-profile-edit-empty'); + await tester.enterText(find.byType(TextField).first, nickname); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await pumpUntilGone(tester, find.byType(ProfileEditPage)); + await pumpUntil(tester, find.text(nickname)); + await shot(tester, '05-profile-nickname-set'); + + // ---- 步骤 5:上传用户头像(真实两步上传 + PATCH)---- + await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料')); + await pumpUntil(tester, find.byType(ProfileEditPage)); + await tester.tap(find.widgetWithText(TextButton, '更换头像')); + await pumpUntil(tester, find.text('使用这张')); + await shot(tester, '06-avatar-sheet-ready'); + await tester.tap(find.text('使用这张')); + await pumpUntil(tester, find.text('已选择\n新头像')); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await pumpUntilGone(tester, find.byType(ProfileEditPage)); + await pumpUntil(tester, find.text('资料已更新')); + await settleReal(tester); + await shot(tester, '07-profile-avatar-uploaded'); + expect(picker.calls, 1); + + // ---- 步骤 6:首页问候语 + Feed 作者名同步为昵称 ---- + await tester.tap(find.text('首页')); + await pumpUntil(tester, find.text('${greetingPrefix()},$nickname 👋')); + await shot(tester, '08-home-greeting-nickname'); + + // Feed 首屏是在设昵称之前拉的,故先下拉刷新——作者名要变必须重取, + // 这一步同时验证服务端 `/internal/users/profiles` 的 SQL 回退链 + // (客户端对 FeedCard.author.nickname 不做任何拼装)。 + final feedList = find.byType(ListView).first; + // ⚠️ 服务端有意的滞后:community 的 AuthorProfileGateway 把 + // `/internal/users/profiles` 的结果放在 60s TTL 的进程内缓存里 + // (`patbond.author-profile.cache-ttl`,M3 T3-05)。首屏 Feed 是设昵称 + // 之前拉的,那次已把「作者名 = username」写进缓存,所以立刻下拉刷新 + // 仍是旧名——这不是客户端 bug,客户端对作者名不做任何拼装。 + // 故先等过 TTL,再刷新验证同步。 + await settleReal(tester, wait: const Duration(seconds: 65)); + var authorSynced = false; + for (var round = 0; round < 3 && !authorSynced; round++) { + // 逐段 moveBy + 抬手才会触发 RefreshIndicator(fling 的速度语义被当成 + // 普通滚动,本机实测过);随后必须循环 pump 让真实 HTTP 有时间完成。 + final pull = await tester.startGesture( + tester.getTopLeft(feedList) + const Offset(200, 120), + ); + for (var i = 0; i < 6; i++) { + await pull.moveBy(const Offset(0, 60)); + await tester.pump(const Duration(milliseconds: 80)); + } + await pull.up(); + await settleReal(tester, wait: const Duration(seconds: 3)); + for (var i = 0; i < 8 && find.text(postContent).evaluate().isEmpty; i++) { + await tester.drag(feedList, const Offset(0, -240)); + await tester.pump(const Duration(milliseconds: 400)); + } + if (find.text(postContent).evaluate().isNotEmpty) { + final card = find.ancestor( + of: find.text(postContent), + matching: find.byType(PostCard), + ); + debugPrint( + '[live] 第 $round 轮我的卡片文本:' + '${tester.widgetList(find.descendant(of: card, matching: find.byType(Text))).map((t) => t.data).join(' | ')}', + ); + authorSynced = find + .descendant(of: card, matching: find.text(nickname)) + .evaluate() + .isNotEmpty; + } + if (!authorSynced) { + await tester.drag(feedList, const Offset(0, 2000)); + await tester.pump(const Duration(milliseconds: 400)); + } + } + expect( + authorSynced, + isTrue, + reason: 'Feed 作者名未随昵称变化(服务端 /internal 回退链或刷新未生效)', + ); + await shot(tester, '09-feed-author-nickname'); + + // ---- 步骤 7:宠物详情传头像(WRITE 档,纯头像 PATCH)---- + await tester.tap(find.text('档案')); + await pumpUntil(tester, find.text('实测豆豆')); + await shot(tester, '10a-pets-list-avatar-placeholder'); + await tester.tap(find.text('实测豆豆')); + await pumpUntil(tester, find.byType(PetDetailPage)); + await shot(tester, '10-pet-detail-placeholder-avatar'); + expect( + tester + .widgetList(find.byType(PetAvatar)) + .any((a) => a.url == null), + isTrue, + reason: '上传前应是爪印占位', + ); + await tester.tap(find.byType(PetAvatar).first); + await pumpUntil(tester, find.text('使用这张')); + await tester.tap(find.text('使用这张')); + await pumpUntil(tester, find.text('头像已更新')); + await settleReal(tester, wait: const Duration(seconds: 6)); + await shot(tester, '11-pet-avatar-uploaded'); + final detailAvatarUrl = tester + .widgetList(find.byType(PetAvatar)) + .map((a) => a.url) + .whereType() + .firstOrNull; + debugPrint('[live] 详情头像 URL:$detailAvatarUrl'); + final imageStatus = await tester.runAsync(() async { + final client = HttpClient(); + final response = await (await client.getUrl( + Uri.parse(detailAvatarUrl!), + )).close(); + final bytes = await response.fold(0, (n, chunk) => n + chunk.length); + client.close(); + return '${response.statusCode} / $bytes bytes'; + }); + debugPrint('[live] 应用进程内直取头像:$imageStatus'); + expect(imageStatus, startsWith('200'), reason: '预签名头像 URL 应可直取'); + // 真的画出了像素:详情头像下必须有 Image,且不是 RemoteImage 的兜底 + // 占位(占位是 Icon(Icons.pets),画不出图时才出现)。 + final detailAvatar = find.byWidgetPredicate( + (widget) => widget is PetAvatar && widget.showEditBadge, + ); + expect( + find.descendant(of: detailAvatar, matching: find.byType(Image)), + findsOneWidget, + ); + expect( + find.descendant(of: detailAvatar, matching: find.byIcon(Icons.pets)), + findsNothing, + reason: '仍是爪印占位说明图没画出来(后端对了但 UI 没显示)', + ); + expect( + tester + .widgetList(find.byType(PetAvatar)) + .any((a) => (a.url ?? '').contains('pet_avatar')), + isTrue, + reason: '详情页头像应换成 pet_avatar 前缀的预签名 URL', + ); + debugPrint('[live] petId=${pet!['id']} username=$username'); + }, skip: !enabled); +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 7c4a0b6..f0a5161 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -10,6 +10,7 @@ import 'package:patbond_flutter/features/community/community_display.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/community/feed_exposure.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; @@ -17,8 +18,19 @@ import 'package:patbond_flutter/widgets/common.dart'; enum HomeSegment { feed, services } /// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据 -/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与 -/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。 +/// (四态 + 游标翻页 + 聚合曝光埋点);问候语自 T3.5-10 起用真实展示名。 +/// +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1 钉死范围,留待对应里程碑; +/// 在此登记以免实测重复反馈): +/// - **天气条与地区选择**([_WeatherStatusBar] / [_AreaPickerSheet] / +/// [_WeatherDetailsSheet]):需接外部天气服务(含 API key 与配额管理), +/// 不属本迭代。 +/// - **story 环的「柴犬圈 / 猫咪圈 / 救助站」**([_StoryRow]):实为话题, +/// ADR-018 已把话题剪出 M3 范围;环内「发布」是真入口。 +/// - **促销卡「新用户首单立减 ¥20」**([_PromoCard]):属 M5 服务域 +/// (优惠/订单能力尚不存在)。 +/// - **搜索框与「本地服务」段**:搜索只过滤已加载页(契约无检索端点); +/// 服务商数据为 demo 常量,同属 M5。 class HomePage extends StatefulWidget { const HomePage({ required this.appState, @@ -26,6 +38,7 @@ class HomePage extends StatefulWidget { required this.onOpenServices, required this.onOpenCompose, super.key, + this.profileController, this.onOpenPost, this.feedAnalytics, this.isActive = true, @@ -36,6 +49,11 @@ class HomePage extends StatefulWidget { /// Feed 数据源(Tab 级单例,app.dart 装配注入)。 final CommunityController communityController; + /// 展示名数据源(T3.5-10):与资料页**共用同一控制器**, + /// 故改昵称后两处一起变,且全程只发一次 `/me`。 + /// 未注入或资料尚未到手时问候语不带名字(不编造「豆豆」这类假名)。 + final ProfileController? profileController; + final ValueChanged onOpenServices; /// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页, @@ -79,6 +97,8 @@ class _HomePageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + // 展示名变化(资料首次到手 / 用户改昵称后返回)即重建问候语。 + widget.profileController?.addListener(_onProfileChanged); // 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。 // 首屏自动预取不计入浏览段 refreshCount(非用户动作)。 if (_feed.phase == FeedPhase.initial) { @@ -87,6 +107,10 @@ class _HomePageState extends State with WidgetsBindingObserver { if (_feedSurfaceVisible) _startSegment(); } + void _onProfileChanged() { + if (mounted) setState(() {}); + } + @override void didUpdateWidget(HomePage oldWidget) { super.didUpdateWidget(oldWidget); @@ -114,6 +138,7 @@ class _HomePageState extends State with WidgetsBindingObserver { @override void dispose() { _settleSegment(); + widget.profileController?.removeListener(_onProfileChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -341,7 +366,10 @@ class _HomePageState extends State with WidgetsBindingObserver { const SizedBox(height: 12), _PetGreetingCard( greeting: greeting, - petName: widget.appState.pet.name, + // T3.5-10:真实展示名(`nickname ?? username`,与资料页 + // 同源同规则)取代硬编码的 demo 宠物名「豆豆」。 + // 资料未到手时为 null → 只问候不称名,不编造假名。 + displayName: widget.profileController?.displayName, petAvatar: widget.appState.pet.avatarUrl, advice: widget.appState.locationWeather.petAdvice, ), @@ -552,6 +580,8 @@ class _HomePageState extends State with WidgetsBindingObserver { } } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):定位与天气需接外部 +/// 服务(含 API key 与配额管理),本迭代不做;地区选择只改本机 demo 数据。 class _WeatherStatusBar extends StatelessWidget { const _WeatherStatusBar({ required this.weather, @@ -641,14 +671,23 @@ class _WeatherStatusBar extends StatelessWidget { class _PetGreetingCard extends StatelessWidget { const _PetGreetingCard({ required this.greeting, - required this.petName, + required this.displayName, required this.petAvatar, required this.advice, }); final String greeting; - final String petName; + + /// 当前用户展示名;null 即资料未到手(问候语退化为不称名形态)。 + final String? displayName; + + /// **刻意保留的 demo 占位**:卡右侧大图仍是 demo 宠物图(用户头像与宠物 + /// 头像本单已真实化,但「首页该显示哪只宠物」需要一个『当前宠物』概念, + /// 尚不存在——不在 ADR-022 范围内)。 final String petAvatar; + + /// **刻意保留的 demo 占位**:养宠建议来自 demo 天气数据(外部天气服务 + /// 未接,ADR-022 决策 D3.5-1)。 final String advice; @override @@ -691,7 +730,9 @@ class _PetGreetingCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$greeting,$petName 👋', + displayName == null + ? '$greeting 👋' + : '$greeting,$displayName 👋', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -886,6 +927,9 @@ Color _weatherColor(WeatherCondition condition) { }; } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):「柴犬圈 / 猫咪圈 / +/// 救助站」三个圈子实为**话题**,ADR-018 已把话题能力剪出 M3 范围, +/// 留待话题里程碑。首格「发布」是真入口(T3-17 起 push 真实发布页)。 class _StoryRow extends StatelessWidget { const _StoryRow({required this.onCreate}); @@ -956,6 +1000,8 @@ class _StoryRow extends StatelessWidget { } } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):促销文案与「去使用」 +/// 属 **M5 服务域**——优惠券与订单能力尚不存在,当前点按只跳本地服务段。 class _PromoCard extends StatelessWidget { const _PromoCard({required this.onTap}); diff --git a/test/features/home/home_greeting_test.dart b/test/features/home/home_greeting_test.dart new file mode 100644 index 0000000..ae20334 --- /dev/null +++ b/test/features/home/home_greeting_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/home/home_page.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/state/app_state.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +/// T3.5-10 首页问候语真实化。 +/// +/// ADR-022 决策 D3.5-1:本迭代**只做问候语**,天气 / 位置 / 圈子 / 促销卡 +/// 刻意保留为 demo 占位——本文件同时钉住「保留项仍在」,避免后续有人以 +/// 「顺手清理」为名越出拍板范围(真要动它们得先改 ADR)。 +void main() { + late FakeCommunityRepository community; + late CommunityController feed; + + setUp(() { + community = FakeCommunityRepository(); + community.onFeed = (_, _) async => feedPage(const []); + feed = CommunityController(repository: community); + }); + + Future pumpHome( + WidgetTester tester, { + required FakeAuthRepository auth, + }) async { + tester.view.physicalSize = const Size(700, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final profile = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(profile.dispose); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: HomePage( + appState: AppState(), + communityController: feed, + profileController: profile, + onOpenServices: (_) {}, + onOpenCompose: () {}, + ), + ), + ), + ); + return profile; + } + + /// 当前时段问候前缀(与页面实现同一套阈值;避免测试跟着挂钟漂移)。 + String greetingPrefix([DateTime? now]) { + final hour = (now ?? DateTime.now()).hour; + if (hour < 6) return '夜深了'; + if (hour < 11) return '早上好'; + if (hour < 14) return '中午好'; + if (hour < 18) return '下午好'; + return '晚上好'; + } + + testWidgets('有昵称:问候语用昵称,demo 宠物名「豆豆」不再出现', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'), + ), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget); + expect(find.textContaining('豆豆'), findsNothing); + }); + + testWidgets('无昵称:问候语回退 username(与资料页同规则)', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + ), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},llx 👋'), findsOneWidget); + }); + + testWidgets('资料未到手:只问候不称名(不编造假名)', (tester) async { + await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => throw const ApiNetworkException('断网'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()} 👋'), findsOneWidget); + // 不出现「问候,某某」形态(更不会退回 demo 宠物名)。 + expect(find.textContaining('${greetingPrefix()},'), findsNothing); + }); + + testWidgets('改昵称后首页同步(与资料页共用同一控制器,无需手动刷新)', (tester) async { + final auth = FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + updateMeHandler: (_) async => + buildProfile(username: 'llx', nickname: '小柴'), + ); + final profile = await pumpHome(tester, auth: auth); + await profile.refresh(); + await tester.pumpAndSettle(); + expect(find.text('${greetingPrefix()},llx 👋'), findsOneWidget); + + await profile.save( + const UpdateMeRequest(nickname: PatchField.value('小柴')), + ); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget); + }); + + testWidgets('刻意保留的 demo 占位仍在(ADR-022 钉死的范围)', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository(meHandler: () async => buildProfile()), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + // 天气 / 位置(外部服务未接)。 + expect(find.text('北京 · 朝阳区'), findsOneWidget); + expect(find.text('28°C 晴'), findsOneWidget); + // 圈子 = 话题(ADR-018 剪出)。 + expect(find.text('柴犬圈'), findsOneWidget); + // 促销卡(M5 服务域)。 + expect(find.text('新用户首单立减 ¥20'), findsOneWidget); + }); +} -- 2.43.0 From fbcd73468805e95b8055395654ca1014e0d6c13c Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 14 Sep 2026 09:53:35 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9AM3.5=20E2E=20?= =?UTF-8?q?=E7=83=9F=E5=9B=B1=E8=84=9A=E6=9C=AC=E2=80=94=E2=80=94=E8=B5=84?= =?UTF-8?q?=E6=96=99=E8=AF=BB=E5=86=99/=E5=8F=8C=E5=A4=B4=E5=83=8F/?= =?UTF-8?q?=E8=8E=B7=E8=B5=9E=E8=81=9A=E5=90=88=E5=8D=81=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=EF=BC=88v0.4.0=20=E5=8F=91=E5=B8=83=E9=97=A8=E7=A6=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.4.0 发布 checklist 第 2 步:既有三份回归脚本(M1 7/7、M2 11/11、M3 14/14) 对 compose 六容器全绿后,补上 M3.5 新增对外面的验证,与前三份并列于仓库根目录。 覆盖 10 场景 / 95 断言(冻结契约 openapi v1.4.0,32 路径 45 操作 75 schema): - GET /me 初始形态:nickname/avatarUrl 键恒在且双 null,不外露 avatarAssetId - PATCH /me 三态:键缺省不改 / 显式 null 清空 / 给值设置;空 patch、纯空白、 空串、只带未声明字段、非法 UUID、坏 JSON 六格 400/40000;同 body 重放幂等 - 昵称边界按码点计:1 与 32 CJK 可、32 emoji 可(UTF-16 长度 64 却过关)、 33 两种字符集均拒、btrim 对齐 - 用户头像端到端:两步上传 user_avatar → 挂载 → 预签名 GET 逐字节一致 → 去签名直访 403(桶私有) - 宠物头像:PATCH /pets 挂 pet_avatar → 详情与列表两处 avatarUrl 均可下载 → 显式 null 清除;version 仍必填 - 两域 asset 校验矩阵:幽灵/他人/错用途(含 user_avatar↔pet_avatar 跨域) → 404/40405 且防枚举响应体一致;本人用途相符但 uploading → 422/42203; 失败路径零副作用 - GET /me/community-stats:无 token 401、空数据 0/0 永不 404、自赞计入、 草稿不计、他人帖不串号、软删后获赞归零、与帖详情 likeCount 对账 - media purpose 白名单三值放行,白名单外 id_card → 400/40000 结果:10/10 全绿,契约偏差 0,无需放宽任何断言(错 purpose 实测 404/40405, 与契约一致)。三份既有脚本逐字未改。 脱敏:token 截断、预签名 URL 签名 query 整体抹为 、 Idempotency-Key 占位。 门禁:flutter analyze 0 issue;dart format --set-exit-if-changed lib test 0 changed (四份根目录脚本另跑显式检查亦 0 changed);flutter test 597 passed 与基线一致。 patbond-api 与 patbond-doc 代码/契约零改动。 报告:patbond-doc iteration-3.5/08-release-e2e-regression.md Co-Authored-By: Claude Fable 5 --- test_e2e_m35_manual.dart | 1166 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1166 insertions(+) create mode 100644 test_e2e_m35_manual.dart diff --git a/test_e2e_m35_manual.dart b/test_e2e_m35_manual.dart new file mode 100644 index 0000000..703c733 --- /dev/null +++ b/test_e2e_m35_manual.dart @@ -0,0 +1,1166 @@ +#!/usr/bin/env dart + +// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。 +/// M3.5 E2E 烟囱测试脚本(v0.4.0 发布门禁):对 compose 真实后端跑通 +/// 「用户资料读写 + 用户/宠物头像 + 获赞聚合」三条 M3.5 新增对外面。 +/// +/// 前置条件:patbond-api 目录执行 `docker compose up -d --build`(六容器: +/// postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084) +/// 运行方式:dart run test_e2e_m35_manual.dart +/// +/// 与 test_e2e_manual.dart(M1,7 场景)/ test_e2e_m2_manual.dart(M2,11 场景) +/// / test_e2e_m3_manual.dart(M3,14 场景)并列——本脚本**只覆盖 M3.5 增量面**, +/// 回归由前三份承担。冻结契约 openapi **v1.4.0**(32 路径 / 45 操作 / 75 schema)。 +/// +/// 覆盖 10 个场景: +/// 1. 注册 A、B → `GET /me` 初始形态:nickname/avatarUrl 双 null、**不外露** +/// avatarAssetId、Me 恰 6 字段 +/// 2. media `purpose` 白名单三值放行(post_image / user_avatar / pet_avatar), +/// 白名单外(id_card)创建上传 400/40000 +/// 3. `PATCH /me` 设昵称 → 响应回显与 `GET /me` 回读逐字段一致(同一 Me 形态) +/// 4. 用户头像端到端:两步上传(user_avatar) → `PATCH /me` 挂载 → avatarUrl +/// 预签名 GET **逐字节一致** → 响应不含 avatarAssetId +/// 5. `PATCH /me` **三态**:只带 nickname 不动头像 / 显式 null 清空昵称(头像留存) +/// / 显式 null 清除头像 / 空 patch 400 / 纯空白 400 / 空串 400 / +/// 只带未声明字段 400 / 非法 UUID 400 / 同 body 重放幂等 +/// 6. 昵称边界(长度按**码点**计):1 可 / 32 CJK 可 / **32 emoji 可** / +/// 33 CJK 拒 / 33 emoji 拒 / btrim 对齐 +/// 7. 用户头像 asset 四态校验:幽灵 / 他人(B) / 错用途(post_image、pet_avatar) +/// → 404/40405;本人用途相符但 uploading → 422/42203 +/// 8. 宠物头像:建档初始 avatarUrl=null 且不外露 avatarAssetId → `PATCH /pets/{id}` +/// 挂 pet_avatar → **详情与列表两处** avatarUrl 均可下载 → 显式 null 清除 +/// 9. 宠物头像 asset 校验:错用途(user_avatar、post_image) / 幽灵 → 404/40405; +/// uploading → 422/42203;version 仍必填(缺失 400/40000) +/// 10. `GET /me/community-stats`:无 token 401/40101 → 空数据 0/0(永不 404)→ +/// 发布 + **自赞** → 1/1 → 草稿不计 → 第二帖 → 2 → 软删该帖 → 获赞归零 +/// +/// 敏感信息脱敏:token 截断、预签名 URL 签名 query 整体抹为 ``、 +/// Idempotency-Key 占位。 +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +const authUrl = 'http://127.0.0.1:8081'; // patbond-auth +const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/me、media 两步上传) +const petUrl = 'http://127.0.0.1:8083'; // patbond-pet +const communityUrl = 'http://127.0.0.1:8084'; // patbond-community + +/// 固定测试图:1×1 JPEG(344 字节)。与 M3 脚本同一常量,sha256 见下。 +/// complete 仅 HEAD 校验 byteSize/Content-Type,但用真 JPEG 让宽高回填与 +/// 预签名 GET 字节比对都走真路径。 +const testJpegBase64 = + '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' + 'Gh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDIzNP/AABEIAAEAAQMBIgACEQEDEQH/' + 'xAAfAAABBQEBAQEBAQAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQEC' + 'AwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5' + 'OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Sl' + 'pqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/a' + 'AAwDAQACEQMRAD8A9/ooooA//9k='; + +/// 上方常量解码后的 sha256(契约照收照存,不做内容核验)。 +const testJpegSha256 = + '32142d9c5636535cf882b05c03275d2b58150623df750b059db0bbdab6973ce8'; + +/// `Me` 冻结形态的键集合(v1.4.0:恰 6 字段,见 components.schemas.Me)。 +const meKeys = { + 'userId', + 'username', + 'nickname', + 'phone', + 'avatarUrl', + 'createdAt', +}; + +final client = HttpClient(); +int _passed = 0; + +/// token 脱敏:仅留前 20 字符。 +String redact(String token) => + '${token.substring(0, min(20, token.length))}...'; + +/// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。 +String redactSignedUrl(String url) { + final uri = Uri.parse(url); + return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}' + '?'; +} + +/// 昵称脱敏无必要,但超长昵称按码点数概述以保持输出可读。 +String describeNickname(String s) => + '"${s.runes.length <= 8 ? s : '${String.fromCharCodes(s.runes.take(4))}…'}"' + '(${s.runes.length} 码点 / UTF-16 长度 ${s.length})'; + +void fail(String msg) { + print(' ✗ $msg'); + client.close(); + exit(1); +} + +void check(bool cond, String okMsg, String failMsg) { + if (cond) { + print(' ✓ $okMsg'); + } else { + fail(failMsg); + } +} + +class Resp { + final int status; + final String body; + final Map json; + Resp(this.status, this.body, this.json); + + Map get data => json['data'] as Map; + List get list => json['data'] as List; + int? get code => json['code'] as int?; + String? get message => json['message'] as String?; +} + +/// 通用调用。[rawBody] 用于绕过 jsonEncode 直发原文(坏 JSON 等场景)。 +Future call( + String method, + String url, { + String? token, + Object? body, + String? rawBody, + Map? headers, +}) async { + final req = await client.openUrl(method, Uri.parse(url)); + if (body != null || rawBody != null) { + req.headers.contentType = ContentType.json; + } + if (token != null) req.headers.set('Authorization', 'Bearer $token'); + headers?.forEach(req.headers.set); + if (rawBody != null) { + req.write(rawBody); + } else if (body != null) { + req.write(jsonEncode(body)); + } + final resp = await req.close(); + final text = await utf8.decodeStream(resp); + Map parsed = const {}; + try { + parsed = jsonDecode(text) as Map; + } catch (_) { + // 非 JSON 响应,parsed 留空 map,由调用方按 status 断言 + } + return Resp(resp.statusCode, text, parsed); +} + +/// 预签名 PUT 直传:不带 Bearer(鉴权即 URL 签名本身),requiredHeaders 原样携带。 +Future presignedPut( + String url, + Map requiredHeaders, + Uint8List bytes, +) async { + final req = await client.openUrl('PUT', Uri.parse(url)); + requiredHeaders.forEach(req.headers.set); + req.contentLength = bytes.length; + req.add(bytes); + final resp = await req.close(); + await resp.drain(); + return resp.statusCode; +} + +/// 预签名 GET:取回对象原始字节(桶私有,无签名直访被拒)。 +Future<(int, Uint8List)> presignedGet(String url) async { + final req = await client.openUrl('GET', Uri.parse(url)); + final resp = await req.close(); + final chunks = []; + await resp.forEach(chunks.addAll); + return (resp.statusCode, Uint8List.fromList(chunks)); +} + +String uuidV4() { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final h = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-' + '${h.substring(16, 20)}-${h.substring(20)}'; +} + +final _imageBytes = base64Decode(testJpegBase64); + +/// 两步上传:createUpload → (可选)预签名 PUT 直传 → (可选)complete 置 ready。 +/// [complete] 为 false 时 asset 停在 `uploading`(供 422/42203 取证)。 +Future uploadAsset( + String token, + String purpose, { + bool complete = true, + String label = '', +}) async { + var r = await call( + 'POST', + '$userUrl/api/v1/media/uploads', + token: token, + body: { + 'kind': 'image', + 'purpose': purpose, + 'mimeType': 'image/jpeg', + 'byteSize': _imageBytes.length, + 'sha256': testJpegSha256, + }, + ); + if (r.status != 201) { + fail('createUpload($purpose$label) 失败: ${r.status} ${r.body}'); + } + final assetId = r.data['assetId'] as String; + if (!complete) { + print(' asset($purpose$label, 停在 uploading): $assetId'); + return assetId; + } + final uploadUrl = r.data['uploadUrl'] as String; + final requiredHeaders = (r.data['requiredHeaders'] as Map).map( + (k, v) => MapEntry(k as String, v as String), + ); + final putStatus = await presignedPut(uploadUrl, requiredHeaders, _imageBytes); + if (putStatus != 200) fail('直传失败($purpose$label): HTTP $putStatus'); + r = await call( + 'POST', + '$userUrl/api/v1/media/uploads/$assetId/complete', + token: token, + ); + if (r.status != 200 || r.data['status'] != 'ready') { + fail('complete($purpose$label) 失败: ${r.status} ${r.body}'); + } + print(' asset($purpose$label, ready): $assetId'); + return assetId; +} + +/// 创建帖子(Idempotency-Key 必带;日志里以占位符示意)。 +Future createPost(String token, Map body) => call( + 'POST', + '$communityUrl/api/v1/posts', + token: token, + body: body, + headers: {'Idempotency-Key': uuidV4()}, +); + +/// 断言 Me 形态:键集合恰为冻结的 6 键,且**不外露** avatarAssetId。 +void assertMeShape(Map me, String where) { + check( + !me.containsKey('avatarAssetId'), + '$where:响应**不含** avatarAssetId(只写不读,"是否有头像"等价于 avatarUrl != null)', + '$where 外露了 avatarAssetId: ${me.keys}', + ); + check( + me.keys.toSet().difference(meKeys).isEmpty && + me.containsKey('userId') && + me.containsKey('username') && + me.containsKey('nickname') && + me.containsKey('avatarUrl') && + me.containsKey('createdAt'), + '$where:Me 形态在冻结的 6 字段内,nickname/avatarUrl 键恒在(值可 null)', + '$where Me 形态偏离契约: ${me.keys}', + ); +} + +Future patchMe(String token, Object body) => + call('PATCH', '$userUrl/api/v1/me', token: token, body: body); + +Future getMe(String token) => + call('GET', '$userUrl/api/v1/me', token: token); + +Future patchPet(String token, String petId, Map body) => + call('PATCH', '$petUrl/api/v1/pets/$petId', token: token, body: body); + +void main() async { + final ts = DateTime.now().millisecondsSinceEpoch; + final usernameA = 'e2e_m35_a_$ts'; + final usernameB = 'e2e_m35_b_$ts'; + const password = 'Test@123456'; + final phoneA = '+8613${Random().nextInt(900000000) + 100000000}'; + final phoneB = '+8613${Random().nextInt(900000000) + 100000000}'; + + print('=== Patbond M3.5 E2E 烟囱测试开始(v0.4.0 发布门禁)==='); + print('契约:openapi v1.4.0;只覆盖 M3.5 增量面(回归由 M1/M2/M3 三份承担)'); + print('账号 A: $usernameA'); + print('账号 B: $usernameB'); + print('测试图: ${_imageBytes.length} 字节 image/jpeg,sha256=$testJpegSha256'); + print(''); + + try { + // ================================================================ + // [1/10] 注册 A、B → GET /me 初始形态(nickname/avatarUrl 双 null) + // ================================================================ + print('[1/10] 注册 A、B(:8081)→ GET /api/v1/me 初始形态'); + var r = await call( + 'POST', + '$authUrl/api/v1/auth/register', + body: {'username': usernameA, 'phone': phoneA, 'password': password}, + ); + print(' POST /api/v1/auth/register (A) → ${r.status}'); + check(r.status == 200 && r.code == 0, 'A 注册成功', 'A 注册失败: ${r.body}'); + final userIdA = r.data['userId'] as String; + final tokenA = r.data['accessToken'] as String; + print(' userId(A): $userIdA / accessToken(A): ${redact(tokenA)}'); + + r = await call( + 'POST', + '$authUrl/api/v1/auth/register', + body: {'username': usernameB, 'phone': phoneB, 'password': password}, + ); + print(' POST /api/v1/auth/register (B) → ${r.status}'); + check(r.status == 200 && r.code == 0, 'B 注册成功', 'B 注册失败: ${r.body}'); + final userIdB = r.data['userId'] as String; + final tokenB = r.data['accessToken'] as String; + print(' userId(B): $userIdB / accessToken(B): ${redact(tokenB)}'); + + r = await getMe(tokenA); + print(' GET /api/v1/me (A, 全新账号) → ${r.status}'); + check( + r.status == 200 && r.code == 0, + 'GET /me 返回 200', + 'GET /me 失败: ${r.body}', + ); + var me = r.data; + print( + ' userId=${me['userId']} username=${me['username']} ' + 'nickname=${me['nickname']} avatarUrl=${me['avatarUrl']} ' + 'phone=${me['phone']}', + ); + check( + me.containsKey('nickname') && me['nickname'] == null, + 'nickname 键恒在且为 **null**(未设置;/me 不做 username 回退——注册不收昵称)', + 'nickname 初始值不符(期望 null): ${me['nickname']}', + ); + check( + me.containsKey('avatarUrl') && me['avatarUrl'] == null, + 'avatarUrl 键恒在且为 **null**(无头像)', + 'avatarUrl 初始值不符(期望 null): ${me['avatarUrl']}', + ); + check( + me['userId'] == userIdA && me['username'] == usernameA, + 'userId/username 与注册一致(既有字段未回归)', + 'userId/username 不符: ${r.body}', + ); + assertMeShape(me, 'GET /me 初始态'); + _passed++; + print(''); + + // ================================================================ + // [2/10] media purpose 白名单:三值放行;白名单外 id_card 被拒 + // ================================================================ + print('[2/10] media `purpose` 白名单:三值放行(含 M3.5 新增两值);id_card 被拒'); + final assetUserAvatar = await uploadAsset( + tokenA, + 'user_avatar', + label: ', A 的用户头像', + ); + final assetPetAvatar = await uploadAsset( + tokenA, + 'pet_avatar', + label: ', A 的宠物头像', + ); + final assetPostImage = await uploadAsset( + tokenA, + 'post_image', + label: ', A 的帖子配图', + ); + check( + {assetUserAvatar, assetPetAvatar, assetPostImage}.length == 3, + 'post_image / **user_avatar** / **pet_avatar** 三用途均登记并置 ready 成功', + '三用途 assetId 有重复', + ); + + r = await call( + 'POST', + '$userUrl/api/v1/media/uploads', + token: tokenA, + body: { + 'kind': 'image', + 'purpose': 'id_card', + 'mimeType': 'image/jpeg', + 'byteSize': _imageBytes.length, + 'sha256': testJpegSha256, + }, + ); + print( + ' POST /api/v1/media/uploads (purpose=id_card, 白名单外) → ${r.status} / code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + '白名单外 purpose 创建上传被拒 400/40000(${r.message})——白名单是引用侧的类型检查', + '白名单外 purpose 未被拒: ${r.status} ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [3/10] PATCH /me 设昵称 → 响应回显与 GET 回读逐字段一致 + // ================================================================ + print('[3/10] PATCH /api/v1/me 设昵称 → 响应回显与 GET /me 回读一致'); + const nick1 = '柴柴小当家'; + r = await patchMe(tokenA, {'nickname': nick1}); + print(' PATCH /api/v1/me {nickname: "$nick1"} → ${r.status}'); + check( + r.status == 200 && r.code == 0, + 'PATCH 成功 200(无 version 乐观锁、无 Idempotency-Key)', + 'PATCH 设昵称失败: ${r.status} ${r.body}', + ); + final patchEcho = r.data; + check( + patchEcho['nickname'] == nick1, + 'PATCH 响应回显新昵称(返回与 GET 完全相同的 Me 全量形态)', + 'PATCH 回显昵称不符: ${patchEcho['nickname']}', + ); + assertMeShape(patchEcho, 'PATCH /me 响应'); + + r = await getMe(tokenA); + me = r.data; + print(' GET /api/v1/me(回读)→ ${r.status} nickname=${me['nickname']}'); + check( + r.status == 200 && me['nickname'] == nick1, + '回读昵称与写入一致(持久化生效)', + '回读昵称不符: ${me['nickname']}', + ); + check( + me['userId'] == patchEcho['userId'] && + me['username'] == patchEcho['username'] && + me['createdAt'] == patchEcho['createdAt'] && + me['phone'] == patchEcho['phone'] && + me['avatarUrl'] == patchEcho['avatarUrl'], + 'PATCH 回显与 GET 回读**逐字段一致**(两动词同一 Me 形态)', + 'PATCH 回显与 GET 回读不一致: ${jsonEncode(patchEcho)} vs ${jsonEncode(me)}', + ); + _passed++; + print(''); + + // ================================================================ + // [4/10] 用户头像端到端:挂载 → avatarUrl 预签名 GET 逐字节一致 + // ================================================================ + print('[4/10] 用户头像端到端:PATCH /me 挂 user_avatar → avatarUrl 下载逐字节比对'); + r = await patchMe(tokenA, {'avatarAssetId': assetUserAvatar}); + print(' PATCH /api/v1/me {avatarAssetId: $assetUserAvatar} → ${r.status}'); + check( + r.status == 200 && r.code == 0, + '挂载用户头像成功 200', + '挂载用户头像失败: ${r.status} ${r.body}', + ); + me = r.data; + var avatarUrl = me['avatarUrl'] as String?; + check( + avatarUrl != null, + 'avatarUrl 由 null 转为**现签预签名 GET**(asset 为 ready 才签)', + '挂载后 avatarUrl 仍为 null: ${r.body}', + ); + print(' avatarUrl: ${redactSignedUrl(avatarUrl!)}'); + check( + Uri.parse(avatarUrl).queryParameters.containsKey('X-Amz-Signature'), + 'avatarUrl 携带 SigV4 query 签名族(私有桶靠签名,不经应用服务器)', + 'avatarUrl 无 X-Amz-Signature: ${redactSignedUrl(avatarUrl)}', + ); + check( + me['nickname'] == nick1, + '只带 avatarAssetId 的 patch **未动昵称**(三态:键缺省 = 不改)', + '挂头像时昵称被改动: ${me['nickname']}', + ); + assertMeShape(me, 'PATCH /me 挂头像后'); + + var (getStatus, downloaded) = await presignedGet(avatarUrl); + print(' GET → $getStatus(${downloaded.length} 字节)'); + check(getStatus == 200, '预签名 GET 取回 200', '预签名 GET 失败: HTTP $getStatus'); + check( + downloaded.length == _imageBytes.length && + const ListEquality().equals(downloaded, _imageBytes), + '取回 ${downloaded.length} 字节与上传**逐字节一致**(用户头像链路内容往返无损)', + '字节不一致:上传 ${_imageBytes.length} / 取回 ${downloaded.length}', + ); + + // 每次响应现签:GET /me 再取一次仍可下载(且不缓存 URL)。 + r = await getMe(tokenA); + final avatarUrl2 = r.data['avatarUrl'] as String; + final (getStatus2, downloaded2) = await presignedGet(avatarUrl2); + check( + getStatus2 == 200 && downloaded2.length == _imageBytes.length, + 'GET /me 的 avatarUrl 亦为现签且可下载(每次响应现签,客户端不得持久化)', + 'GET /me 的 avatarUrl 不可下载: HTTP $getStatus2', + ); + + final unsigned = Uri.parse(avatarUrl).replace(query: '').toString(); + final (unsignedStatus, _) = await presignedGet(unsigned); + print(' GET <同一对象但去掉签名> → $unsignedStatus'); + check( + unsignedStatus == 403, + '桶保持私有:无签名直访头像对象被存储侧拒绝(403)', + '无签名直访未被拒: HTTP $unsignedStatus', + ); + _passed++; + print(''); + + // ================================================================ + // [5/10] PATCH /me 三态语义 + 参数错谱 + 重放幂等 + // ================================================================ + print('[5/10] PATCH /api/v1/me **三态**:键缺省 / 显式 null / 给值;及参数错谱'); + const nick2 = '豆豆'; + r = await patchMe(tokenA, {'nickname': nick2}); + print(' PATCH {nickname: "$nick2"}(只带昵称)→ ${r.status}'); + check( + r.status == 200 && + r.data['nickname'] == nick2 && + r.data['avatarUrl'] != null, + '**只带 nickname 的 patch 不影响头像**(avatarUrl 仍非 null)——键缺省 = 不改', + '只带昵称的 patch 影响了头像: ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': null}); + print(' PATCH {nickname: null}(显式 null)→ ${r.status}'); + check( + r.status == 200 && + r.data['nickname'] == null && + r.data['avatarUrl'] != null, + '**显式 null 清空昵称生效**,且头像留存(清空只作用于携带的键)', + '显式 null 未清空昵称或误清头像: ${r.body}', + ); + r = await getMe(tokenA); + check( + r.data['nickname'] == null, + '回读确认昵称已为 null(清空已持久化)', + '清空后回读昵称非 null: ${r.data['nickname']}', + ); + + r = await patchMe(tokenA, {'avatarAssetId': null}); + print(' PATCH {avatarAssetId: null}(显式 null)→ ${r.status}'); + check( + r.status == 200 && r.data['avatarUrl'] == null, + '**显式 null 清除头像生效**(avatarUrl 回落 null)', + '显式 null 未清除头像: ${r.body}', + ); + assertMeShape(r.data, 'PATCH /me 清空后'); + + r = await patchMe(tokenA, {}); + print(' PATCH {}(空 patch)→ ${r.status} / code ${r.code}'); + check( + r.status == 400 && r.code == 40000, + '**空 patch 400/40000**(${r.message})——不静默 200,空 PATCH 几乎总是客户端 bug', + '空 patch 未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': ' '}); + print(' PATCH {nickname: " "}(纯空白)→ ${r.status} / code ${r.code}'); + check( + r.status == 400 && r.code == 40000, + '**纯空白昵称 400/40000**(${r.message})——不隐式清空,清空只留显式 null 一条路', + '纯空白昵称未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': ''}); + print(' PATCH {nickname: ""}(空串)→ ${r.status} / code ${r.code}'); + check( + r.status == 400 && r.code == 40000, + '空串昵称 400/40000(与纯空白同判)', + '空串昵称未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'bio': '只带未声明字段'}); + print(' PATCH {bio: …}(只带未声明字段)→ ${r.status} / code ${r.code}'); + check( + r.status == 400 && r.code == 40000, + '只带未声明字段 = 空 patch,400/40000(bio 列存在但 M3.5 未开放读写)', + '只带未声明字段未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'avatarAssetId': 'not-a-uuid'}); + print( + ' PATCH {avatarAssetId: "not-a-uuid"} → ${r.status} / code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + '非法 UUID 的 avatarAssetId 400/40000(参数错先于引用校验)', + '非法 UUID 未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'PATCH', + '$userUrl/api/v1/me', + token: tokenA, + rawBody: '{"nickname": ', + ); + print(' PATCH <坏 JSON> → ${r.status} / code ${r.code}'); + check( + r.status == 400 && r.code == 40000, + '非法 JSON body 400/40000', + '坏 JSON 未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'PATCH', + '$userUrl/api/v1/me', + body: {'nickname': '无 token'}, + ); + print(' PATCH(无 token)→ ${r.status} / code ${r.code}'); + check( + r.status == 401 && r.code == 40101, + '无 token 401/40101(写入口同样强制鉴权)', + '无 token 未被拒: ${r.status} ${r.body}', + ); + + // 重放幂等:同 body 连发两次,第二次仍 200 且状态与首次一致。 + final replayBody = {'nickname': '重放昵称', 'avatarAssetId': assetUserAvatar}; + r = await patchMe(tokenA, replayBody); + check(r.status == 200, '重放取证:首次 PATCH 成功 200', '重放首发失败: ${r.body}'); + final firstNick = r.data['nickname']; + final firstHasAvatar = r.data['avatarUrl'] != null; + r = await patchMe(tokenA, replayBody); + print(' PATCH(同 body 重放第二次)→ ${r.status}'); + check( + r.status == 200 && + r.data['nickname'] == firstNick && + (r.data['avatarUrl'] != null) == firstHasAvatar, + '同 body 重放第二次仍 200 且状态与首次一致(无 Idempotency-Key,天然幂等)', + '重放结果不一致: ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [6/10] 昵称边界:长度按码点计(1 / 32 CJK / 32 emoji / 33 拒) + // ================================================================ + print('[6/10] 昵称边界(长度按**码点**计,btrim 后 1~32)'); + final nick1cp = '柴'; + final nick32cjk = '猫' * 32; + final nick33cjk = '猫' * 33; + final nick32emoji = '🐕' * 32; // 32 码点 / UTF-16 长度 64 + final nick33emoji = '🐕' * 33; // 33 码点 / UTF-16 长度 66 + + r = await patchMe(tokenA, {'nickname': nick1cp}); + print(' PATCH ${describeNickname(nick1cp)} → ${r.status}'); + check( + r.status == 200 && r.data['nickname'] == nick1cp, + '**1 码点**昵称接受(下边界)', + '1 码点昵称被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': nick32cjk}); + print(' PATCH ${describeNickname(nick32cjk)} → ${r.status}'); + check( + r.status == 200 && r.data['nickname'] == nick32cjk, + '**32 CJK 码点**昵称接受(上边界)', + '32 CJK 昵称被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': nick32emoji}); + print(' PATCH ${describeNickname(nick32emoji)} → ${r.status}'); + check( + r.status == 200 && r.data['nickname'] == nick32emoji, + '**32 emoji 码点**昵称接受(UTF-16 长度 64 却过关 → 长度确按码点计,' + '非 String.length())', + '32 emoji 昵称被拒(疑按 UTF-16 长度校验): ${r.status} ${r.body}', + ); + r = await getMe(tokenA); + check( + r.data['nickname'] == nick32emoji && + (r.data['nickname'] as String).runes.length == 32, + '回读 32 emoji 昵称逐字一致(存储与返回都无截断)', + 'emoji 昵称回读不符: ${r.data['nickname']}', + ); + + r = await patchMe(tokenA, {'nickname': nick33cjk}); + print( + ' PATCH ${describeNickname(nick33cjk)} → ${r.status} / code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + '**33 CJK 码点**昵称被拒 400/40000(${r.message})', + '33 CJK 昵称未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': nick33emoji}); + print( + ' PATCH ${describeNickname(nick33emoji)} → ${r.status} / code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + '**33 emoji 码点**昵称被拒 400/40000(上边界对两种字符集一致)', + '33 emoji 昵称未被拒: ${r.status} ${r.body}', + ); + + r = await patchMe(tokenA, {'nickname': ' 豆豆 '}); + print(' PATCH {nickname: " 豆豆 "}(前后空白)→ ${r.status}'); + check( + r.status == 200 && r.data['nickname'] == '豆豆', + 'btrim 对齐:" 豆豆 " 存为 "豆豆"(长度校验在 btrim 之后)', + 'btrim 未对齐: ${r.data['nickname']}', + ); + _passed++; + print(''); + + // ================================================================ + // [7/10] 用户头像 asset 四态校验:40405 五路 + 42203 + // ================================================================ + print('[7/10] 用户头像 asset 校验:幽灵/他人/错用途 → 404/40405;uploading → 422/42203'); + final ghostAssetId = uuidV4(); + final assetOfB = await uploadAsset(tokenB, 'user_avatar', label: ', B 的头像'); + + final notFoundCases = { + '幽灵 assetId': ghostAssetId, + '他人(B)的 user_avatar': assetOfB, + '错用途:post_image': assetPostImage, + '错用途:pet_avatar(跨域头像不通用)': assetPetAvatar, + }; + final notFoundBodies = []; + for (final entry in notFoundCases.entries) { + r = await patchMe(tokenA, {'avatarAssetId': entry.value}); + print(' PATCH /me ← ${entry.key} → ${r.status} / code ${r.code}'); + check( + r.status == 404 && r.code == 40405, + '${entry.key} → 404/40405(${r.message})', + '${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}', + ); + notFoundBodies.add(r.body); + } + check( + notFoundBodies.take(2).toSet().length == 1, + '幽灵 id 与他人 asset 响应体逐字节一致(防枚举合并:不能据差异探出 id 是真的)', + '幽灵与他人 asset 响应可区分: $notFoundBodies', + ); + + final assetUploading = await uploadAsset( + tokenA, + 'user_avatar', + complete: false, + label: ', 真未直传', + ); + r = await patchMe(tokenA, {'avatarAssetId': assetUploading}); + print( + ' PATCH /me ← 本人 user_avatar 但仍 uploading → ${r.status} / code ${r.code}', + ); + check( + r.status == 422 && r.code == 42203, + '本人、用途相符但未就绪 → 422/42203(${r.message})——状态机拒绝,可重试', + '未就绪 asset 未按 42203 拒绝: ${r.status} ${r.body}', + ); + + r = await getMe(tokenA); + check( + r.status == 200 && r.data['avatarUrl'] != null, + '以上 5 次失败的 PATCH **均未产生副作用**(头像仍是场景 5 重放时挂上的那张)', + '失败的 PATCH 改动了头像: ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [8/10] 宠物头像:详情与列表两处 avatarUrl + 三态清除 + // ================================================================ + print('[8/10] 宠物头像:建档 → PATCH 挂 pet_avatar → 详情+列表 avatarUrl → 清除'); + r = await call( + 'POST', + '$petUrl/api/v1/pets', + token: tokenA, + body: { + 'name': 'M3.5 头像喵', + 'species': 'cat', + 'sex': 'female', + 'customBreedName': '中华田园猫', + }, + ); + print(' POST /api/v1/pets → ${r.status}'); + check( + r.status == 201 && r.code == 0, + '建档成功(201)', + '建档失败: ${r.status} ${r.body}', + ); + final petId = r.data['id'] as String; + var petVersion = r.data['version'] as int; + print(' petId: $petId / version: $petVersion'); + check( + r.data.containsKey('avatarUrl') && r.data['avatarUrl'] == null, + '创建响应 avatarUrl 键恒在且为 null(新建无头像)', + '创建响应 avatarUrl 不符: ${r.data['avatarUrl']}', + ); + check( + !r.data.containsKey('avatarAssetId'), + 'Pet 响应**不含** avatarAssetId(与 Me 同一纪律:只写不读)', + 'Pet 响应外露 avatarAssetId: ${r.data.keys}', + ); + + r = await patchPet(tokenA, petId, { + 'version': petVersion, + 'avatarAssetId': assetPetAvatar, + }); + print(' PATCH /api/v1/pets/$petId {version, avatarAssetId} → ${r.status}'); + check( + r.status == 200 && r.code == 0, + '挂宠物头像成功 200(仅头像字段 → WRITE 档)', + '挂宠物头像失败: ${r.status} ${r.body}', + ); + check( + r.data['version'] == petVersion + 1, + 'version $petVersion→${r.data['version']}(头像与资料共用同一把乐观锁)', + 'version 未 +1: ${r.data['version']}', + ); + petVersion = r.data['version'] as int; + final petAvatarUrl = r.data['avatarUrl'] as String?; + check( + petAvatarUrl != null, + 'PATCH 响应 avatarUrl 现签非空', + 'PATCH 响应 avatarUrl 为 null: ${r.body}', + ); + print(' avatarUrl (PATCH 响应): ${redactSignedUrl(petAvatarUrl!)}'); + check( + !r.data.containsKey('avatarAssetId'), + 'PATCH 响应仍不外露 avatarAssetId', + 'PATCH 响应外露 avatarAssetId: ${r.data.keys}', + ); + check( + r.data['name'] == 'M3.5 头像喵' && r.data['species'] == 'cat', + '只带 avatarAssetId 的 patch 未动资料字段(name/species 原样)', + '资料字段被改动: ${r.body}', + ); + + r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); + final detailUrl = r.data['avatarUrl'] as String?; + print(' GET /api/v1/pets/$petId(详情)→ ${r.status}'); + check( + r.status == 200 && detailUrl != null, + '**详情**处 avatarUrl 非空(现签)', + '详情 avatarUrl 为 null: ${r.body}', + ); + var (detailStatus, detailBytes) = await presignedGet(detailUrl!); + print( + ' GET <详情 avatarUrl> ${redactSignedUrl(detailUrl)} → ' + '$detailStatus(${detailBytes.length} 字节)', + ); + check( + detailStatus == 200 && + const ListEquality().equals(detailBytes, _imageBytes), + '详情 avatarUrl 可下载且与上传逐字节一致', + '详情 avatarUrl 下载不符: HTTP $detailStatus / ${detailBytes.length} 字节', + ); + + r = await call('GET', '$petUrl/api/v1/pets', token: tokenA); + print(' GET /api/v1/pets(列表)→ ${r.status}'); + final listed = + r.list.firstWhere((e) => (e as Map)['id'] == petId) + as Map; + final listUrl = listed['avatarUrl'] as String?; + check( + r.status == 200 && listUrl != null, + '**列表**处 avatarUrl 亦非空(四处 Pet 响应统一扩字段)', + '列表 avatarUrl 为 null: ${jsonEncode(listed)}', + ); + check( + !listed.containsKey('avatarAssetId'), + '列表项同样不外露 avatarAssetId', + '列表项外露 avatarAssetId: ${listed.keys}', + ); + final (listStatus, listBytes) = await presignedGet(listUrl!); + print(' GET <列表 avatarUrl> → $listStatus(${listBytes.length} 字节)'); + check( + listStatus == 200 && const ListEquality().equals(listBytes, _imageBytes), + '列表 avatarUrl 可下载且字节一致(两处 URL 各自现签、均有效)', + '列表 avatarUrl 下载不符: HTTP $listStatus', + ); + + r = await patchPet(tokenA, petId, { + 'version': petVersion, + 'avatarAssetId': null, + }); + print( + ' PATCH /api/v1/pets/$petId {avatarAssetId: null}(三态清除)→ ${r.status}', + ); + check( + r.status == 200 && r.data['avatarUrl'] == null, + '**显式 null 清除宠物头像生效**(avatarAssetId 是 UpdatePetRequest 唯一的三态字段)', + '宠物头像未被清除: ${r.body}', + ); + petVersion = r.data['version'] as int; + r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); + check( + r.data['avatarUrl'] == null, + '回读确认宠物头像已清除', + '清除后回读 avatarUrl 非 null: ${r.data['avatarUrl']}', + ); + _passed++; + print(''); + + // ================================================================ + // [9/10] 宠物头像 asset 校验 + version 仍必填 + // ================================================================ + print('[9/10] 宠物头像 asset 校验:错用途/幽灵 → 404/40405;uploading → 422/42203'); + final petNotFoundCases = { + '错用途:user_avatar(跨域头像不通用)': assetUserAvatar, + '错用途:post_image': assetPostImage, + '幽灵 assetId': uuidV4(), + '他人(B)的 asset': assetOfB, + }; + for (final entry in petNotFoundCases.entries) { + r = await patchPet(tokenA, petId, { + 'version': petVersion, + 'avatarAssetId': entry.value, + }); + print(' PATCH /pets/{id} ← ${entry.key} → ${r.status} / code ${r.code}'); + check( + r.status == 404 && r.code == 40405, + '${entry.key} → 404/40405(${r.message})', + '${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}', + ); + } + + final petAssetUploading = await uploadAsset( + tokenA, + 'pet_avatar', + complete: false, + label: ', 真未直传', + ); + r = await patchPet(tokenA, petId, { + 'version': petVersion, + 'avatarAssetId': petAssetUploading, + }); + print( + ' PATCH /pets/{id} ← 本人 pet_avatar 但仍 uploading → ${r.status} / code ${r.code}', + ); + check( + r.status == 422 && r.code == 42203, + '本人、用途相符但未就绪 → 422/42203(${r.message})', + '未就绪 pet asset 未按 42203 拒绝: ${r.status} ${r.body}', + ); + + r = await patchPet(tokenA, petId, {'avatarAssetId': assetPetAvatar}); + print( + ' PATCH /pets/{id}(缺 version,只带 avatarAssetId)→ ${r.status} / code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + 'version 仍必填:缺失 400/40000(${r.message})——即便只改头像', + '缺 version 未被拒: ${r.status} ${r.body}', + ); + + r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); + check( + r.status == 200 && + r.data['avatarUrl'] == null && + r.data['version'] == petVersion, + '以上 6 次失败的 PATCH 均无副作用(头像仍为 null,version 未 +1)', + '失败的 PATCH 产生了副作用: ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [10/10] GET /me/community-stats:空数据 0/0 → 自赞 → 软删归零 + // ================================================================ + print('[10/10] GET /api/v1/me/community-stats:空数据 0/0 → 发帖自赞 1/1 → 软删归零'); + r = await call('GET', '$communityUrl/api/v1/me/community-stats'); + print(' GET /me/community-stats(无 token)→ ${r.status} / code ${r.code}'); + check( + r.status == 401 && r.code == 40101, + '无 token 401/40101(唯一的错误谱)', + '无 token 未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print( + ' GET /me/community-stats(A 尚未发帖)→ ${r.status} ${jsonEncode(r.data)}', + ); + check( + r.status == 200 && r.code == 0, + '空数据返回 200(**永不 404**:任何已认证用户都有 stats)', + '空数据未返回 200: ${r.status} ${r.body}', + ); + check( + r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 0, + '空数据两字段均为 **0 而非 null**(COALESCE(SUM(like_count), 0))', + '空数据不为 0/0: ${r.body}', + ); + check( + r.data.keys.toSet().length == 2 && + r.data.containsKey('receivedLikeCount') && + r.data.containsKey('publishedPostCount'), + 'CommunityStats 恰两字段(无多余键)', + 'CommunityStats 形态不符: ${r.data.keys}', + ); + + r = await createPost(tokenA, { + 'title': 'M3.5 获赞聚合取证帖', + 'content': 'M3.5 烟囱:本帖将被作者自赞,用于 community-stats 对账(ts=$ts)', + 'category': 'general', + 'status': 'published', + }); + print( + ' POST /api/v1/posts (status=published, Idempotency-Key=) → ${r.status}', + ); + check(r.status == 201 && r.code == 0, '发布帖创建成功(201)', '发帖失败: ${r.body}'); + final statsPostId = r.data['id'] as String; + print(' postId: $statsPostId'); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(发布 1 帖,未获赞)→ ${jsonEncode(r.data)}'); + check( + r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 0, + '作品数 1、获赞 0(发布即计入作品集合)', + '发布后计数不符: ${r.body}', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$statsPostId/like', + token: tokenA, + ); + print( + ' PUT /posts/{id}/like(**A 自赞**)→ ${r.status} ${jsonEncode(r.data)}', + ); + check( + r.status == 200 && r.data['liked'] == true && r.data['likeCount'] == 1, + '自赞成功,帖级 likeCount=1', + '自赞不符: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(自赞后)→ ${jsonEncode(r.data)}'); + check( + r.data['receivedLikeCount'] == 1 && r.data['publishedPostCount'] == 1, + '**receivedLikeCount=1 / publishedPostCount=1**:自己赞自己**计入**' + '(与帖子详情 likeCount 同一口径,两处数字对得上)', + '自赞后计数不符: ${r.body}', + ); + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$statsPostId', + token: tokenA, + ); + check( + r.data['likeCount'] == 1, + '帖子详情 likeCount=1 与 receivedLikeCount 对账一致', + '两处口径不一致: 详情 ${r.data['likeCount']}', + ); + + r = await createPost(tokenA, { + 'title': 'M3.5 草稿(不应计入作品数)', + 'content': '草稿尚非作品,其获赞也不可能存在(ts=$ts)', + 'status': 'draft', + }); + check( + r.status == 201 && r.data['status'] == 'draft', + '草稿创建成功(201)', + '草稿创建失败: ${r.body}', + ); + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(新增 1 草稿后)→ ${jsonEncode(r.data)}'); + check( + r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 1, + '**草稿不计入**作品数(作品数仍为 1)', + '草稿被计入: ${r.body}', + ); + + r = await createPost(tokenA, { + 'title': 'M3.5 第二篇发布帖', + 'content': '第二篇已发布帖,作品数应累加到 2(ts=$ts)', + 'status': 'published', + }); + check(r.status == 201, '第二篇发布帖创建成功(201)', '第二帖失败: ${r.body}'); + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(发布 2 帖)→ ${jsonEncode(r.data)}'); + check( + r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1, + '作品数累加为 2,获赞仍为 1(多帖求和口径正确)', + '两帖计数不符: ${r.body}', + ); + + // 他人帖不串号:B 发布并自赞一帖,A 的 stats 不受影响。 + r = await createPost(tokenB, { + 'title': 'B 的帖(不应进 A 的 stats)', + 'content': 'B 的已发布帖 + 自赞,用于验证主体隔离(ts=$ts)', + 'status': 'published', + }); + check(r.status == 201, 'B 发布帖创建成功(201)', 'B 发帖失败: ${r.body}'); + final postOfB = r.data['id'] as String; + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$postOfB/like', + token: tokenB, + ); + check(r.status == 200, 'B 自赞成功', 'B 自赞失败: ${r.body}'); + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(B 也发帖自赞后,A 视角)→ ${jsonEncode(r.data)}'); + check( + r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1, + '他人帖**不串号**进 A 的 stats(主体恒为 token 里的调用者)', + 'A 的 stats 被 B 的帖污染: ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$statsPostId', + token: tokenA, + ); + print(' DELETE /api/v1/posts/$statsPostId(软删被赞的那帖)→ ${r.status}'); + check( + r.status == 200 && r.code == 0, + '软删成功', + '软删失败: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/community-stats', + token: tokenA, + ); + print(' GET /me/community-stats(软删后)→ ${jsonEncode(r.data)}'); + check( + r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 1, + '**软删帖后获赞归零**(1→0)、作品数回落 1(删帖即撤回其数字,' + '与 /me/posts、Feed 可见性一致)', + '软删后计数不符: ${r.body}', + ); + _passed++; + print(''); + + print('=== M3.5 E2E 烟囱测试全部通过 ✓($_passed/10 场景)==='); + print('E2E_USERNAME_A=$usernameA'); + print('E2E_USER_ID_A=$userIdA'); + print('E2E_USER_ID_B=$userIdB'); + print('E2E_USER_AVATAR_ASSET_ID=$assetUserAvatar'); + print('E2E_PET_AVATAR_ASSET_ID=$assetPetAvatar'); + print('E2E_PET_ID=$petId'); + print('E2E_STATS_POST_ID=$statsPostId(已软删)'); + } catch (e, stack) { + print('✗ 测试异常: $e'); + print(stack); + exit(1); + } finally { + client.close(); + } +} + +/// 逐元素比较(避免为脚本引入 collection 包依赖)。 +class ListEquality { + const ListEquality(); + bool equals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} -- 2.43.0