diff --git a/apps/lib/core/theme/design_tokens.dart b/apps/lib/core/theme/design_tokens.dart index c392cdd..096d43e 100644 --- a/apps/lib/core/theme/design_tokens.dart +++ b/apps/lib/core/theme/design_tokens.dart @@ -8,6 +8,10 @@ class AppColorPalette extends ThemeExtension { final Color g3Text, g3Divider, g3Border; final Color eventDefault; final Color eventArchived; + final Color warning; + final Color warningContainer; + final Color onWarning; + final Color onWarningContainer; const AppColorPalette({ required this.eventPresetColors, @@ -23,6 +27,10 @@ class AppColorPalette extends ThemeExtension { required this.g3Border, required this.eventDefault, required this.eventArchived, + required this.warning, + required this.warningContainer, + required this.onWarning, + required this.onWarningContainer, }); static const light = AppColorPalette( @@ -51,6 +59,10 @@ class AppColorPalette extends ThemeExtension { g3Border: Color(0xFFCFE1FB), eventDefault: Color(0xFF3B82F6), eventArchived: Color(0xFF64748B), + warning: Color(0xFFF59E0B), + warningContainer: Color(0xFFFEF3C7), + onWarning: Color(0xFFFFFFFF), + onWarningContainer: Color(0xFF78350F), ); static const dark = AppColorPalette( @@ -79,6 +91,10 @@ class AppColorPalette extends ThemeExtension { g3Border: Color(0xFF2563EB), eventDefault: Color(0xFF60A5FA), eventArchived: Color(0xFF94A3B8), + warning: Color(0xFFFBBF24), + warningContainer: Color(0xFF78350F), + onWarning: Color(0xFF0F172A), + onWarningContainer: Color(0xFFFEF3C7), ); @override @@ -96,6 +112,10 @@ class AppColorPalette extends ThemeExtension { Color? g3Border, Color? eventDefault, Color? eventArchived, + Color? warning, + Color? warningContainer, + Color? onWarning, + Color? onWarningContainer, }) { return AppColorPalette( eventPresetColors: eventPresetColors ?? this.eventPresetColors, @@ -111,6 +131,10 @@ class AppColorPalette extends ThemeExtension { g3Border: g3Border ?? this.g3Border, eventDefault: eventDefault ?? this.eventDefault, eventArchived: eventArchived ?? this.eventArchived, + warning: warning ?? this.warning, + warningContainer: warningContainer ?? this.warningContainer, + onWarning: onWarning ?? this.onWarning, + onWarningContainer: onWarningContainer ?? this.onWarningContainer, ); } @@ -139,6 +163,18 @@ class AppColorPalette extends ThemeExtension { g3Border: Color.lerp(g3Border, other.g3Border, t)!, eventDefault: Color.lerp(eventDefault, other.eventDefault, t)!, eventArchived: Color.lerp(eventArchived, other.eventArchived, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningContainer: Color.lerp( + warningContainer, + other.warningContainer, + t, + )!, + onWarning: Color.lerp(onWarning, other.onWarning, t)!, + onWarningContainer: Color.lerp( + onWarningContainer, + other.onWarningContainer, + t, + )!, ); } } diff --git a/apps/lib/data/network/error_code_mapper.dart b/apps/lib/data/network/error_code_mapper.dart index 3ae3b41..d305553 100644 --- a/apps/lib/data/network/error_code_mapper.dart +++ b/apps/lib/data/network/error_code_mapper.dart @@ -13,6 +13,8 @@ String? mapErrorCodeToL10nKey( return 'errorGenericSafe'; case 'AGENT_RUN_MESSAGES_INVALID': return 'errorGenericSafe'; + case 'AGENT_INVALID_RUN_ID': + return 'errorGenericSafe'; case 'AGENT_INVALID_LAST_EVENT_ID': return 'errorAgentInvalidLastEventId'; case 'AGENT_SSE_CONNECTION_LIMIT': diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 7cb9af3..90a7c70 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -41,8 +41,6 @@ class LoginView extends StatefulWidget { } class _LoginViewState extends State { - static const _dialCodes = ['+86', '+1', '+44', '+81', '+65']; - final _phoneController = TextEditingController(); final _codeController = TextEditingController(); bool _agreedToTerms = false; @@ -230,7 +228,6 @@ class _LoginViewState extends State { keyboardType: TextInputType.phone, prefix: PhonePrefixSelector( value: state.dialCode, - items: _dialCodes, onChanged: (value) { context .read() diff --git a/apps/lib/features/contacts/data/apis/friends_api.dart b/apps/lib/features/contacts/data/apis/friends_api.dart index bfb9157..0c8d7e7 100644 --- a/apps/lib/features/contacts/data/apis/friends_api.dart +++ b/apps/lib/features/contacts/data/apis/friends_api.dart @@ -113,13 +113,15 @@ class FriendRequestResponse { }); factory FriendRequestResponse.fromJson(Map json) { + final rawContent = json['content'] as Map?; + final content = rawContent?['message'] as String?; return FriendRequestResponse( id: json['id'] as String, sender: UserBasicInfo.fromJson(json['sender'] as Map), recipient: UserBasicInfo.fromJson( json['recipient'] as Map, ), - content: json['content'] as String?, + content: content, status: json['status'] as String, createdAt: DateTime.parse(json['created_at'] as String), ); diff --git a/apps/lib/features/contacts/data/models/friend_request.dart b/apps/lib/features/contacts/data/models/friend_request.dart index 7874713..ff5ec41 100644 --- a/apps/lib/features/contacts/data/models/friend_request.dart +++ b/apps/lib/features/contacts/data/models/friend_request.dart @@ -38,11 +38,13 @@ class FriendRequest { }); factory FriendRequest.fromJson(Map json) { + final rawContent = json['content'] as Map?; + final content = rawContent?['message'] as String?; return FriendRequest( id: json['id'] as String, sender: FriendUser.fromJson(json['sender'] as Map), recipient: FriendUser.fromJson(json['recipient'] as Map), - content: json['content'] as String?, + content: content, status: _friendRequestStatusFromApi(json['status'] as String), createdAt: DateTime.parse(json['created_at'] as String), ); diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 5560fa5..4f473a1 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -342,9 +342,7 @@ class _HomeScreenState extends State if (showWaitingIndicator) Align( alignment: Alignment.bottomLeft, - child: HomeWaitingIndicator( - label: stageLabel(state.currentStage), - ), + child: HomeWaitingIndicator(label: _agentWaitingLabel(state)), ), Align( alignment: Alignment.topCenter, @@ -494,6 +492,13 @@ class _HomeScreenState extends State return state.isWaitingFirstToken || state.isStreaming || state.isCancelling; } + String _agentWaitingLabel(ChatState state) { + if (state.isWaitingFirstToken && !state.hasSeenStep) { + return context.l10n.agentStageRequesting; + } + return stageLabel(state.currentStage); + } + void _handleScrollChanged() { if (!_scrollController.hasClients) { return; diff --git a/apps/lib/features/home/presentation/widgets/home_background_field.dart b/apps/lib/features/home/presentation/widgets/home_background_field.dart index 65f8512..64244c0 100644 --- a/apps/lib/features/home/presentation/widgets/home_background_field.dart +++ b/apps/lib/features/home/presentation/widgets/home_background_field.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../../../core/theme/design_tokens.dart'; + const homeBackgroundFieldKey = ValueKey('home_background_field'); class HomeBackgroundField extends StatelessWidget { @@ -13,12 +15,65 @@ class HomeBackgroundField extends StatelessWidget { key: homeBackgroundFieldKey, decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [colorScheme.surface, colorScheme.surfaceContainerLowest], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surface, + colorScheme.surfaceContainerLow, + colorScheme.surface, + ], + stops: const [0, 0.38, 1], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + top: -(AppSpacing.xxl * 2), + left: -(AppSpacing.xxl * 2), + child: _AmbientOrb( + color: colorScheme.primaryContainer.withValues(alpha: 0.55), + size: AppSpacing.xxl * 8, + ), + ), + Positioned( + right: -(AppSpacing.xxl * 2), + top: AppSpacing.xxl, + child: _AmbientOrb( + color: colorScheme.secondaryContainer.withValues(alpha: 0.42), + size: AppSpacing.xxl * 6, + ), + ), + ], + ), + ); + } +} + +class _AmbientOrb extends StatelessWidget { + const _AmbientOrb({required this.color, required this.size}); + + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + color, + color.withValues(alpha: 0.12), + color.withValues(alpha: 0), + ], + stops: const [0, 0.55, 1], + ), ), ), - child: const SizedBox.expand(), ); } } diff --git a/apps/lib/features/home/presentation/widgets/home_floating_header.dart b/apps/lib/features/home/presentation/widgets/home_floating_header.dart index 7e95d73..799496f 100644 --- a/apps/lib/features/home/presentation/widgets/home_floating_header.dart +++ b/apps/lib/features/home/presentation/widgets/home_floating_header.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; const homeFloatingHeaderKey = ValueKey('home_floating_header'); const homeFloatingHeaderTitleKey = ValueKey('home_floating_header_title'); +const _actionSlotWidth = + (AppSpacing.xxl + AppSpacing.lg) * 2 + AppSpacing.sm + AppSpacing.sm; class HomeFloatingHeader extends StatelessWidget { const HomeFloatingHeader({ @@ -33,52 +36,66 @@ class HomeFloatingHeader extends StatelessWidget { AppSpacing.xs, ), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.95), - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + color: colorScheme.surface.withValues(alpha: 0.92), + border: Border( + bottom: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.65), + ), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.04), + blurRadius: AppSpacing.xl, + offset: const Offset(AppSpacing.none, AppSpacing.xs), + ), + ], ), - child: Stack( - alignment: Alignment.center, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _HeaderIconButton( + SizedBox( + width: _actionSlotWidth, + child: Align( + alignment: Alignment.centerLeft, + child: _HeaderIconButton( icon: LucideIcons.settings, onPressed: onTapSettings, ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _HeaderIconButton( - icon: LucideIcons.calendar, - onPressed: onTapCalendar, - ), - const SizedBox(width: AppSpacing.sm), - _MessagesButton( - unreadCount: unreadCount, - onPressed: onTapMessages, - ), - ], - ), - ], + ), ), - IgnorePointer( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xl * 3), - child: Text( - 'Linksy', - key: homeFloatingHeaderTitleKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppSpacing.lg + (AppSpacing.xs / 2), - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), + Expanded( + child: Text( + context.l10n.appTitle, + key: homeFloatingHeaderTitleKey, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppSpacing.lg, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: colorScheme.onSurface, ), ), ), + SizedBox( + width: _actionSlotWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _HeaderIconButton( + icon: LucideIcons.calendar, + onPressed: onTapCalendar, + ), + const SizedBox(width: AppSpacing.sm), + _MessagesButton( + unreadCount: unreadCount, + onPressed: onTapMessages, + ), + ], + ), + ), ], ), ); @@ -103,7 +120,11 @@ class _HeaderIconButton extends StatelessWidget { minHeight: AppSpacing.xxl + AppSpacing.lg, ), onPressed: onPressed, - icon: Icon(icon, size: AppSpacing.xxl, color: colorScheme.onSurface), + icon: Icon( + icon, + size: AppSpacing.xxl, + color: colorScheme.onSurface.withValues(alpha: 0.95), + ), ); } } @@ -145,7 +166,11 @@ class _MessagesButton extends StatelessWidget { ), decoration: BoxDecoration( color: colorScheme.error, - borderRadius: BorderRadius.circular(AppSpacing.sm), + borderRadius: BorderRadius.circular(AppSpacing.md), + border: Border.all( + color: colorScheme.surface, + width: AppSpacing.xs / 2, + ), ), constraints: const BoxConstraints( minWidth: AppSpacing.lg, diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index b2b6998..bd50c0f 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -68,6 +68,7 @@ "homeStopRequested": "Stop requested", "homeNoValidSpeech": "No valid speech detected. Please move closer to the microphone and retry.", "agentStageRouting": "Analyzing intent", + "agentStageRequesting": "Requesting server", "agentStageExecution": "Executing task", "agentStageMemory": "Loading memory", "agentStageProcessing": "Processing task", @@ -159,7 +160,14 @@ } }, "uiSchemaActionFallback": "Action", + "uiSchemaStatusInfo": "Info", + "uiSchemaStatusSuccess": "Completed", + "uiSchemaStatusWarning": "Warning", + "uiSchemaStatusError": "Failed", + "uiSchemaStatusPending": "In progress", "uiSchemaActionNotImplemented": "This action is not available yet", + "uiSchemaUrlInvalid": "Invalid URL", + "uiSchemaUrlOpenFailed": "Unable to open URL", "uiSchemaNavigationInvalidParams": "Invalid navigation params", "uiSchemaNavigationInvalidPath": "Invalid navigation path", "notificationSnoozeMinutes": "{minutes} min", @@ -771,5 +779,12 @@ "title": {} } }, - "messagesCalendarCardDeletedWithoutTitle": "Calendar event deleted" + "messagesCalendarCardDeletedWithoutTitle": "Calendar event deleted", + "calendarDetailSubscribers": "Subscribers ({count})", + "@calendarDetailSubscribers": { + "placeholders": { + "count": {"type": "int"} + } + }, + "calendarOwnerBadge": "Owner" } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index c85cf34..2de1739 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -464,6 +464,12 @@ abstract class AppLocalizations { /// **'意图识别中'** String get agentStageRouting; + /// No description provided for @agentStageRequesting. + /// + /// In zh, this message translates to: + /// **'网络请求中'** + String get agentStageRequesting; + /// No description provided for @agentStageExecution. /// /// In zh, this message translates to: @@ -836,12 +842,54 @@ abstract class AppLocalizations { /// **'操作'** String get uiSchemaActionFallback; + /// No description provided for @uiSchemaStatusInfo. + /// + /// In zh, this message translates to: + /// **'提示'** + String get uiSchemaStatusInfo; + + /// No description provided for @uiSchemaStatusSuccess. + /// + /// In zh, this message translates to: + /// **'已完成'** + String get uiSchemaStatusSuccess; + + /// No description provided for @uiSchemaStatusWarning. + /// + /// In zh, this message translates to: + /// **'提醒'** + String get uiSchemaStatusWarning; + + /// No description provided for @uiSchemaStatusError. + /// + /// In zh, this message translates to: + /// **'失败'** + String get uiSchemaStatusError; + + /// No description provided for @uiSchemaStatusPending. + /// + /// In zh, this message translates to: + /// **'进行中'** + String get uiSchemaStatusPending; + /// No description provided for @uiSchemaActionNotImplemented. /// /// In zh, this message translates to: /// **'该操作暂未接入'** String get uiSchemaActionNotImplemented; + /// No description provided for @uiSchemaUrlInvalid. + /// + /// In zh, this message translates to: + /// **'链接无效'** + String get uiSchemaUrlInvalid; + + /// No description provided for @uiSchemaUrlOpenFailed. + /// + /// In zh, this message translates to: + /// **'无法打开链接'** + String get uiSchemaUrlOpenFailed; + /// No description provided for @uiSchemaNavigationInvalidParams. /// /// In zh, this message translates to: @@ -3408,6 +3456,18 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'日历事件已删除'** String get messagesCalendarCardDeletedWithoutTitle; + + /// No description provided for @calendarDetailSubscribers. + /// + /// In zh, this message translates to: + /// **'已订阅 ({count}人)'** + String calendarDetailSubscribers(int count); + + /// No description provided for @calendarOwnerBadge. + /// + /// In zh, this message translates to: + /// **'所有者'** + String get calendarOwnerBadge; } class _AppLocalizationsDelegate diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 8db3820..6ea0fc5 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -209,6 +209,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get agentStageRouting => 'Analyzing intent'; + @override + String get agentStageRequesting => 'Requesting server'; + @override String get agentStageExecution => 'Executing task'; @@ -411,9 +414,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get uiSchemaActionFallback => 'Action'; + @override + String get uiSchemaStatusInfo => 'Info'; + + @override + String get uiSchemaStatusSuccess => 'Completed'; + + @override + String get uiSchemaStatusWarning => 'Warning'; + + @override + String get uiSchemaStatusError => 'Failed'; + + @override + String get uiSchemaStatusPending => 'In progress'; + @override String get uiSchemaActionNotImplemented => 'This action is not available yet'; + @override + String get uiSchemaUrlInvalid => 'Invalid URL'; + + @override + String get uiSchemaUrlOpenFailed => 'Unable to open URL'; + @override String get uiSchemaNavigationInvalidParams => 'Invalid navigation params'; @@ -1837,4 +1861,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messagesCalendarCardDeletedWithoutTitle => 'Calendar event deleted'; + + @override + String calendarDetailSubscribers(int count) { + return 'Subscribers ($count)'; + } + + @override + String get calendarOwnerBadge => 'Owner'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 333c40d..be8ed1a 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -198,6 +198,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get agentStageRouting => '意图识别中'; + @override + String get agentStageRequesting => '网络请求中'; + @override String get agentStageExecution => '任务执行中'; @@ -397,9 +400,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get uiSchemaActionFallback => '操作'; + @override + String get uiSchemaStatusInfo => '提示'; + + @override + String get uiSchemaStatusSuccess => '已完成'; + + @override + String get uiSchemaStatusWarning => '提醒'; + + @override + String get uiSchemaStatusError => '失败'; + + @override + String get uiSchemaStatusPending => '进行中'; + @override String get uiSchemaActionNotImplemented => '该操作暂未接入'; + @override + String get uiSchemaUrlInvalid => '链接无效'; + + @override + String get uiSchemaUrlOpenFailed => '无法打开链接'; + @override String get uiSchemaNavigationInvalidParams => '导航参数无效'; @@ -1790,4 +1814,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get messagesCalendarCardDeletedWithoutTitle => '日历事件已删除'; + + @override + String calendarDetailSubscribers(int count) { + return '已订阅 ($count人)'; + } + + @override + String get calendarOwnerBadge => '所有者'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 211862d..22c760c 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -68,6 +68,7 @@ "homeStopRequested": "已请求停止", "homeNoValidSpeech": "未识别到有效语音,请靠近麦克风并连续说话后重试", "agentStageRouting": "意图识别中", + "agentStageRequesting": "网络请求中", "agentStageExecution": "任务执行中", "agentStageMemory": "记忆提取中", "agentStageProcessing": "任务处理中", @@ -159,7 +160,14 @@ } }, "uiSchemaActionFallback": "操作", + "uiSchemaStatusInfo": "提示", + "uiSchemaStatusSuccess": "已完成", + "uiSchemaStatusWarning": "提醒", + "uiSchemaStatusError": "失败", + "uiSchemaStatusPending": "进行中", "uiSchemaActionNotImplemented": "该操作暂未接入", + "uiSchemaUrlInvalid": "链接无效", + "uiSchemaUrlOpenFailed": "无法打开链接", "uiSchemaNavigationInvalidParams": "导航参数无效", "uiSchemaNavigationInvalidPath": "导航路径无效", "notificationSnoozeMinutes": "{minutes} 分钟", @@ -771,5 +779,12 @@ "title": {} } }, - "messagesCalendarCardDeletedWithoutTitle": "日历事件已删除" + "messagesCalendarCardDeletedWithoutTitle": "日历事件已删除", + "calendarDetailSubscribers": "已订阅 ({count}人)", + "@calendarDetailSubscribers": { + "placeholders": { + "count": {"type": "int"} + } + }, + "calendarOwnerBadge": "所有者" } diff --git a/apps/lib/shared/widgets/app_input.dart b/apps/lib/shared/widgets/app_input.dart index b6c35ca..2e20f3e 100644 --- a/apps/lib/shared/widgets/app_input.dart +++ b/apps/lib/shared/widgets/app_input.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/theme/design_tokens.dart'; + class AppInput extends StatelessWidget { final String label; final String hint; @@ -43,7 +45,20 @@ class AppInput extends StatelessWidget { keyboardType: keyboardType, maxLines: maxLines, enabled: enabled, - decoration: InputDecoration(hintText: hint, suffixIcon: suffix), + decoration: InputDecoration( + hintText: hint, + suffixIcon: suffix, + filled: true, + fillColor: colorScheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide.none, + ), + ), ), ], ); diff --git a/apps/lib/shared/widgets/phone_prefix_selector.dart b/apps/lib/shared/widgets/phone_prefix_selector.dart index 2c5503c..39d043e 100644 --- a/apps/lib/shared/widgets/phone_prefix_selector.dart +++ b/apps/lib/shared/widgets/phone_prefix_selector.dart @@ -1,15 +1,17 @@ import 'package:flutter/material.dart'; +import '../../data/models/dial_codes.dart'; + class PhonePrefixSelector extends StatelessWidget { const PhonePrefixSelector({ super.key, required this.value, - required this.items, + List? items, this.onChanged, - }); + }) : items = items ?? kDialCodes; final String value; - final List items; + final List items; final ValueChanged? onChanged; @override @@ -21,7 +23,10 @@ class PhonePrefixSelector extends StatelessWidget { onSelected: onChanged, itemBuilder: (context) => items .map( - (item) => PopupMenuItem(value: item, child: Text(item)), + (item) => PopupMenuItem( + value: item.value, + child: Text(item.value), + ), ) .toList(growable: false), color: colorScheme.surface, diff --git a/apps/lib/shared/widgets/toast/toast_type_config.dart b/apps/lib/shared/widgets/toast/toast_type_config.dart index e74f760..cda6eeb 100644 --- a/apps/lib/shared/widgets/toast/toast_type_config.dart +++ b/apps/lib/shared/widgets/toast/toast_type_config.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/theme/design_tokens.dart'; import 'toast_type.dart'; class ToastTypeConfig { @@ -25,18 +26,22 @@ class ToastTypeConfig { return switch (type) { ToastType.success => ToastTypeConfig( - surfaceColor: colorScheme.tertiaryContainer, - borderColor: colorScheme.tertiary, - iconColor: colorScheme.tertiary, - textColor: colorScheme.onTertiaryContainer, + surfaceColor: colorScheme.primaryContainer, + borderColor: colorScheme.primary, + iconColor: colorScheme.primary, + textColor: colorScheme.onPrimaryContainer, label: l10n.toastLabelSuccess, icon: Icons.check_circle_outline, ), ToastType.warning => ToastTypeConfig( - surfaceColor: colorScheme.secondaryContainer, - borderColor: colorScheme.secondary, - iconColor: colorScheme.secondary, - textColor: colorScheme.onSecondaryContainer, + surfaceColor: Theme.of( + context, + ).extension()!.warningContainer, + borderColor: Theme.of(context).extension()!.warning, + iconColor: Theme.of(context).extension()!.warning, + textColor: Theme.of( + context, + ).extension()!.onWarningContainer, label: l10n.toastLabelWarning, icon: Icons.warning_amber_rounded, ), diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 8f1daea..3c20889 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: image_picker: ^1.0.7 package_info_plus: ^8.0.3 drag_and_drop_lists: ^0.4.2 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: diff --git a/apps/rules/visual_design_language.md b/apps/rules/visual_design_language.md index 6699c38..14d798e 100644 --- a/apps/rules/visual_design_language.md +++ b/apps/rules/visual_design_language.md @@ -330,6 +330,32 @@ Avoid: - flat monochrome sameness - harsh enterprise-blue overuse +### Semantic Color Slots + +The app uses these semantic color slots via `ColorScheme`: + +| Slot | Purpose | Example Usage | +|------|---------|---------------| +| `primary` | Brand anchor, success feedback | Buttons, success toasts, focus states | +| `secondary` | Secondary actions, info | Secondary buttons, info toasts | +| `error` | Error states | Error messages, destructive actions | +| `surface` | Background surfaces | Page backgrounds, cards | + +### Warning Color + +Warning is a **semantic extension** beyond `ColorScheme`. It lives in `AppColorPalette` and follows the same pattern: + +| Token | Light | Dark | Usage | +|-------|-------|------|-------| +| `warning` | `#F59E0B` | `#FBBF24` | Warning icon, border accent | +| `warningContainer` | `#FEF3C7` | `#78350F` | Warning toast surface | +| `onWarningContainer` | `#78350F` | `#FEF3C7` | Text on warning surface | + +Warning is used for: +- Caution states that are not errors +- Reminders or alerts requiring user attention +- Toast notifications of type `ToastType.warning` + --- ## 11) Typography Feel (MUST) diff --git a/apps/test/app/router/app_router_redirect_test.dart b/apps/test/app/router/app_router_redirect_test.dart index 813d505..82a58e1 100644 --- a/apps/test/app/router/app_router_redirect_test.dart +++ b/apps/test/app/router/app_router_redirect_test.dart @@ -108,6 +108,7 @@ class _FakeChatApi implements ChatApi { @override Future> streamRunEvents( String threadId, { + required String runId, String? lastEventId, }) { throw UnimplementedError(); diff --git a/apps/test/core/chat/ag_ui_event_test.dart b/apps/test/core/chat/ag_ui_event_test.dart new file mode 100644 index 0000000..721cc28 --- /dev/null +++ b/apps/test/core/chat/ag_ui_event_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/ag_ui_event.dart'; + +void main() { + test('history message timestamp is normalized to local time', () { + final raw = { + 'id': 'm1', + 'seq': 1, + 'role': 'assistant', + 'content': 'hello', + 'timestamp': '2026-03-29T16:06:27.870001+00:00', + 'attachments': const [], + }; + + final message = HistoryMessage.fromJson(raw); + final expected = DateTime.parse( + '2026-03-29T16:06:27.870001+00:00', + ).toLocal(); + + expect(message.timestamp.isUtc, isFalse); + expect(message.timestamp, expected); + }); +} diff --git a/apps/test/core/chat/ag_ui_service_test.dart b/apps/test/core/chat/ag_ui_service_test.dart new file mode 100644 index 0000000..df91201 --- /dev/null +++ b/apps/test/core/chat/ag_ui_service_test.dart @@ -0,0 +1,126 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/ag_ui_event.dart'; +import 'package:social_app/core/chat/ag_ui_service.dart'; +import 'package:social_app/core/chat/chat_api.dart'; + +class _RetryableSseChatApi implements ChatApi { + int streamCalls = 0; + + @override + Future> createRun(Map runInput) async { + return {'threadId': 'thread-1', 'runId': 'run-1'}; + } + + @override + Future> streamRunEvents( + String threadId, { + required String runId, + String? lastEventId, + }) async { + streamCalls += 1; + + if (streamCalls == 1) { + return Stream.fromIterable([ + 'id: e-1', + 'event: RUN_STARTED', + 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', + '', + ]); + } + + return Stream.fromIterable([ + 'id: e-2', + 'event: TEXT_MESSAGE_END', + 'data: {"type":"TEXT_MESSAGE_END","threadId":"thread-1","runId":"run-1","messageId":"m-assistant-1","answer":"ok","role":"assistant","status":"success"}', + '', + 'id: e-3', + 'event: RUN_FINISHED', + 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', + '', + ]); + } + + @override + Future cancelRun({required String threadId, required String runId}) { + throw UnimplementedError(); + } + + @override + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }) { + throw UnimplementedError(); + } + + @override + Future fetchAttachmentPreview(String previewPath) { + throw UnimplementedError(); + } + + @override + Future transcribeAudio(String filePath) { + throw UnimplementedError(); + } + + @override + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) { + throw UnimplementedError(); + } +} + +class _AlwaysPrematureCloseChatApi extends _RetryableSseChatApi { + @override + Future> streamRunEvents( + String threadId, { + required String runId, + String? lastEventId, + }) async { + streamCalls += 1; + return Stream.fromIterable([ + 'id: e-1', + 'event: RUN_STARTED', + 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', + '', + ]); + } +} + +void main() { + test( + 'reconnects SSE stream when first attempt closes before terminal', + () async { + final chatApi = _RetryableSseChatApi(); + final events = []; + final service = AgUiService( + chatApi: chatApi, + onEvent: (event) { + events.add(event.type); + }, + ); + + await service.sendMessage('hello'); + + expect(chatApi.streamCalls, 2); + expect(events.contains(AgUiEventType.runStarted), isTrue); + expect(events.contains(AgUiEventType.textMessageEnd), isTrue); + expect(events.contains(AgUiEventType.runFinished), isTrue); + }, + ); + + test('throws after SSE resume attempts are exhausted', () async { + final chatApi = _AlwaysPrematureCloseChatApi(); + final service = AgUiService(chatApi: chatApi); + + await expectLater(service.sendMessage('hello'), throwsA(isA())); + + expect(chatApi.streamCalls, 3); + }); +} diff --git a/apps/test/core/chat/agent_stage_test.dart b/apps/test/core/chat/agent_stage_test.dart new file mode 100644 index 0000000..20c9505 --- /dev/null +++ b/apps/test/core/chat/agent_stage_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/agent_stage.dart'; + +void main() { + test('maps router/worker/memory step names exactly', () { + expect(stageFromStepName('router'), AgentStage.routing); + expect(stageFromStepName('worker'), AgentStage.execution); + expect(stageFromStepName('memory'), AgentStage.memory); + }); + + test('normalizes step name with trim and case', () { + expect(stageFromStepName(' ROUTER '), AgentStage.routing); + expect(stageFromStepName('Worker'), AgentStage.execution); + expect(stageFromStepName(' MEMORY'), AgentStage.memory); + }); + + test('returns null for unknown step name', () { + expect(stageFromStepName('tool'), isNull); + expect(stageFromStepName(''), isNull); + expect(stageFromStepName('unknown'), isNull); + }); +} diff --git a/apps/test/core/chat/chat_timeline_reconciler_test.dart b/apps/test/core/chat/chat_timeline_reconciler_test.dart new file mode 100644 index 0000000..e16e23e --- /dev/null +++ b/apps/test/core/chat/chat_timeline_reconciler_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/chat_list_item.dart'; +import 'package:social_app/core/chat/chat_timeline_reconciler.dart'; + +void main() { + test('replaces optimistic user echo with remote persisted user message', () { + final now = DateTime(2026, 3, 30, 0, 6, 0); + final local = [ + TextMessageItem( + id: 'user-local-1', + content: '随便推荐,比如说给我推荐B站的主页。', + timestamp: now, + sender: MessageSender.user, + isLocalEcho: true, + ), + ]; + final remote = [ + TextMessageItem( + id: 'db-user-27', + content: '随便推荐,比如说给我推荐B站的主页。', + timestamp: now.add(const Duration(seconds: 2)), + sender: MessageSender.user, + ), + TextMessageItem( + id: 'db-assistant-30', + content: 'B站(哔哩哔哩)的主页链接是:https://www.bilibili.com/', + timestamp: now.add(const Duration(seconds: 6)), + sender: MessageSender.ai, + ), + ]; + + final merged = ChatTimelineReconciler.merge( + localItems: local, + remoteItems: remote, + ); + + expect( + merged + .whereType() + .where((m) => m.sender == MessageSender.user) + .length, + 1, + ); + expect(merged.any((item) => item.id == 'user-local-1'), isFalse); + expect(merged.any((item) => item.id == 'db-user-27'), isTrue); + expect(merged.any((item) => item.id == 'db-assistant-30'), isTrue); + }); + + test('keeps optimistic user echo when remote does not match content', () { + final now = DateTime(2026, 3, 30, 0, 6, 0); + final local = [ + TextMessageItem( + id: 'user-local-1', + content: 'A', + timestamp: now, + sender: MessageSender.user, + isLocalEcho: true, + ), + ]; + final remote = [ + TextMessageItem( + id: 'db-user-2', + content: 'B', + timestamp: now.add(const Duration(seconds: 1)), + sender: MessageSender.user, + ), + ]; + + final merged = ChatTimelineReconciler.merge( + localItems: local, + remoteItems: remote, + ); + + expect(merged.any((item) => item.id == 'user-local-1'), isTrue); + expect(merged.any((item) => item.id == 'db-user-2'), isTrue); + }); + + test( + 'dedupes attachment message even when local uses path and remote uses url', + () { + final now = DateTime(2026, 3, 30, 0, 6, 0); + final local = [ + TextMessageItem( + id: 'user-local-attachment', + content: '看这张图', + timestamp: now, + sender: MessageSender.user, + isLocalEcho: true, + attachments: const [ + {'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'}, + ], + ), + ]; + final remote = [ + TextMessageItem( + id: 'db-user-attachment', + content: '看这张图', + timestamp: now.add(const Duration(seconds: 3)), + sender: MessageSender.user, + attachments: const [ + {'url': 'https://cdn.example.com/a.jpg', 'mimeType': 'image/jpeg'}, + ], + ), + ]; + + final merged = ChatTimelineReconciler.merge( + localItems: local, + remoteItems: remote, + ); + + expect(merged.any((item) => item.id == 'user-local-attachment'), isFalse); + expect(merged.any((item) => item.id == 'db-user-attachment'), isTrue); + }, + ); + + test( + 'matches nearest optimistic echo when same text sent multiple times', + () { + final base = DateTime(2026, 3, 30, 0, 6, 0); + final local = [ + TextMessageItem( + id: 'echo-older', + content: '你好', + timestamp: base, + sender: MessageSender.user, + isLocalEcho: true, + ), + TextMessageItem( + id: 'echo-newer', + content: '你好', + timestamp: base.add(const Duration(seconds: 45)), + sender: MessageSender.user, + isLocalEcho: true, + ), + ]; + final remote = [ + TextMessageItem( + id: 'db-user-newer', + content: '你好', + timestamp: base.add(const Duration(seconds: 47)), + sender: MessageSender.user, + ), + ]; + + final merged = ChatTimelineReconciler.merge( + localItems: local, + remoteItems: remote, + ); + + expect(merged.any((item) => item.id == 'echo-older'), isTrue); + expect(merged.any((item) => item.id == 'echo-newer'), isFalse); + expect(merged.any((item) => item.id == 'db-user-newer'), isTrue); + }, + ); + + test('does not dedupe when attachment identity differs', () { + final now = DateTime(2026, 3, 30, 0, 6, 0); + final local = [ + TextMessageItem( + id: 'echo-attachment-1', + content: '看这张图', + timestamp: now, + sender: MessageSender.user, + isLocalEcho: true, + attachments: const [ + {'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'}, + ], + ), + ]; + final remote = [ + TextMessageItem( + id: 'db-attachment-2', + content: '看这张图', + timestamp: now.add(const Duration(seconds: 2)), + sender: MessageSender.user, + attachments: const [ + {'url': 'https://cdn.example.com/b.jpg', 'mimeType': 'image/jpeg'}, + ], + ), + ]; + + final merged = ChatTimelineReconciler.merge( + localItems: local, + remoteItems: remote, + ); + + expect(merged.any((item) => item.id == 'echo-attachment-1'), isTrue); + expect(merged.any((item) => item.id == 'db-attachment-2'), isTrue); + }); +} diff --git a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart index ebbe911..d25c650 100644 --- a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart +++ b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/core/chat/chat_api.dart'; import 'package:social_app/core/chat/chat_history_repository.dart'; +import 'package:social_app/data/cache/cache_scope.dart'; import 'package:social_app/data/cache/cache_store.dart'; class _FakeChatApi implements ChatApi { @@ -42,6 +43,7 @@ class _FakeChatApi implements ChatApi { @override Future> streamRunEvents( String threadId, { + required String runId, String? lastEventId, }) { throw UnimplementedError(); @@ -79,6 +81,14 @@ class _FakeChatApi implements ChatApi { } void main() { + setUp(() { + CacheScope.configureProvider(() => null); + }); + + tearDown(() { + CacheScope.resetProvider(); + }); + test('loads first-page history from cache on second read', () async { final chatApi = _FakeChatApi(); chatApi.setHistory('first:default', { @@ -113,4 +123,32 @@ void main() { expect(second.messages.length, 1); expect(chatApi.historyCalls['first:default'], 1); }); + + test('separates history cache by global scope provider', () async { + final chatApi = _FakeChatApi(); + chatApi.setHistory('first:default', { + 'scope': 'history_day', + 'threadId': 't1', + 'day': '2026-03-29', + 'hasMore': false, + 'messages': const [], + }); + + final repository = ChatHistoryRepository( + chatApi: chatApi, + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); + + var scope = 'user-a'; + CacheScope.configureProvider(() => scope); + + await repository.loadHistory(); + scope = 'user-b'; + await repository.loadHistory(); + + expect(chatApi.historyCalls['first:default'], 2); + }); } diff --git a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart new file mode 100644 index 0000000..2a29328 --- /dev/null +++ b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/agent_stage.dart'; +import 'package:social_app/core/chat/ag_ui_event.dart'; +import 'package:social_app/core/chat/ag_ui_service.dart'; +import 'package:social_app/core/chat/chat_api.dart'; +import 'package:social_app/core/chat/chat_list_item.dart'; +import 'package:social_app/core/l10n/l10n.dart'; +import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; + +class _NoopChatApi implements ChatApi { + @override + Future cancelRun({required String threadId, required String runId}) { + throw UnimplementedError(); + } + + @override + Future> createRun(Map runInput) { + throw UnimplementedError(); + } + + @override + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }) { + throw UnimplementedError(); + } + + @override + Future fetchAttachmentPreview(String previewPath) { + throw UnimplementedError(); + } + + @override + Future> streamRunEvents( + String threadId, { + required String runId, + String? lastEventId, + }) { + throw UnimplementedError(); + } + + @override + Future transcribeAudio(String filePath) { + throw UnimplementedError(); + } + + @override + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) { + throw UnimplementedError(); + } +} + +class _FakeAgUiService extends AgUiService { + _FakeAgUiService() : super(chatApi: _NoopChatApi(), onEvent: (_) {}); + + Future Function( + String content, + List? attachments, + )? + sendMessageHandler; + + Future Function({DateTime? beforeDate, bool forceRefresh})? + loadHistoryHandler; + + int loadHistoryCalls = 0; + + void emitEventForTest(AgUiEvent event) { + onEvent(event); + } + + @override + Future sendMessage( + String content, { + List? attachments, + }) async { + final handler = sendMessageHandler; + if (handler == null) { + throw UnimplementedError(); + } + return handler(content, attachments); + } + + @override + Future loadHistory({ + DateTime? beforeDate, + bool forceRefresh = false, + }) async { + loadHistoryCalls += 1; + final handler = loadHistoryHandler; + if (handler == null) { + throw UnimplementedError(); + } + return handler(beforeDate: beforeDate, forceRefresh: forceRefresh); + } + + @override + Future setUserContext(String? userId) async {} +} + +HistorySnapshot _snapshot( + List messages, { + bool hasMore = false, +}) { + return HistorySnapshot( + scope: 'history_day', + threadId: 'thread-1', + day: '2026-03-30', + hasMore: hasMore, + messages: messages, + ); +} + +HistoryMessage _historyMessage({ + required String id, + required int seq, + required String role, + required String content, + required DateTime timestamp, +}) { + return HistoryMessage( + id: id, + seq: seq, + role: role, + content: content, + timestamp: timestamp, + ); +} + +void main() { + setUp(() { + L10n.setLocale(const Locale('zh')); + }); + + test( + 'loadHistory ignores stale result after switchUser epoch change', + () async { + final service = _FakeAgUiService(); + final completer = Completer(); + service.loadHistoryHandler = + ({DateTime? beforeDate, bool forceRefresh = false}) { + return completer.future; + }; + + final bloc = ChatBloc( + service: service, + chatApi: _NoopChatApi(), + recoveryPollInterval: const Duration(milliseconds: 1), + recoveryTimeout: const Duration(milliseconds: 80), + ); + + final pendingLoad = bloc.loadHistory(); + await bloc.switchUser('user-b'); + completer.complete( + _snapshot([ + _historyMessage( + id: 'old-1', + seq: 1, + role: 'assistant', + content: 'old session data', + timestamp: DateTime.now(), + ), + ]), + ); + await pendingLoad; + + expect(bloc.state.items, isEmpty); + expect(bloc.state.isLoadingHistory, isFalse); + }, + ); + + test( + 'sendMessage recovers from premature SSE close with polled history', + () async { + final service = _FakeAgUiService(); + service.sendMessageHandler = (content, attachments) async { + throw StateError('SSE closed before terminal event for run'); + }; + + var loadAttempt = 0; + service.loadHistoryHandler = + ({DateTime? beforeDate, bool forceRefresh = false}) async { + loadAttempt += 1; + final now = DateTime.now(); + if (loadAttempt == 1) { + return _snapshot([ + _historyMessage( + id: 'db-user-1', + seq: 1, + role: 'user', + content: 'hello', + timestamp: now, + ), + ]); + } + return _snapshot([ + _historyMessage( + id: 'db-user-1', + seq: 1, + role: 'user', + content: 'hello', + timestamp: now, + ), + _historyMessage( + id: 'db-assistant-1', + seq: 2, + role: 'assistant', + content: 'world', + timestamp: now.add(const Duration(seconds: 1)), + ), + ]); + }; + + final bloc = ChatBloc( + service: service, + chatApi: _NoopChatApi(), + recoveryPollInterval: const Duration(milliseconds: 1), + recoveryTimeout: const Duration(milliseconds: 50), + ); + await bloc.sendMessage('hello'); + + final userMessages = bloc.state.items + .whereType() + .where((item) => item.sender == MessageSender.user) + .toList(); + expect(userMessages.length, 1); + expect(userMessages.first.id, 'db-user-1'); + expect( + bloc.state.items.any( + (item) => + item is TextMessageItem && + item.sender == MessageSender.ai && + item.content == 'world', + ), + isTrue, + ); + expect(bloc.state.error, isNull); + expect(service.loadHistoryCalls, 2); + }, + ); + + test('sendMessage reports error after recovery attempts exhausted', () async { + final service = _FakeAgUiService(); + service.sendMessageHandler = (content, attachments) async { + throw StateError('SSE closed before terminal event for run'); + }; + service.loadHistoryHandler = + ({DateTime? beforeDate, bool forceRefresh = false}) async { + final now = DateTime.now(); + return _snapshot([ + _historyMessage( + id: 'db-user-1', + seq: 1, + role: 'user', + content: 'hello', + timestamp: now, + ), + ]); + }; + + final bloc = ChatBloc( + service: service, + chatApi: _NoopChatApi(), + recoveryPollInterval: const Duration(milliseconds: 1), + recoveryTimeout: const Duration(milliseconds: 15), + ); + await bloc.sendMessage('hello'); + + expect(bloc.state.error, L10n.current.chatSseInterruptedRetry); + expect(service.loadHistoryCalls, greaterThanOrEqualTo(1)); + }); + + test( + 'tracks hasSeenStep to distinguish requesting vs processing stage', + () async { + final service = _FakeAgUiService(); + final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); + + service.emitEventForTest( + RunStartedEvent(threadId: 'thread-1', runId: 'run-1'), + ); + expect(bloc.state.isWaitingFirstToken, isTrue); + expect(bloc.state.hasSeenStep, isFalse); + expect(bloc.state.currentStage, isNull); + + service.emitEventForTest(StepStartedEvent(stepName: 'router')); + expect(bloc.state.hasSeenStep, isTrue); + expect(bloc.state.currentStage, AgentStage.routing); + + service.emitEventForTest(StepFinishedEvent(stepName: 'router')); + expect(bloc.state.hasSeenStep, isTrue); + expect(bloc.state.currentStage, isNull); + }, + ); +} diff --git a/apps/test/shared/widgets/ui_schema/ui_schema_renderer_test.dart b/apps/test/shared/widgets/ui_schema/ui_schema_renderer_test.dart new file mode 100644 index 0000000..207c0a7 --- /dev/null +++ b/apps/test/shared/widgets/ui_schema/ui_schema_renderer_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/l10n/app_localizations.dart'; +import 'package:social_app/shared/widgets/ui_schema/ui_schema_renderer.dart'; + +Map _badgeSchema({ + required String label, + required String status, +}) { + return { + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'children': [ + {'type': 'badge', 'label': label, 'status': status}, + ], + }, + }; +} + +Widget _buildRendererHost(Map schema, Locale locale) { + return MaterialApp( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return UiSchemaRenderer(context, colorScheme).renderSchema(schema); + }, + ), + ), + ); +} + +Map _buttonSchema(Map action) { + return { + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'children': [ + { + 'type': 'button', + 'label': '查看详情', + 'style': 'primary', + 'action': action, + }, + ], + }, + }; +} + +Widget _buildRouterHost(Map schema, Locale locale) { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + body: UiSchemaRenderer(context, colorScheme).renderSchema(schema), + ); + }, + ), + GoRoute( + path: '/todo/123', + builder: (context, state) => const Scaffold(body: Text('todo-detail')), + ), + ], + ); + + return MaterialApp.router( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + routerConfig: router, + ); +} + +void main() { + testWidgets('localizes stable status token labels', (tester) async { + final schema = _badgeSchema(label: 'ui.status.success', status: 'success'); + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('已完成'), findsOneWidget); + }); + + testWidgets('localizes legacy uppercase status labels', (tester) async { + final schema = _badgeSchema(label: 'SUCCESS', status: 'success'); + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('已完成'), findsOneWidget); + }); + + testWidgets('keeps unknown status token label unchanged', (tester) async { + final schema = _badgeSchema( + label: 'ui.status.processing', + status: 'success', + ); + await tester.pumpWidget(_buildRendererHost(schema, const Locale('en'))); + + expect(find.text('ui.status.processing'), findsOneWidget); + }); + + testWidgets('keeps custom badge label unchanged', (tester) async { + final schema = _badgeSchema(label: '创建完成', status: 'success'); + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('创建完成'), findsOneWidget); + }); + + testWidgets('navigates when navigation action path is valid', (tester) async { + final schema = _buttonSchema({ + 'type': 'navigation', + 'path': '/todo/123', + 'params': {'from': 'assistant', 'focus': true}, + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pumpAndSettle(); + + expect(find.text('todo-detail'), findsOneWidget); + }); + + testWidgets('shows l10n error when navigation path is invalid', ( + tester, + ) async { + final schema = _buttonSchema({ + 'type': 'navigation', + 'path': 'https://example.com', + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('导航路径无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('shows l10n error when url action is invalid', (tester) async { + final schema = _buttonSchema({'type': 'url', 'url': 'javascript:alert(1)'}); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('链接无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('rejects encoded navigation path payload', (tester) async { + final schema = _buttonSchema({ + 'type': 'navigation', + 'path': '/calendar/events/%2F%2Fevil.example.com', + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('导航路径无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('rejects dot-segment traversal navigation path', (tester) async { + final schema = _buttonSchema({ + 'type': 'navigation', + 'path': '/todo/../../settings', + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('导航路径无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('rejects private-network URL action', (tester) async { + final schema = _buttonSchema({ + 'type': 'url', + 'url': 'http://127.0.0.1:8080/admin', + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('链接无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('rejects ipv6 loopback URL action', (tester) async { + final schema = _buttonSchema({ + 'type': 'url', + 'url': 'http://[0:0:0:0:0:0:0:1]:8080/admin', + }); + + await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh'))); + await tester.tap(find.text('查看详情')); + await tester.pump(); + + expect(find.text('链接无效'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); +} diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 42465b8..29ef760 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -17,6 +17,7 @@ from v1.auth.schemas import ( PhoneSessionCreateRequest, SessionRefreshRequest, SessionResponse, + UserByIdResponse, UserByPhoneResponse, ) from v1.auth.service import AuthServiceGateway @@ -205,6 +206,41 @@ class SupabaseAuthGateway(AuthServiceGateway): ), ) + async def get_user_by_id(self, user_id: str) -> UserByIdResponse: + try: + admin_client = self._get_admin_client() + user = await asyncio.to_thread(admin_client.auth.get_user_by_id, user_id) + if user is None: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + user_attrs = getattr(user, "user", user) + return UserByIdResponse( + id=str(getattr(user_attrs, "id", "")), + phone=getattr(user_attrs, "phone", None), + created_at=str(getattr(user_attrs, "created_at", "")), + phone_confirmed_at=( + str(getattr(user_attrs, "phone_confirmed_at", "")) + if getattr(user_attrs, "phone_confirmed_at", None) + else None + ), + ) + except AuthError as exc: + logger.warning("Get user by id failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) from exc + async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: normalized_query = _normalize_phone_search_query(query) if not normalized_query: diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 4fc3f32..446283a 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -49,6 +49,13 @@ class UserByPhoneResponse(BaseModel): phone_confirmed_at: str | None = None +class UserByIdResponse(BaseModel): + id: str + phone: str | None = None + created_at: str + phone_confirmed_at: str | None = None + + class OtpSendResponse(BaseModel): phone: str = Field(pattern=SUPABASE_PHONE_PATTERN)