chore: 更新国际化翻译及 UI 组件优化
This commit is contained in:
@@ -8,6 +8,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
final Color g3Text, g3Divider, g3Border;
|
final Color g3Text, g3Divider, g3Border;
|
||||||
final Color eventDefault;
|
final Color eventDefault;
|
||||||
final Color eventArchived;
|
final Color eventArchived;
|
||||||
|
final Color warning;
|
||||||
|
final Color warningContainer;
|
||||||
|
final Color onWarning;
|
||||||
|
final Color onWarningContainer;
|
||||||
|
|
||||||
const AppColorPalette({
|
const AppColorPalette({
|
||||||
required this.eventPresetColors,
|
required this.eventPresetColors,
|
||||||
@@ -23,6 +27,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
required this.g3Border,
|
required this.g3Border,
|
||||||
required this.eventDefault,
|
required this.eventDefault,
|
||||||
required this.eventArchived,
|
required this.eventArchived,
|
||||||
|
required this.warning,
|
||||||
|
required this.warningContainer,
|
||||||
|
required this.onWarning,
|
||||||
|
required this.onWarningContainer,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const light = AppColorPalette(
|
static const light = AppColorPalette(
|
||||||
@@ -51,6 +59,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
g3Border: Color(0xFFCFE1FB),
|
g3Border: Color(0xFFCFE1FB),
|
||||||
eventDefault: Color(0xFF3B82F6),
|
eventDefault: Color(0xFF3B82F6),
|
||||||
eventArchived: Color(0xFF64748B),
|
eventArchived: Color(0xFF64748B),
|
||||||
|
warning: Color(0xFFF59E0B),
|
||||||
|
warningContainer: Color(0xFFFEF3C7),
|
||||||
|
onWarning: Color(0xFFFFFFFF),
|
||||||
|
onWarningContainer: Color(0xFF78350F),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const dark = AppColorPalette(
|
static const dark = AppColorPalette(
|
||||||
@@ -79,6 +91,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
g3Border: Color(0xFF2563EB),
|
g3Border: Color(0xFF2563EB),
|
||||||
eventDefault: Color(0xFF60A5FA),
|
eventDefault: Color(0xFF60A5FA),
|
||||||
eventArchived: Color(0xFF94A3B8),
|
eventArchived: Color(0xFF94A3B8),
|
||||||
|
warning: Color(0xFFFBBF24),
|
||||||
|
warningContainer: Color(0xFF78350F),
|
||||||
|
onWarning: Color(0xFF0F172A),
|
||||||
|
onWarningContainer: Color(0xFFFEF3C7),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -96,6 +112,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
Color? g3Border,
|
Color? g3Border,
|
||||||
Color? eventDefault,
|
Color? eventDefault,
|
||||||
Color? eventArchived,
|
Color? eventArchived,
|
||||||
|
Color? warning,
|
||||||
|
Color? warningContainer,
|
||||||
|
Color? onWarning,
|
||||||
|
Color? onWarningContainer,
|
||||||
}) {
|
}) {
|
||||||
return AppColorPalette(
|
return AppColorPalette(
|
||||||
eventPresetColors: eventPresetColors ?? this.eventPresetColors,
|
eventPresetColors: eventPresetColors ?? this.eventPresetColors,
|
||||||
@@ -111,6 +131,10 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
|
|||||||
g3Border: g3Border ?? this.g3Border,
|
g3Border: g3Border ?? this.g3Border,
|
||||||
eventDefault: eventDefault ?? this.eventDefault,
|
eventDefault: eventDefault ?? this.eventDefault,
|
||||||
eventArchived: eventArchived ?? this.eventArchived,
|
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<AppColorPalette> {
|
|||||||
g3Border: Color.lerp(g3Border, other.g3Border, t)!,
|
g3Border: Color.lerp(g3Border, other.g3Border, t)!,
|
||||||
eventDefault: Color.lerp(eventDefault, other.eventDefault, t)!,
|
eventDefault: Color.lerp(eventDefault, other.eventDefault, t)!,
|
||||||
eventArchived: Color.lerp(eventArchived, other.eventArchived, 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,
|
||||||
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ String? mapErrorCodeToL10nKey(
|
|||||||
return 'errorGenericSafe';
|
return 'errorGenericSafe';
|
||||||
case 'AGENT_RUN_MESSAGES_INVALID':
|
case 'AGENT_RUN_MESSAGES_INVALID':
|
||||||
return 'errorGenericSafe';
|
return 'errorGenericSafe';
|
||||||
|
case 'AGENT_INVALID_RUN_ID':
|
||||||
|
return 'errorGenericSafe';
|
||||||
case 'AGENT_INVALID_LAST_EVENT_ID':
|
case 'AGENT_INVALID_LAST_EVENT_ID':
|
||||||
return 'errorAgentInvalidLastEventId';
|
return 'errorAgentInvalidLastEventId';
|
||||||
case 'AGENT_SSE_CONNECTION_LIMIT':
|
case 'AGENT_SSE_CONNECTION_LIMIT':
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ class LoginView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LoginViewState extends State<LoginView> {
|
class _LoginViewState extends State<LoginView> {
|
||||||
static const _dialCodes = <String>['+86', '+1', '+44', '+81', '+65'];
|
|
||||||
|
|
||||||
final _phoneController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
bool _agreedToTerms = false;
|
bool _agreedToTerms = false;
|
||||||
@@ -230,7 +228,6 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
prefix: PhonePrefixSelector(
|
prefix: PhonePrefixSelector(
|
||||||
value: state.dialCode,
|
value: state.dialCode,
|
||||||
items: _dialCodes,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context
|
context
|
||||||
.read<LoginCubit>()
|
.read<LoginCubit>()
|
||||||
|
|||||||
@@ -113,13 +113,15 @@ class FriendRequestResponse {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory FriendRequestResponse.fromJson(Map<String, dynamic> json) {
|
factory FriendRequestResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawContent = json['content'] as Map<String, dynamic>?;
|
||||||
|
final content = rawContent?['message'] as String?;
|
||||||
return FriendRequestResponse(
|
return FriendRequestResponse(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
sender: UserBasicInfo.fromJson(json['sender'] as Map<String, dynamic>),
|
sender: UserBasicInfo.fromJson(json['sender'] as Map<String, dynamic>),
|
||||||
recipient: UserBasicInfo.fromJson(
|
recipient: UserBasicInfo.fromJson(
|
||||||
json['recipient'] as Map<String, dynamic>,
|
json['recipient'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
content: json['content'] as String?,
|
content: content,
|
||||||
status: json['status'] as String,
|
status: json['status'] as String,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ class FriendRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory FriendRequest.fromJson(Map<String, dynamic> json) {
|
factory FriendRequest.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawContent = json['content'] as Map<String, dynamic>?;
|
||||||
|
final content = rawContent?['message'] as String?;
|
||||||
return FriendRequest(
|
return FriendRequest(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
sender: FriendUser.fromJson(json['sender'] as Map<String, dynamic>),
|
sender: FriendUser.fromJson(json['sender'] as Map<String, dynamic>),
|
||||||
recipient: FriendUser.fromJson(json['recipient'] as Map<String, dynamic>),
|
recipient: FriendUser.fromJson(json['recipient'] as Map<String, dynamic>),
|
||||||
content: json['content'] as String?,
|
content: content,
|
||||||
status: _friendRequestStatusFromApi(json['status'] as String),
|
status: _friendRequestStatusFromApi(json['status'] as String),
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -342,9 +342,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
if (showWaitingIndicator)
|
if (showWaitingIndicator)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: HomeWaitingIndicator(
|
child: HomeWaitingIndicator(label: _agentWaitingLabel(state)),
|
||||||
label: stageLabel(state.currentStage),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
@@ -494,6 +492,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
return state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
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() {
|
void _handleScrollChanged() {
|
||||||
if (!_scrollController.hasClients) {
|
if (!_scrollController.hasClients) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
const homeBackgroundFieldKey = ValueKey('home_background_field');
|
const homeBackgroundFieldKey = ValueKey('home_background_field');
|
||||||
|
|
||||||
class HomeBackgroundField extends StatelessWidget {
|
class HomeBackgroundField extends StatelessWidget {
|
||||||
@@ -13,12 +15,65 @@ class HomeBackgroundField extends StatelessWidget {
|
|||||||
key: homeBackgroundFieldKey,
|
key: homeBackgroundFieldKey,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomRight,
|
||||||
colors: [colorScheme.surface, colorScheme.surfaceContainerLowest],
|
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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../../../core/l10n/l10n.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
const homeFloatingHeaderKey = ValueKey('home_floating_header');
|
const homeFloatingHeaderKey = ValueKey('home_floating_header');
|
||||||
const homeFloatingHeaderTitleKey = ValueKey('home_floating_header_title');
|
const homeFloatingHeaderTitleKey = ValueKey('home_floating_header_title');
|
||||||
|
const _actionSlotWidth =
|
||||||
|
(AppSpacing.xxl + AppSpacing.lg) * 2 + AppSpacing.sm + AppSpacing.sm;
|
||||||
|
|
||||||
class HomeFloatingHeader extends StatelessWidget {
|
class HomeFloatingHeader extends StatelessWidget {
|
||||||
const HomeFloatingHeader({
|
const HomeFloatingHeader({
|
||||||
@@ -33,21 +36,52 @@ class HomeFloatingHeader extends StatelessWidget {
|
|||||||
AppSpacing.xs,
|
AppSpacing.xs,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface.withValues(alpha: 0.95),
|
color: colorScheme.surface.withValues(alpha: 0.92),
|
||||||
border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)),
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.65),
|
||||||
),
|
),
|
||||||
child: Stack(
|
),
|
||||||
alignment: Alignment.center,
|
boxShadow: [
|
||||||
children: [
|
BoxShadow(
|
||||||
Row(
|
color: colorScheme.shadow.withValues(alpha: 0.04),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
blurRadius: AppSpacing.xl,
|
||||||
|
offset: const Offset(AppSpacing.none, AppSpacing.xs),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_HeaderIconButton(
|
SizedBox(
|
||||||
|
width: _actionSlotWidth,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _HeaderIconButton(
|
||||||
icon: LucideIcons.settings,
|
icon: LucideIcons.settings,
|
||||||
onPressed: onTapSettings,
|
onPressed: onTapSettings,
|
||||||
),
|
),
|
||||||
Row(
|
),
|
||||||
|
),
|
||||||
|
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,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_HeaderIconButton(
|
_HeaderIconButton(
|
||||||
@@ -61,23 +95,6 @@ class HomeFloatingHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -103,7 +120,11 @@ class _HeaderIconButton extends StatelessWidget {
|
|||||||
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||||
),
|
),
|
||||||
onPressed: onPressed,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.error,
|
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(
|
constraints: const BoxConstraints(
|
||||||
minWidth: AppSpacing.lg,
|
minWidth: AppSpacing.lg,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"homeStopRequested": "Stop requested",
|
"homeStopRequested": "Stop requested",
|
||||||
"homeNoValidSpeech": "No valid speech detected. Please move closer to the microphone and retry.",
|
"homeNoValidSpeech": "No valid speech detected. Please move closer to the microphone and retry.",
|
||||||
"agentStageRouting": "Analyzing intent",
|
"agentStageRouting": "Analyzing intent",
|
||||||
|
"agentStageRequesting": "Requesting server",
|
||||||
"agentStageExecution": "Executing task",
|
"agentStageExecution": "Executing task",
|
||||||
"agentStageMemory": "Loading memory",
|
"agentStageMemory": "Loading memory",
|
||||||
"agentStageProcessing": "Processing task",
|
"agentStageProcessing": "Processing task",
|
||||||
@@ -159,7 +160,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uiSchemaActionFallback": "Action",
|
"uiSchemaActionFallback": "Action",
|
||||||
|
"uiSchemaStatusInfo": "Info",
|
||||||
|
"uiSchemaStatusSuccess": "Completed",
|
||||||
|
"uiSchemaStatusWarning": "Warning",
|
||||||
|
"uiSchemaStatusError": "Failed",
|
||||||
|
"uiSchemaStatusPending": "In progress",
|
||||||
"uiSchemaActionNotImplemented": "This action is not available yet",
|
"uiSchemaActionNotImplemented": "This action is not available yet",
|
||||||
|
"uiSchemaUrlInvalid": "Invalid URL",
|
||||||
|
"uiSchemaUrlOpenFailed": "Unable to open URL",
|
||||||
"uiSchemaNavigationInvalidParams": "Invalid navigation params",
|
"uiSchemaNavigationInvalidParams": "Invalid navigation params",
|
||||||
"uiSchemaNavigationInvalidPath": "Invalid navigation path",
|
"uiSchemaNavigationInvalidPath": "Invalid navigation path",
|
||||||
"notificationSnoozeMinutes": "{minutes} min",
|
"notificationSnoozeMinutes": "{minutes} min",
|
||||||
@@ -771,5 +779,12 @@
|
|||||||
"title": {}
|
"title": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messagesCalendarCardDeletedWithoutTitle": "Calendar event deleted"
|
"messagesCalendarCardDeletedWithoutTitle": "Calendar event deleted",
|
||||||
|
"calendarDetailSubscribers": "Subscribers ({count})",
|
||||||
|
"@calendarDetailSubscribers": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarOwnerBadge": "Owner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,6 +464,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'意图识别中'**
|
/// **'意图识别中'**
|
||||||
String get agentStageRouting;
|
String get agentStageRouting;
|
||||||
|
|
||||||
|
/// No description provided for @agentStageRequesting.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'网络请求中'**
|
||||||
|
String get agentStageRequesting;
|
||||||
|
|
||||||
/// No description provided for @agentStageExecution.
|
/// No description provided for @agentStageExecution.
|
||||||
///
|
///
|
||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
@@ -836,12 +842,54 @@ abstract class AppLocalizations {
|
|||||||
/// **'操作'**
|
/// **'操作'**
|
||||||
String get uiSchemaActionFallback;
|
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.
|
/// No description provided for @uiSchemaActionNotImplemented.
|
||||||
///
|
///
|
||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'该操作暂未接入'**
|
/// **'该操作暂未接入'**
|
||||||
String get uiSchemaActionNotImplemented;
|
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.
|
/// No description provided for @uiSchemaNavigationInvalidParams.
|
||||||
///
|
///
|
||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
@@ -3408,6 +3456,18 @@ abstract class AppLocalizations {
|
|||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'日历事件已删除'**
|
/// **'日历事件已删除'**
|
||||||
String get messagesCalendarCardDeletedWithoutTitle;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get agentStageRouting => 'Analyzing intent';
|
String get agentStageRouting => 'Analyzing intent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get agentStageRequesting => 'Requesting server';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get agentStageExecution => 'Executing task';
|
String get agentStageExecution => 'Executing task';
|
||||||
|
|
||||||
@@ -411,9 +414,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get uiSchemaActionFallback => 'Action';
|
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
|
@override
|
||||||
String get uiSchemaActionNotImplemented => 'This action is not available yet';
|
String get uiSchemaActionNotImplemented => 'This action is not available yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaUrlInvalid => 'Invalid URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaUrlOpenFailed => 'Unable to open URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get uiSchemaNavigationInvalidParams => 'Invalid navigation params';
|
String get uiSchemaNavigationInvalidParams => 'Invalid navigation params';
|
||||||
|
|
||||||
@@ -1837,4 +1861,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get messagesCalendarCardDeletedWithoutTitle =>
|
String get messagesCalendarCardDeletedWithoutTitle =>
|
||||||
'Calendar event deleted';
|
'Calendar event deleted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String calendarDetailSubscribers(int count) {
|
||||||
|
return 'Subscribers ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calendarOwnerBadge => 'Owner';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get agentStageRouting => '意图识别中';
|
String get agentStageRouting => '意图识别中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get agentStageRequesting => '网络请求中';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get agentStageExecution => '任务执行中';
|
String get agentStageExecution => '任务执行中';
|
||||||
|
|
||||||
@@ -397,9 +400,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get uiSchemaActionFallback => '操作';
|
String get uiSchemaActionFallback => '操作';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaStatusInfo => '提示';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaStatusSuccess => '已完成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaStatusWarning => '提醒';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaStatusError => '失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaStatusPending => '进行中';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get uiSchemaActionNotImplemented => '该操作暂未接入';
|
String get uiSchemaActionNotImplemented => '该操作暂未接入';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaUrlInvalid => '链接无效';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get uiSchemaUrlOpenFailed => '无法打开链接';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get uiSchemaNavigationInvalidParams => '导航参数无效';
|
String get uiSchemaNavigationInvalidParams => '导航参数无效';
|
||||||
|
|
||||||
@@ -1790,4 +1814,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get messagesCalendarCardDeletedWithoutTitle => '日历事件已删除';
|
String get messagesCalendarCardDeletedWithoutTitle => '日历事件已删除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String calendarDetailSubscribers(int count) {
|
||||||
|
return '已订阅 ($count人)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calendarOwnerBadge => '所有者';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"homeStopRequested": "已请求停止",
|
"homeStopRequested": "已请求停止",
|
||||||
"homeNoValidSpeech": "未识别到有效语音,请靠近麦克风并连续说话后重试",
|
"homeNoValidSpeech": "未识别到有效语音,请靠近麦克风并连续说话后重试",
|
||||||
"agentStageRouting": "意图识别中",
|
"agentStageRouting": "意图识别中",
|
||||||
|
"agentStageRequesting": "网络请求中",
|
||||||
"agentStageExecution": "任务执行中",
|
"agentStageExecution": "任务执行中",
|
||||||
"agentStageMemory": "记忆提取中",
|
"agentStageMemory": "记忆提取中",
|
||||||
"agentStageProcessing": "任务处理中",
|
"agentStageProcessing": "任务处理中",
|
||||||
@@ -159,7 +160,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uiSchemaActionFallback": "操作",
|
"uiSchemaActionFallback": "操作",
|
||||||
|
"uiSchemaStatusInfo": "提示",
|
||||||
|
"uiSchemaStatusSuccess": "已完成",
|
||||||
|
"uiSchemaStatusWarning": "提醒",
|
||||||
|
"uiSchemaStatusError": "失败",
|
||||||
|
"uiSchemaStatusPending": "进行中",
|
||||||
"uiSchemaActionNotImplemented": "该操作暂未接入",
|
"uiSchemaActionNotImplemented": "该操作暂未接入",
|
||||||
|
"uiSchemaUrlInvalid": "链接无效",
|
||||||
|
"uiSchemaUrlOpenFailed": "无法打开链接",
|
||||||
"uiSchemaNavigationInvalidParams": "导航参数无效",
|
"uiSchemaNavigationInvalidParams": "导航参数无效",
|
||||||
"uiSchemaNavigationInvalidPath": "导航路径无效",
|
"uiSchemaNavigationInvalidPath": "导航路径无效",
|
||||||
"notificationSnoozeMinutes": "{minutes} 分钟",
|
"notificationSnoozeMinutes": "{minutes} 分钟",
|
||||||
@@ -771,5 +779,12 @@
|
|||||||
"title": {}
|
"title": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messagesCalendarCardDeletedWithoutTitle": "日历事件已删除"
|
"messagesCalendarCardDeletedWithoutTitle": "日历事件已删除",
|
||||||
|
"calendarDetailSubscribers": "已订阅 ({count}人)",
|
||||||
|
"@calendarDetailSubscribers": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarOwnerBadge": "所有者"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
class AppInput extends StatelessWidget {
|
class AppInput extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String hint;
|
final String hint;
|
||||||
@@ -43,7 +45,20 @@ class AppInput extends StatelessWidget {
|
|||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
enabled: enabled,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../data/models/dial_codes.dart';
|
||||||
|
|
||||||
class PhonePrefixSelector extends StatelessWidget {
|
class PhonePrefixSelector extends StatelessWidget {
|
||||||
const PhonePrefixSelector({
|
const PhonePrefixSelector({
|
||||||
super.key,
|
super.key,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.items,
|
List<DialCode>? items,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
});
|
}) : items = items ?? kDialCodes;
|
||||||
|
|
||||||
final String value;
|
final String value;
|
||||||
final List<String> items;
|
final List<DialCode> items;
|
||||||
final ValueChanged<String>? onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,7 +23,10 @@ class PhonePrefixSelector extends StatelessWidget {
|
|||||||
onSelected: onChanged,
|
onSelected: onChanged,
|
||||||
itemBuilder: (context) => items
|
itemBuilder: (context) => items
|
||||||
.map(
|
.map(
|
||||||
(item) => PopupMenuItem<String>(value: item, child: Text(item)),
|
(item) => PopupMenuItem<String>(
|
||||||
|
value: item.value,
|
||||||
|
child: Text(item.value),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
|
import '../../../core/theme/design_tokens.dart';
|
||||||
import 'toast_type.dart';
|
import 'toast_type.dart';
|
||||||
|
|
||||||
class ToastTypeConfig {
|
class ToastTypeConfig {
|
||||||
@@ -25,18 +26,22 @@ class ToastTypeConfig {
|
|||||||
|
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
ToastType.success => ToastTypeConfig(
|
ToastType.success => ToastTypeConfig(
|
||||||
surfaceColor: colorScheme.tertiaryContainer,
|
surfaceColor: colorScheme.primaryContainer,
|
||||||
borderColor: colorScheme.tertiary,
|
borderColor: colorScheme.primary,
|
||||||
iconColor: colorScheme.tertiary,
|
iconColor: colorScheme.primary,
|
||||||
textColor: colorScheme.onTertiaryContainer,
|
textColor: colorScheme.onPrimaryContainer,
|
||||||
label: l10n.toastLabelSuccess,
|
label: l10n.toastLabelSuccess,
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
),
|
),
|
||||||
ToastType.warning => ToastTypeConfig(
|
ToastType.warning => ToastTypeConfig(
|
||||||
surfaceColor: colorScheme.secondaryContainer,
|
surfaceColor: Theme.of(
|
||||||
borderColor: colorScheme.secondary,
|
context,
|
||||||
iconColor: colorScheme.secondary,
|
).extension<AppColorPalette>()!.warningContainer,
|
||||||
textColor: colorScheme.onSecondaryContainer,
|
borderColor: Theme.of(context).extension<AppColorPalette>()!.warning,
|
||||||
|
iconColor: Theme.of(context).extension<AppColorPalette>()!.warning,
|
||||||
|
textColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).extension<AppColorPalette>()!.onWarningContainer,
|
||||||
label: l10n.toastLabelWarning,
|
label: l10n.toastLabelWarning,
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dependencies:
|
|||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
package_info_plus: ^8.0.3
|
package_info_plus: ^8.0.3
|
||||||
drag_and_drop_lists: ^0.4.2
|
drag_and_drop_lists: ^0.4.2
|
||||||
|
url_launcher: ^6.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -330,6 +330,32 @@ Avoid:
|
|||||||
- flat monochrome sameness
|
- flat monochrome sameness
|
||||||
- harsh enterprise-blue overuse
|
- 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)
|
## 11) Typography Feel (MUST)
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class _FakeChatApi implements ChatApi {
|
|||||||
@override
|
@override
|
||||||
Future<Stream<String>> streamRunEvents(
|
Future<Stream<String>> streamRunEvents(
|
||||||
String threadId, {
|
String threadId, {
|
||||||
|
required String runId,
|
||||||
String? lastEventId,
|
String? lastEventId,
|
||||||
}) {
|
}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|||||||
@@ -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 = <String, dynamic>{
|
||||||
|
'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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) async {
|
||||||
|
return <String, dynamic>{'threadId': 'thread-1', 'runId': 'run-1'};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Stream<String>> streamRunEvents(
|
||||||
|
String threadId, {
|
||||||
|
required String runId,
|
||||||
|
String? lastEventId,
|
||||||
|
}) async {
|
||||||
|
streamCalls += 1;
|
||||||
|
|
||||||
|
if (streamCalls == 1) {
|
||||||
|
return Stream<String>.fromIterable(<String>[
|
||||||
|
'id: e-1',
|
||||||
|
'event: RUN_STARTED',
|
||||||
|
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stream<String>.fromIterable(<String>[
|
||||||
|
'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<void> cancelRun({required String threadId, required String runId}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> fetchHistory({
|
||||||
|
String? threadId,
|
||||||
|
DateTime? beforeDate,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> transcribeAudio(String filePath) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> uploadAttachment({
|
||||||
|
required String threadId,
|
||||||
|
required String filename,
|
||||||
|
required String mimeType,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlwaysPrematureCloseChatApi extends _RetryableSseChatApi {
|
||||||
|
@override
|
||||||
|
Future<Stream<String>> streamRunEvents(
|
||||||
|
String threadId, {
|
||||||
|
required String runId,
|
||||||
|
String? lastEventId,
|
||||||
|
}) async {
|
||||||
|
streamCalls += 1;
|
||||||
|
return Stream<String>.fromIterable(<String>[
|
||||||
|
'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 = <AgUiEventType>[];
|
||||||
|
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<StateError>()));
|
||||||
|
|
||||||
|
expect(chatApi.streamCalls, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = <ChatListItem>[
|
||||||
|
TextMessageItem(
|
||||||
|
id: 'user-local-1',
|
||||||
|
content: '随便推荐,比如说给我推荐B站的主页。',
|
||||||
|
timestamp: now,
|
||||||
|
sender: MessageSender.user,
|
||||||
|
isLocalEcho: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final remote = <ChatListItem>[
|
||||||
|
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<TextMessageItem>()
|
||||||
|
.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 = <ChatListItem>[
|
||||||
|
TextMessageItem(
|
||||||
|
id: 'user-local-1',
|
||||||
|
content: 'A',
|
||||||
|
timestamp: now,
|
||||||
|
sender: MessageSender.user,
|
||||||
|
isLocalEcho: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final remote = <ChatListItem>[
|
||||||
|
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 = <ChatListItem>[
|
||||||
|
TextMessageItem(
|
||||||
|
id: 'user-local-attachment',
|
||||||
|
content: '看这张图',
|
||||||
|
timestamp: now,
|
||||||
|
sender: MessageSender.user,
|
||||||
|
isLocalEcho: true,
|
||||||
|
attachments: const [
|
||||||
|
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final remote = <ChatListItem>[
|
||||||
|
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 = <ChatListItem>[
|
||||||
|
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 = <ChatListItem>[
|
||||||
|
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 = <ChatListItem>[
|
||||||
|
TextMessageItem(
|
||||||
|
id: 'echo-attachment-1',
|
||||||
|
content: '看这张图',
|
||||||
|
timestamp: now,
|
||||||
|
sender: MessageSender.user,
|
||||||
|
isLocalEcho: true,
|
||||||
|
attachments: const [
|
||||||
|
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final remote = <ChatListItem>[
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:social_app/core/chat/chat_api.dart';
|
import 'package:social_app/core/chat/chat_api.dart';
|
||||||
import 'package:social_app/core/chat/chat_history_repository.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';
|
import 'package:social_app/data/cache/cache_store.dart';
|
||||||
|
|
||||||
class _FakeChatApi implements ChatApi {
|
class _FakeChatApi implements ChatApi {
|
||||||
@@ -42,6 +43,7 @@ class _FakeChatApi implements ChatApi {
|
|||||||
@override
|
@override
|
||||||
Future<Stream<String>> streamRunEvents(
|
Future<Stream<String>> streamRunEvents(
|
||||||
String threadId, {
|
String threadId, {
|
||||||
|
required String runId,
|
||||||
String? lastEventId,
|
String? lastEventId,
|
||||||
}) {
|
}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
@@ -79,6 +81,14 @@ class _FakeChatApi implements ChatApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
CacheScope.configureProvider(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
CacheScope.resetProvider();
|
||||||
|
});
|
||||||
|
|
||||||
test('loads first-page history from cache on second read', () async {
|
test('loads first-page history from cache on second read', () async {
|
||||||
final chatApi = _FakeChatApi();
|
final chatApi = _FakeChatApi();
|
||||||
chatApi.setHistory('first:default', {
|
chatApi.setHistory('first:default', {
|
||||||
@@ -113,4 +123,32 @@ void main() {
|
|||||||
expect(second.messages.length, 1);
|
expect(second.messages.length, 1);
|
||||||
expect(chatApi.historyCalls['first:default'], 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> cancelRun({required String threadId, required String runId}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> fetchHistory({
|
||||||
|
String? threadId,
|
||||||
|
DateTime? beforeDate,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Stream<String>> streamRunEvents(
|
||||||
|
String threadId, {
|
||||||
|
required String runId,
|
||||||
|
String? lastEventId,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> transcribeAudio(String filePath) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> 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<SendMessageResult> Function(
|
||||||
|
String content,
|
||||||
|
List<AttachmentUploadInput>? attachments,
|
||||||
|
)?
|
||||||
|
sendMessageHandler;
|
||||||
|
|
||||||
|
Future<HistorySnapshot> Function({DateTime? beforeDate, bool forceRefresh})?
|
||||||
|
loadHistoryHandler;
|
||||||
|
|
||||||
|
int loadHistoryCalls = 0;
|
||||||
|
|
||||||
|
void emitEventForTest(AgUiEvent event) {
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SendMessageResult> sendMessage(
|
||||||
|
String content, {
|
||||||
|
List<AttachmentUploadInput>? attachments,
|
||||||
|
}) async {
|
||||||
|
final handler = sendMessageHandler;
|
||||||
|
if (handler == null) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
return handler(content, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HistorySnapshot> 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<void> setUserContext(String? userId) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorySnapshot _snapshot(
|
||||||
|
List<HistoryMessage> 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<HistorySnapshot>();
|
||||||
|
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<TextMessageItem>()
|
||||||
|
.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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> _badgeSchema({
|
||||||
|
required String label,
|
||||||
|
required String status,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'root': {
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'children': [
|
||||||
|
{'type': 'badge', 'label': label, 'status': status},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRendererHost(Map<String, dynamic> 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<String, dynamic> _buttonSchema(Map<String, dynamic> action) {
|
||||||
|
return {
|
||||||
|
'root': {
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'type': 'button',
|
||||||
|
'label': '查看详情',
|
||||||
|
'style': 'primary',
|
||||||
|
'action': action,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRouterHost(Map<String, dynamic> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ from v1.auth.schemas import (
|
|||||||
PhoneSessionCreateRequest,
|
PhoneSessionCreateRequest,
|
||||||
SessionRefreshRequest,
|
SessionRefreshRequest,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
|
UserByIdResponse,
|
||||||
UserByPhoneResponse,
|
UserByPhoneResponse,
|
||||||
)
|
)
|
||||||
from v1.auth.service import AuthServiceGateway
|
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]:
|
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
||||||
normalized_query = _normalize_phone_search_query(query)
|
normalized_query = _normalize_phone_search_query(query)
|
||||||
if not normalized_query:
|
if not normalized_query:
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class UserByPhoneResponse(BaseModel):
|
|||||||
phone_confirmed_at: str | None = None
|
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):
|
class OtpSendResponse(BaseModel):
|
||||||
phone: str = Field(pattern=SUPABASE_PHONE_PATTERN)
|
phone: str = Field(pattern=SUPABASE_PHONE_PATTERN)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user