refactor: 移除前端 Mock API,新增共享组件,优化认证流程

- 删除 mock_api_client、mock_calendar_service、mock_history_service
- 新增 fixed_length_code_input、link_button、message_composer 共享组件
- 优化登录/注册/密码重置页面使用新组件
- 简化 injection.dart 移除 mock 分支
- 更新 env.dart 配置(BACKEND_URL 替换 API_URL)
- 后端 agentscope 工具和测试更新
- 重构 AGENTS.md 文档结构
- 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
qzl
2026-03-12 16:41:45 +08:00
parent d7fbb74bf8
commit 01c36eb32e
70 changed files with 5138 additions and 5829 deletions
+16 -11
View File
@@ -2,6 +2,13 @@
This document defines **hard constraints** for Flutter mobile development. Treat all items as **non-negotiable** unless explicitly overridden.
## 0) Scope and Precedence (MUST)
- This file applies to all changes under `apps/**`.
- It extends root routing rules in `AGENTS.md` and workspace global runtime rules.
- If rules conflict, apply the stricter requirement.
- Keep Flutter-specific constraints in this file; avoid duplicating them in root `AGENTS.md`.
## 1) Design Tokens (MUST)
- **MUST** use design tokens from `apps/lib/core/theme/design_tokens.dart`:
@@ -32,13 +39,17 @@ This document defines **hard constraints** for Flutter mobile development. Treat
- If persistent header/footer regions exist, **MUST** center primary content within the remaining usable region.
- **MUST** prioritize *visual centering* over purely geometric centering when they differ.
## 5) Quality Gate for Important Screens (MUST)
## 5) Testing Strategy (MUST)
For important screens:
Follow lightweight testing strategy - prioritize value over coverage:
- **MUST** add widget tests to reduce layout regression risk:
- Verify primary content stays centered relative to the usable viewport.
- Include at least one constrained scenario (e.g., small height **or** large text scale).
**Write tests for:**
- Model / DTO parsing (json → model)
- Service layer logic (business rules, API call handling)
- Complex custom widgets with rich interactions
**Skip for:**
- Simple UI pages, regular buttons, basic layouts
## 6) UI Feedback System (MUST)
@@ -62,9 +73,3 @@ Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` s
- **MUST NOT** return non-streaming responses for agent chat.
- **MUST NOT** omit required lifecycle events.
- **MUST NOT** use non-AG-UI event formats (except where the spec explicitly allows).
## 8) Debugging Behavior (MUST)
- **MUST NOT** automatically start Flutter app debugging or running.
- After code changes, **MUST** instruct the user to run manually (user-controlled):
- `flutter run --dart-define=MOCK_API=true -d emulator-5554`
-181
View File
@@ -1,181 +0,0 @@
import 'package:dio/dio.dart';
import 'i_api_client.dart';
class MockRequest {
final String path;
final String method;
final dynamic data;
final Options? options;
final Map<String, String>? headers;
MockRequest({
required this.path,
required this.method,
this.data,
this.options,
this.headers,
});
}
typedef MockHandler = dynamic Function(MockRequest request);
class _PatternRoute {
final RegExp pattern;
final String method;
final MockHandler handler;
_PatternRoute({
required this.pattern,
required this.method,
required this.handler,
});
}
class MockApiClient implements IApiClient {
final Map<String, MockHandler> _handlers = {};
final List<_PatternRoute> _patternHandlers = [];
void registerHandler(String path, String method, MockHandler handler) {
final key = '$path:$method';
_handlers[key] = handler;
}
void registerPatternHandler(
RegExp pattern,
String method,
MockHandler handler,
) {
_patternHandlers.add(
_PatternRoute(
pattern: pattern,
method: method.toUpperCase(),
handler: handler,
),
);
}
void clearMocks() {
_handlers.clear();
_patternHandlers.clear();
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
return _handleRequest('GET', path, options: options);
}
@override
Future<Response<T>> post<T>(
String path, {
dynamic data,
Options? options,
}) async {
return _handleRequest('POST', path, data: data, options: options);
}
@override
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Options? options,
}) async {
return _handleRequest('PATCH', path, data: data, options: options);
}
@override
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Options? options,
}) async {
return _handleRequest('DELETE', path, data: data, options: options);
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) async {
final key = '$path:SSE';
final direct = _handlers[key];
if (direct != null) {
final response = direct(
MockRequest(path: path, method: 'SSE', headers: headers),
);
if (response is Stream<String>) {
return response;
}
if (response is Iterable<String>) {
return Stream<String>.fromIterable(response);
}
return const Stream<String>.empty();
}
for (final route in _patternHandlers) {
if (route.method != 'SSE') {
continue;
}
if (!route.pattern.hasMatch(path)) {
continue;
}
final response = route.handler(
MockRequest(path: path, method: 'SSE', headers: headers),
);
if (response is Stream<String>) {
return response;
}
if (response is Iterable<String>) {
return Stream<String>.fromIterable(response);
}
return const Stream<String>.empty();
}
return const Stream<String>.empty();
}
Future<Response<T>> _handleRequest<T>(
String method,
String path, {
dynamic data,
Options? options,
}) async {
await Future.delayed(const Duration(milliseconds: 200));
final handler = _resolveHandler(path: path, method: method);
if (handler != null) {
final response = handler(
MockRequest(path: path, method: method, data: data, options: options),
);
if (response is Response) {
return response as Response<T>;
}
return Response<T>(
data: response as T?,
statusCode: 200,
requestOptions: RequestOptions(path: path),
);
}
return Response<T>(
data: null,
statusCode: 404,
requestOptions: RequestOptions(path: path),
);
}
MockHandler? _resolveHandler({required String path, required String method}) {
final key = '$path:$method';
final direct = _handlers[key];
if (direct != null) {
return direct;
}
for (final route in _patternHandlers) {
if (route.method != method.toUpperCase()) {
continue;
}
if (route.pattern.hasMatch(path)) {
return route.handler;
}
}
return null;
}
}
+4 -2
View File
@@ -2,8 +2,10 @@ import 'dart:io';
class Env {
static String get apiUrl {
const url = String.fromEnvironment('API_URL');
if (url.isNotEmpty) return url;
final backendUrl = const String.fromEnvironment('BACKEND_URL');
if (backendUrl.isNotEmpty && backendUrl != 'false') {
return backendUrl;
}
if (Platform.isAndroid) {
return 'http://192.168.1.25:5775';
}
+20 -32
View File
@@ -3,7 +3,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import '../api/api_client.dart';
import '../api/i_api_client.dart';
import '../api/mock_api_client.dart';
import '../storage/token_storage.dart';
import '../config/env.dart';
import '../notifications/local_notification_service.dart';
@@ -12,7 +11,7 @@ import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/data/auth_repository_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/mock_calendar_service.dart';
import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart';
@@ -29,18 +28,13 @@ Future<void> configureDependencies() async {
final IApiClient apiClient;
final SecureTokenStorage tokenStorage;
if (Env.isMockApi) {
apiClient = MockApiClient();
tokenStorage = SecureTokenStorage(const FlutterSecureStorage());
} else {
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
tokenStorage = SecureTokenStorage(const FlutterSecureStorage());
apiClient = ApiClient(
baseUrl: Env.apiUrl,
tokenStorage: tokenStorage,
dio: dio,
);
}
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
tokenStorage = SecureTokenStorage(const FlutterSecureStorage());
apiClient = ApiClient(
baseUrl: Env.apiUrl,
tokenStorage: tokenStorage,
dio: dio,
);
sl.registerSingleton<IApiClient>(apiClient);
@@ -53,9 +47,7 @@ Future<void> configureDependencies() async {
final calendarApi = CalendarApi(apiClient);
sl.registerSingleton<CalendarApi>(calendarApi);
final calendarService = CalendarService(
apiClient: Env.isMockApi ? null : apiClient,
);
final calendarService = CalendarService(apiClient: apiClient);
sl.registerSingleton<CalendarService>(calendarService);
sl.registerSingleton<LocalNotificationService>(LocalNotificationService());
@@ -72,24 +64,20 @@ Future<void> configureDependencies() async {
final authRepository = AuthRepositoryImpl(
api: authApi,
tokenStorage: tokenStorage,
onLogout: Env.isMockApi
? null
: () async {
(apiClient as ApiClient).resetInterceptor();
},
onLogout: () async {
(apiClient as ApiClient).resetInterceptor();
},
);
sl.registerSingleton<AuthRepository>(authRepository);
if (!Env.isMockApi) {
(apiClient as ApiClient).setRefreshCallback((token) async {
try {
await authRepository.refreshSession(token);
return true;
} catch (_) {
return false;
}
});
}
(apiClient as ApiClient).setRefreshCallback((token) async {
try {
await authRepository.refreshSession(token);
return true;
} catch (_) {
return false;
}
});
sl.registerSingleton<AuthBloc>(AuthBloc(authRepository));
sl.registerSingleton<CalendarStateManager>(CalendarStateManager());
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../widgets/auth_page_scaffold.dart';
import '../../presentation/cubits/login_cubit.dart';
@@ -225,30 +226,16 @@ class _LoginViewState extends State<LoginView> {
}
Widget _buildForgotPassword() {
return GestureDetector(
return LinkButton(
text: '忘记密码?',
onTap: () => context.push('/reset-password'),
child: const Text(
'忘记密码?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
);
}
Widget _buildFooter() {
return GestureDetector(
return LinkButton(
text: '还没有账号?去注册',
onTap: () => context.push('/register'),
child: const Text(
'还没有账号?去注册',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
);
}
}
@@ -8,6 +8,8 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/register_cubit.dart';
@@ -34,6 +36,41 @@ class RegisterView extends StatefulWidget {
}
class _RegisterViewState extends State<RegisterView> {
static const _inviteCodeLength = 4;
static const _inviteAllowedChars = <String>{
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'J',
'K',
'M',
'N',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
};
final _nicknameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@@ -51,10 +88,15 @@ class _RegisterViewState extends State<RegisterView> {
Future<void> _handleNext() async {
final cubit = context.read<RegisterCubit>();
final inviteCode = _inviteCodeController.text.trim().toUpperCase();
final normalizedInviteCode = inviteCode.length == _inviteCodeLength
? inviteCode
: '';
cubit.usernameChanged(_nicknameController.text);
cubit.emailChanged(_emailController.text);
cubit.passwordChanged(_passwordController.text);
cubit.inviteCodeChanged(_inviteCodeController.text);
cubit.inviteCodeChanged(normalizedInviteCode);
if (!cubit.state.isStep1Valid || cubit.state.isSending) {
String? errorMsg;
@@ -71,6 +113,14 @@ class _RegisterViewState extends State<RegisterView> {
return;
}
if (inviteCode.isNotEmpty && normalizedInviteCode.isEmpty && mounted) {
Toast.show(
context,
'邀请码需为 4 位,且仅支持 A-H/J-N/P-Z 与 2-9;已按无邀请码继续注册',
type: ToastType.warning,
);
}
if (mounted) {
context.push('/register/verification', extra: cubit);
}
@@ -147,7 +197,7 @@ class _RegisterViewState extends State<RegisterView> {
const SizedBox(height: 12),
_buildPasswordInput(),
const SizedBox(height: 12),
_buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController),
_buildInviteCodeInput(),
const SizedBox(height: 12),
_buildStepIndicator(),
if (state.errorMessage != null)
@@ -174,6 +224,42 @@ class _RegisterViewState extends State<RegisterView> {
);
}
Widget _buildInviteCodeInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'邀请码(选填)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
FixedLengthCodeInput(
controller: _inviteCodeController,
length: _inviteCodeLength,
semanticLabel: '邀请码输入框',
uppercase: true,
allowedCharacters: _inviteAllowedChars,
onChanged: (value) {
context.read<RegisterCubit>().inviteCodeChanged(value);
},
),
const SizedBox(height: 6),
const Text(
'4 位邀请码,支持 A-H/J-N/P-Z 与 2-9',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.slate500,
),
),
],
);
}
Widget _buildInput(
String label,
String hint,
@@ -262,16 +348,6 @@ class _RegisterViewState extends State<RegisterView> {
}
Widget _buildFooter() {
return GestureDetector(
onTap: () => context.pop(),
child: const Text(
'已有账号?去登录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
);
return LinkButton(text: '已有账号?去登录', onTap: () => context.pop());
}
}
@@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart';
import 'package:formz/formz.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/register_cubit.dart';
@@ -49,22 +51,10 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
Timer? _countdownTimer;
int _countdown = 0;
bool _firstSendCompleted = false;
bool _hintShown = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_hintShown) {
_hintShown = true;
Toast.show(
context,
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
type: ToastType.info,
duration: const Duration(seconds: 5),
);
}
});
}
@override
@@ -200,6 +190,12 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
!_firstSendCompleted) {
_firstSendCompleted = true;
_startCountdown();
Toast.show(
context,
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
type: ToastType.info,
duration: const Duration(seconds: 5),
);
}
},
builder: (context, state) {
@@ -246,16 +242,28 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
Expanded(
child: SizedBox(
height: 40,
child: TextField(
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '邮箱验证码输入框',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
hintText: '输入验证码',
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<RegisterCubit>().verificationCodeChanged(
value,
);
},
),
),
),
@@ -268,36 +276,38 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
}
Widget _buildResendButton(bool canResend, RegisterState state) {
final bgColor = canResend ? AppColors.primary : const Color(0xFFF1F5F9);
final textColor = canResend ? AppColors.white : AppColors.slate400;
final canPress =
canResend && state.status != FormzSubmissionStatus.inProgress;
String text;
if (state.status == FormzSubmissionStatus.inProgress) {
text = '发送中';
} else if (canResend) {
text = '重发';
} else {
text = '${_countdown}s';
}
return GestureDetector(
onTap: canResend ? _handleResendCode : null,
child: Container(
width: 70,
height: 40,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textColor,
return SizedBox(
width: 70,
height: 44,
child: TextButton(
onPressed: canPress ? _handleResendCode : null,
style: TextButton.styleFrom(
backgroundColor: canResend ? AppColors.primary : AppColors.slate100,
foregroundColor: canResend ? AppColors.white : AppColors.slate400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
padding: EdgeInsets.zero,
),
child: state.status == FormzSubmissionStatus.inProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.slate400),
),
)
: Text(
canResend ? '重发' : '${_countdown}s',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
@@ -329,16 +339,6 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
}
Widget _buildFooter() {
return GestureDetector(
onTap: () => context.go('/'),
child: const Text(
'已有账号?去登录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
);
return LinkButton(text: '已有账号?去登录', onTap: () => context.go('/'));
}
}
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/reset_password_cubit.dart';
@@ -114,7 +116,7 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
children: [
_buildEmailInput(state.email.displayError != null),
const SizedBox(height: 12),
_buildCodeInput(state.code.displayError != null, state),
_buildCodeInput(state),
const SizedBox(height: 12),
_buildPasswordInput(state.newPassword.displayError != null),
const SizedBox(height: 12),
@@ -160,7 +162,7 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
);
}
Widget _buildCodeInput(bool hasError, ResetPasswordState state) {
Widget _buildCodeInput(ResetPasswordState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -176,16 +178,26 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
Row(
children: [
Expanded(
child: TextField(
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '重置密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
decoration: InputDecoration(
hintText: '请输入 6 位验证码',
errorText: hasError ? ' ' : null,
),
),
),
const SizedBox(width: 12),
@@ -325,17 +337,6 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
}
Widget _buildBackToLogin() {
return GestureDetector(
onTap: () => context.go('/'),
child: const Text(
'返回登录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
textAlign: TextAlign.center,
),
);
return LinkButton(text: '返回登录', onTap: () => context.go('/'));
}
}
@@ -0,0 +1,50 @@
import 'package:social_app/core/api/i_api_client.dart';
import '../calendar_api.dart';
import '../models/schedule_item_model.dart';
class CalendarService {
final IApiClient _apiClient;
CalendarApi? _calendarApi;
CalendarService({required IApiClient apiClient}) : _apiClient = apiClient;
CalendarApi get _api {
final api = _calendarApi;
if (api != null) {
return api;
}
final created = CalendarApi(_apiClient);
_calendarApi = created;
return created;
}
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
final start = DateTime(date.year, date.month, date.day);
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
return getEventsForRange(start, end);
}
Future<List<ScheduleItemModel>> getEventsForRange(
DateTime start,
DateTime end,
) async {
return _api.listByRange(startAt: start, endAt: end);
}
Future<ScheduleItemModel?> getEventById(String id) async {
return _api.getById(id);
}
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
return _api.create(event);
}
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
return _api.update(event);
}
Future<void> deleteEvent(String id) async {
await _api.delete(id);
}
}
@@ -1,130 +0,0 @@
import 'package:social_app/core/api/i_api_client.dart';
import '../calendar_api.dart';
import '../models/schedule_item_model.dart';
class MockCalendarService {
static final MockCalendarService _instance = MockCalendarService._internal();
factory MockCalendarService() => _instance;
final List<ScheduleItemModel> _events = [];
MockCalendarService._internal();
List<ScheduleItemModel> get events => List.unmodifiable(_events);
List<ScheduleItemModel> getEventsForDay(DateTime date) {
final dateOnly = DateTime(date.year, date.month, date.day);
return _events.where((event) {
final eventDate = DateTime(
event.startAt.year,
event.startAt.month,
event.startAt.day,
);
return eventDate == dateOnly && event.status == ScheduleStatus.active;
}).toList()..sort((a, b) => a.startAt.compareTo(b.startAt));
}
List<ScheduleItemModel> getEventsForRange(DateTime start, DateTime end) {
return _events.where((event) {
return event.startAt.isAfter(start.subtract(const Duration(days: 1))) &&
event.startAt.isBefore(end.add(const Duration(days: 1))) &&
event.status == ScheduleStatus.active;
}).toList()..sort((a, b) => a.startAt.compareTo(b.startAt));
}
ScheduleItemModel? getEventById(String id) {
try {
return _events.firstWhere((e) => e.id == id);
} catch (_) {
return null;
}
}
void addEvent(ScheduleItemModel event) {
_events.add(event);
}
void updateEvent(ScheduleItemModel event) {
final index = _events.indexWhere((e) => e.id == event.id);
if (index >= 0) {
_events[index] = event;
}
}
void deleteEvent(String id) {
_events.removeWhere((e) => e.id == id);
}
}
class CalendarService {
final IApiClient? _apiClient;
final MockCalendarService _mock = MockCalendarService();
CalendarApi? _calendarApi;
CalendarService({IApiClient? apiClient}) : _apiClient = apiClient;
CalendarApi get _api {
final api = _calendarApi;
if (api != null) {
return api;
}
final client = _apiClient;
if (client == null) {
throw StateError('Real API client not configured');
}
final created = CalendarApi(client);
_calendarApi = created;
return created;
}
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
if (_apiClient == null) {
return _mock.getEventsForDay(date);
}
final start = DateTime(date.year, date.month, date.day);
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
return getEventsForRange(start, end);
}
Future<List<ScheduleItemModel>> getEventsForRange(
DateTime start,
DateTime end,
) async {
if (_apiClient != null) {
return _api.listByRange(startAt: start, endAt: end);
}
return _mock.getEventsForRange(start, end);
}
Future<ScheduleItemModel?> getEventById(String id) async {
if (_apiClient != null) {
return _api.getById(id);
}
return _mock.getEventById(id);
}
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
if (_apiClient != null) {
return _api.create(event);
}
_mock.addEvent(event);
return event;
}
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
if (_apiClient != null) {
return _api.update(event);
}
_mock.updateEvent(event);
return event;
}
Future<void> deleteEvent(String id) async {
if (_apiClient != null) {
await _api.delete(id);
return;
}
_mock.deleteEvent(id);
}
}
@@ -7,7 +7,7 @@ import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/services/mock_calendar_service.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
class CalendarDayWeekScreen extends StatefulWidget {
@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/services/mock_calendar_service.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart';
@@ -9,7 +9,7 @@ import '../calendar_time_utils.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/mock_calendar_service.dart';
import '../../data/services/calendar_service.dart';
class CalendarMonthScreen extends StatefulWidget {
final bool resetToToday;
@@ -5,7 +5,7 @@ import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/mock_calendar_service.dart';
import '../../data/services/calendar_service.dart';
class CreateEventSheet extends StatefulWidget {
final DateTime? initialDate;
@@ -6,16 +6,13 @@ import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import '../ai/ai_decision_engine.dart';
import '../models/ag_ui_event.dart';
import '../tools/tool_registry.dart';
import 'mock_history_service.dart';
typedef EventCallback = void Function(AgUiEvent event);
/// ID 前缀常量
const _runIdPrefix = 'run_';
const _messageIdPrefix = 'msg_';
const _toolCallIdPrefix = 'tc_';
@@ -24,24 +21,16 @@ class AgUiService {
final IApiClient _apiClient;
EventCallback onEvent;
final AiDecisionEngine _decisionEngine;
final MockHistoryService _historyService;
final Map<String, List<String>> _mockSseLinesByThread = {};
final Map<String, String> _lastEventIdByThread = {};
int _activeStreamToken = 0;
String? _threadId;
bool _hasMoreHistory = false;
bool _mockApiConfigured = false;
AgUiService({EventCallback? onEvent, IApiClient? apiClient})
AgUiService({EventCallback? onEvent, required IApiClient apiClient})
: onEvent = onEvent ?? ((_) {}),
_apiClient = apiClient ?? MockApiClient(),
_decisionEngine = AiDecisionEngine(),
_historyService = MockHistoryService() {
if (_apiClient is MockApiClient) {
_configureMockAgentApi(_apiClient);
}
}
_apiClient = apiClient,
_decisionEngine = AiDecisionEngine();
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final streamToken = ++_activeStreamToken;
@@ -409,368 +398,4 @@ class AgUiService {
const variant = ['8', '9', 'a', 'b'];
return '${hex(8)}-${hex(4)}-4${hex(3)}-${variant[random.nextInt(4)]}${hex(3)}-${hex(12)}';
}
void _configureMockAgentApi(MockApiClient client) {
if (_mockApiConfigured) {
return;
}
_mockApiConfigured = true;
client.registerHandler('/api/v1/agent/runs', 'POST', _handleMockRun);
client.registerPatternHandler(
RegExp(r'^/api/v1/agent/runs/[^/]+/resume$'),
'POST',
_handleMockResume,
);
client.registerPatternHandler(
RegExp(r'^/api/v1/agent/history(?:\?.*)?$'),
'GET',
_handleMockHistory,
);
client.registerPatternHandler(
RegExp(r'^/api/v1/agent/runs/[^/]+/events$'),
'SSE',
_handleMockSse,
);
client.registerHandler(
'/api/v1/agent/attachments',
'POST',
_handleMockUploadAttachment,
);
client.registerHandler(
'/api/v1/agent/transcribe',
'POST',
_handleMockTranscribe,
);
}
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
return {'transcript': '这是模拟语音转写'};
}
Map<String, dynamic> _handleMockUploadAttachment(MockRequest request) {
final payload = request.data;
final threadId = payload is Map<String, dynamic>
? (payload['threadId'] as String?)
: null;
final resolvedThreadId = (threadId != null && threadId.isNotEmpty)
? threadId
: (_threadId ?? _newUuid());
final path =
'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png';
return {
'attachment': {
'bucket': 'mock-bucket',
'path': path,
'mimeType': 'image/png',
'url': 'https://mock.local/$path',
},
};
}
Map<String, dynamic> _handleMockRun(MockRequest request) {
final payload = request.data;
final runInput = payload is Map<String, dynamic>
? payload
: <String, dynamic>{};
final threadId = (runInput['threadId'] as String?) ?? _newUuid();
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
_threadId = threadId;
final content = _extractLatestUserContent(runInput);
final events = _buildMockRunEvents(
threadId: threadId,
runId: runId,
userInput: content,
);
_mockSseLinesByThread[threadId] = _toSseLines(events);
return {
'taskId': _nextId('task_'),
'threadId': threadId,
'runId': runId,
'created': false,
};
}
Map<String, dynamic> _handleMockResume(MockRequest request) {
final match = RegExp(
r'^/api/v1/agent/runs/([^/]+)/resume$',
).firstMatch(request.path);
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
final payload = request.data;
final runInput = payload is Map<String, dynamic>
? payload
: <String, dynamic>{};
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
_threadId = threadId;
final toolMessage = _extractLatestToolMessage(runInput);
final events = <Map<String, dynamic>>[
{
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
{
'type': AgUiEventTypeWire.toolCallResult,
'messageId': _nextId(_messageIdPrefix),
'toolCallId': toolMessage.$1,
'content': toolMessage.$2,
},
{
'type': AgUiEventTypeWire.textMessageStart,
'messageId': _nextId(_messageIdPrefix),
'role': 'assistant',
},
{
'type': AgUiEventTypeWire.textMessageContent,
'messageId': _nextId(_messageIdPrefix),
'delta': '已收到你的审批,继续执行完成。',
},
{
'type': AgUiEventTypeWire.textMessageEnd,
'messageId': _nextId(_messageIdPrefix),
},
{
'type': AgUiEventTypeWire.runFinished,
'threadId': threadId,
'runId': runId,
},
];
_mockSseLinesByThread[threadId] = _toSseLines(events);
return {
'taskId': _nextId('task_'),
'threadId': threadId,
'runId': runId,
'created': false,
};
}
Map<String, dynamic> _handleMockHistory(MockRequest request) {
final uri = Uri.parse(request.path);
final query = uri.queryParameters;
final providedThreadId = query['threadId'];
final threadId = providedThreadId ?? _threadId ?? _newUuid();
_threadId = threadId;
final beforeRaw = query['before'];
DateTime? beforeDate;
if (beforeRaw != null && beforeRaw.isNotEmpty) {
beforeDate = DateTime.tryParse(beforeRaw);
}
DateTime? targetDate;
if (beforeDate == null) {
targetDate = _historyService.getLatestHistoryDate();
} else {
targetDate = _historyService.getPreviousDay(beforeDate);
}
final messages = targetDate == null
? <SnapshotMessage>[]
: _historyService.getHistoryForDay(targetDate);
final hasMore =
targetDate != null && _historyService.hasEarlierHistory(targetDate);
_hasMoreHistory = hasMore;
return {
'type': AgUiEventTypeWire.stateSnapshot,
'threadId': threadId,
'snapshot': {
'scope': 'history_day',
'threadId': threadId,
'day': targetDate == null
? null
: DateTime(
targetDate.year,
targetDate.month,
targetDate.day,
).toIso8601String().substring(0, 10),
'hasMore': hasMore,
'messages': messages.map((item) => item.toJson()).toList(),
},
};
}
Stream<String> _handleMockSse(MockRequest request) {
final match = RegExp(
r'^/api/v1/agent/runs/([^/]+)/events$',
).firstMatch(request.path);
final threadId = match?.group(1);
if (threadId == null) {
return const Stream<String>.empty();
}
final lines = _mockSseLinesByThread[threadId];
if (lines == null) {
return const Stream<String>.empty();
}
return Stream<String>.fromIterable(lines);
}
List<Map<String, dynamic>> _buildMockRunEvents({
required String threadId,
required String runId,
required String userInput,
}) {
final events = <Map<String, dynamic>>[
{
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
];
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
Map<String, dynamic>? args;
String? toolName;
if (forceTrigger != null) {
toolName = forceTrigger.toolName;
args = forceTrigger.args;
} else if (_looksLikeNavigationIntent(userInput)) {
toolName = 'front.navigate_to_route';
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
}
if (toolName != null && args != null) {
if (toolName == 'front.navigate_to_route') {
args = {...args, '__nonce': _nextId('nonce_')};
}
final toolCallId = _nextId(_toolCallIdPrefix);
events.add({
'type': AgUiEventTypeWire.toolCallStart,
'toolCallId': toolCallId,
'toolCallName': toolName,
});
events.add({
'type': AgUiEventTypeWire.toolCallArgs,
'toolCallId': toolCallId,
'delta': jsonEncode(args),
});
events.add({
'type': AgUiEventTypeWire.toolCallEnd,
'toolCallId': toolCallId,
});
if (toolName == 'front.navigate_to_route') {
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
} else {
events.add({
'type': AgUiEventTypeWire.toolCallError,
'toolCallId': toolCallId,
'error': 'Unsupported frontend tool in mock mode',
'code': 'UNSUPPORTED_TOOL',
});
}
}
final replies = _generateReplies(userInput);
for (final reply in replies) {
final messageId = _nextId(_messageIdPrefix);
events.add({
'type': AgUiEventTypeWire.textMessageStart,
'messageId': messageId,
'role': 'assistant',
});
events.add({
'type': AgUiEventTypeWire.textMessageContent,
'messageId': messageId,
'delta': reply,
});
events.add({
'type': AgUiEventTypeWire.textMessageEnd,
'messageId': messageId,
});
}
events.add({
'type': AgUiEventTypeWire.runFinished,
'threadId': threadId,
'runId': runId,
});
return events;
}
List<String> _toSseLines(List<Map<String, dynamic>> events) {
final lines = <String>[];
for (var i = 0; i < events.length; i++) {
final event = events[i];
final eventType = event['type'] as String? ?? 'MESSAGE';
final eventId = '${i + 1}-0';
lines.add('id: $eventId');
lines.add('event: $eventType');
lines.add('data: ${jsonEncode(event)}');
lines.add('');
}
return lines;
}
String _extractLatestUserContent(Map<String, dynamic> runInput) {
final messages = runInput['messages'];
if (messages is! List<dynamic>) {
return '';
}
for (var i = messages.length - 1; i >= 0; i--) {
final raw = messages[i];
if (raw is! Map<String, dynamic>) {
continue;
}
if (raw['role'] != 'user') {
continue;
}
final content = raw['content'];
if (content is String) {
return content;
}
}
return '';
}
(String, String) _extractLatestToolMessage(Map<String, dynamic> runInput) {
final messages = runInput['messages'];
if (messages is! List<dynamic>) {
return (_nextId(_toolCallIdPrefix), '{}');
}
for (var i = messages.length - 1; i >= 0; i--) {
final raw = messages[i];
if (raw is! Map<String, dynamic>) {
continue;
}
if (raw['role'] != 'tool') {
continue;
}
final toolCallId =
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
final content = raw['content'] as String? ?? '{}';
return (toolCallId, content);
}
return (_nextId(_toolCallIdPrefix), '{}');
}
List<String> _generateReplies(String content) {
final intent = _decisionEngine.matchIntent(content);
switch (intent) {
case Intent.createEvent:
return ['好的,我已经为您创建了日程安排。'];
case Intent.searchEvent:
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
case Intent.unknown:
return ['我理解了您的问题,让我来帮您处理。'];
}
}
bool _looksLikeNavigationIntent(String input) {
return input.contains('打开') ||
input.contains('跳转') ||
input.toLowerCase().contains('navigate') ||
input.toLowerCase().contains('open');
}
String _inferNavigationRoute(String input) {
if (input.contains('设置')) {
return '/settings';
}
if (input.contains('待办')) {
return '/todo';
}
return '/calendar/dayweek';
}
}
@@ -1,157 +0,0 @@
import '../models/ag_ui_event.dart';
import '../models/tool_result.dart';
class MockHistoryService {
static final MockHistoryService _instance = MockHistoryService._internal();
factory MockHistoryService() => _instance;
MockHistoryService._internal();
/// Normalize DateTime to date-only (midnight)
DateTime _toDateOnly(DateTime date) =>
DateTime(date.year, date.month, date.day);
List<SnapshotMessage> getHistoryForDay(DateTime date) {
final dayStart = _toDateOnly(date);
final allHistory = _generateAllHistory();
return allHistory.where((msg) {
if (msg.timestamp == null) return false;
final msgDate = _toDateOnly(msg.timestamp!);
return msgDate == dayStart;
}).toList();
}
DateTime? getLatestHistoryDate() {
final allHistory = _generateAllHistory();
if (allHistory.isEmpty) return null;
return allHistory
.where((msg) => msg.timestamp != null)
.map((msg) => _toDateOnly(msg.timestamp!))
.reduce((a, b) => a.isAfter(b) ? a : b);
}
DateTime? getPreviousDay(DateTime currentDate) {
final allDates = _getAllHistoryDates();
final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a));
final currentDateOnly = _toDateOnly(currentDate);
for (final date in sortedDates) {
if (date.isBefore(currentDateOnly)) {
return date;
}
}
return null;
}
bool hasEarlierHistory(DateTime fromDate) {
final allDates = _getAllHistoryDates();
final fromDateOnly = _toDateOnly(fromDate);
return allDates.any((date) => date.isBefore(fromDateOnly));
}
Set<DateTime> _getAllHistoryDates() {
final now = DateTime.now();
final today = _toDateOnly(now);
final yesterday = today.subtract(const Duration(days: 1));
return {today, yesterday};
}
List<SnapshotMessage> _generateAllHistory() {
final now = DateTime.now();
final today = _toDateOnly(now);
final yesterday = today.subtract(const Duration(days: 1));
return [
SnapshotMessage(
id: 'hist-m1',
role: 'user',
content: '明天提醒我开会',
timestamp: today.add(const Duration(hours: 10)),
),
SnapshotMessage(
id: 'hist-t1',
role: 'tool',
toolCallId: 'hist-tc1',
timestamp: today.add(const Duration(hours: 10)),
ui: UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
id: 'hist-s1',
title: '产品评审会议',
description: '讨论Q2产品路线图',
startAt: today
.add(const Duration(days: 1, hours: 10))
.toIso8601String(),
endAt: today
.add(const Duration(days: 1, hours: 11))
.toIso8601String(),
timezone: 'Asia/Shanghai',
location: '会议室A / 在线',
color: '#4F46E5',
sourceType: 'ai_generated',
).toJson(),
actions: [
CardAction(
type: 'link',
label: '查看详情',
target: '/calendar/hist-s1',
),
],
),
),
SnapshotMessage(
id: 'hist-m2',
role: 'assistant',
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
timestamp: today.add(const Duration(hours: 10)),
),
SnapshotMessage(
id: 'hist-m3',
role: 'user',
content: '下周一之前提交项目报告',
timestamp: yesterday.add(const Duration(hours: 14)),
),
SnapshotMessage(
id: 'hist-t2',
role: 'tool',
toolCallId: 'hist-tc2',
timestamp: yesterday.add(const Duration(hours: 14)),
ui: UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
id: 'hist-s2',
title: '提交项目报告',
description: '完成并提交Q2项目报告',
startAt: yesterday.add(const Duration(days: 5)).toIso8601String(),
endAt: null,
timezone: 'Asia/Shanghai',
location: null,
color: '#F59E0B',
sourceType: 'ai_generated',
).toJson(),
actions: [
CardAction(
type: 'link',
label: '查看详情',
target: '/calendar/hist-s2',
),
],
),
),
SnapshotMessage(
id: 'hist-m4',
role: 'assistant',
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
timestamp: yesterday.add(const Duration(hours: 14)),
),
SnapshotMessage(
id: 'hist-m5',
role: 'assistant',
content: '你好,我有什么可以帮你的?',
timestamp: yesterday.add(const Duration(hours: 9)),
),
];
}
}
@@ -4,7 +4,6 @@ import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/core/di/injection.dart';
import '../../data/models/ag_ui_event.dart';
@@ -91,16 +90,8 @@ class ChatBloc extends Cubit<ChatState> {
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
service ??
AgUiService(
apiClient:
apiClient ??
(sl.isRegistered<IApiClient>()
? sl<IApiClient>()
: MockApiClient()),
),
ChatBloc({AgUiService? service, required IApiClient apiClient})
: _service = service ?? AgUiService(apiClient: apiClient),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
+219 -215
View File
@@ -15,6 +15,7 @@ import '../../../chat/data/tools/route_navigation_tool.dart';
import '../../../messages/data/inbox_api.dart';
import '../../data/voice_recorder.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
import '../../../../shared/widgets/message_composer.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import 'home_sheet.dart';
@@ -28,19 +29,15 @@ const _iconSize = 24.0;
const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0;
const _cornerRadius = 12.0;
const _inputMinHeight = 48.0;
const _inputRadius = 24.0;
const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg;
const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl);
const _scrollDurationMs = 300;
const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0;
const _transcribingSpinnerSize = 18.0;
const _transcribingStrokeWidth = 2.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _inputActionButtonKey = ValueKey('home_input_action_button');
const _inputActionIconKey = ValueKey('home_input_action_icon');
const _holdToSpeakKey = ValueKey('home_hold_to_speak_button');
/// 颜色常量
const _chatBgColor = AppColors.slate50;
@@ -79,6 +76,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isRecording = false;
bool _isHoldToSpeakMode = false;
bool _isTranscribing = false;
bool _isCancelGestureActive = false;
int _unreadCount = 0;
final List<XFile> _selectedImages = [];
@@ -158,12 +156,17 @@ class _HomeScreenState extends State<HomeScreen>
return Scaffold(
backgroundColor: _chatBgColor,
body: SafeArea(
child: Column(
child: Stack(
children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
_buildImagePreview(),
_buildInputContainer(context, state),
Column(
children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
_buildImagePreview(),
_buildInputContainer(context, state),
],
),
if (_isRecording) _buildRecordingGestureOverlay(),
],
),
),
@@ -712,191 +715,147 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildInputContainer(BuildContext context, ChatState state) {
return Container(
padding: const EdgeInsets.all(_inputPadding),
color: _chatBgColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: _isRecording
? _stopRecording
: () => _showBottomSheet(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.white,
shape: BoxShape.circle,
border: Border.all(color: AppColors.slate300),
),
child: Icon(
_isRecording ? LucideIcons.square : LucideIcons.plus,
size: 20,
color: _isRecording ? AppColors.red600 : AppColors.slate500,
),
),
),
const SizedBox(width: 8),
Expanded(
child: _isHoldToSpeakMode
? _buildHoldToSpeakButton()
: _buildNormalInputField(state),
),
const SizedBox(width: 8),
_buildRightActionButton(state),
],
),
if (_isHoldToSpeakMode) ...[
const SizedBox(height: 8),
_buildHoldToSpeakHint(),
],
],
),
);
}
Widget _buildHoldToSpeakButton() {
return GestureDetector(
key: _holdToSpeakKey,
onLongPressStart: (_) => _onHoldToSpeakStart(),
onLongPressEnd: (_) => _onHoldToSpeakEnd(),
onLongPressMoveUpdate: (details) => _onHoldToSpeakMoveUpdate(details),
child: Container(
constraints: const BoxConstraints(minHeight: _inputMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_inputRadius),
border: Border.all(color: AppColors.slate300),
),
child: const Center(
child: Text(
'按住说话',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
),
),
);
}
Widget _buildNormalInputField(ChatState state) {
return Container(
constraints: const BoxConstraints(minHeight: _inputMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_inputRadius),
border: Border.all(color: AppColors.slate300),
),
child: _isRecording
? _buildListeningIndicator()
: _isTranscribing
? _buildTranscribingIndicator()
: TextField(
controller: _messageController,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入消息...',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
);
}
Widget _buildRightActionButton(ChatState state) {
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return GestureDetector(
key: _inputActionButtonKey,
onTap: _isTranscribing
? null
: isWaitingAgent
? () => _onStopGenerating(context)
: _hasMessage
? () => _sendMessage(context)
: _toggleHoldToSpeakMode,
child: _isTranscribing
? const SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
)
: Icon(
key: _inputActionIconKey,
isWaitingAgent
? LucideIcons.square
: _hasMessage
? LucideIcons.send
: _isHoldToSpeakMode
? LucideIcons.keyboard
: LucideIcons.activity,
size: _iconSize,
color: isWaitingAgent || _hasMessage
? AppColors.blue600
: AppColors.slate500,
),
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
color: _chatBgColor,
child: MessageComposer(
mode: _isHoldToSpeakMode
? MessageComposerMode.holdToSpeak
: MessageComposerMode.text,
process: _composerProcess,
hasMessage: _hasMessage,
isWaitingAgent: isWaitingAgent,
iconSize: _iconSize,
composerMinHeight: _inputMinHeight,
onTapPlus: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: false)
: () => _showBottomSheet(context),
onTapRightAction: () => _onRightActionTap(context, state),
onHoldToSpeakStart: _onHoldToSpeakStart,
onHoldToSpeakEnd: _onHoldToSpeakEnd,
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
onHoldToSpeakCancel: _onHoldToSpeakCancel,
textInputChild: _buildTextInputContent(context),
recordingAnimation: const SizedBox.shrink(),
recordingText: _isCancelGestureActive ? '松手取消' : '松手发送',
recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消',
showRecordingInlineFeedback: false,
),
);
}
Widget _buildHoldToSpeakHint() {
return Column(
children: [
if (_isRecording) ...[
_buildRecordingAnimation(),
const SizedBox(height: 4),
const Text(
'松开发送,上滑取消',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
MessageComposerProcess get _composerProcess {
if (_isRecording) {
return MessageComposerProcess.recording;
}
if (_isTranscribing) {
return MessageComposerProcess.transcribing;
}
return MessageComposerProcess.idle;
}
Widget _buildTextInputContent(BuildContext context) {
if (_isTranscribing) {
return _buildTranscribingIndicator();
}
return SizedBox.expand(
child: Align(
alignment: Alignment.centerLeft,
child: TextField(
controller: _messageController,
minLines: 1,
maxLines: 1,
style: const TextStyle(
fontSize: AppSpacing.lg,
height: 1,
color: AppColors.slate900,
),
],
],
textAlignVertical: TextAlignVertical.center,
decoration: const InputDecoration(
hintText: '输入消息...',
hintStyle: TextStyle(
fontSize: AppSpacing.lg,
height: 1,
color: AppColors.slate400,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isCollapsed: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
),
);
}
Widget _buildRecordingAnimation() {
return _buildListeningIndicator();
void _onRightActionTap(BuildContext context, ChatState state) {
if (_isTranscribing || _isRecording) {
return;
}
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
if (isWaitingAgent) {
_onStopGenerating();
return;
}
if (_hasMessage) {
_sendMessage(context);
return;
}
_toggleHoldToSpeakMode();
}
void _toggleHoldToSpeakMode() {
if (_isRecording || _isTranscribing) {
return;
}
setState(() {
_isHoldToSpeakMode = !_isHoldToSpeakMode;
});
}
void _onHoldToSpeakStart() {
HapticFeedback.lightImpact();
HapticFeedback.heavyImpact();
HapticFeedback.vibrate();
setState(() {
_isCancelGestureActive = false;
});
_startRecording();
}
void _onHoldToSpeakEnd() {
if (_isCancelGestureActive) {
HapticFeedback.selectionClick();
_cancelRecording(showToast: false);
return;
}
HapticFeedback.mediumImpact();
_stopRecording(autoSendAfterTranscribe: true);
}
void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) {
const cancelThreshold = -50.0;
if (details.offsetFromOrigin.dy < cancelThreshold) {
_cancelRecording();
final willCancel = details.offsetFromOrigin.dy < _cancelThreshold;
if (willCancel != _isCancelGestureActive && mounted) {
HapticFeedback.selectionClick();
setState(() {
_isCancelGestureActive = willCancel;
});
}
}
Future<void> _cancelRecording() async {
void _onHoldToSpeakCancel() {
_cancelRecording(showToast: false);
}
Future<void> _cancelRecording({bool showToast = true}) async {
try {
await _voiceRecorder.stop();
_listeningAnimationController.stop();
@@ -904,8 +863,11 @@ class _HomeScreenState extends State<HomeScreen>
if (!mounted) return;
setState(() {
_isRecording = false;
_isCancelGestureActive = false;
});
Toast.show(context, '已取消', type: ToastType.info);
if (showToast) {
Toast.show(context, '已取消', type: ToastType.info);
}
}
Future<void> _sendMessage(BuildContext context) async {
@@ -933,8 +895,8 @@ class _HomeScreenState extends State<HomeScreen>
});
}
Future<void> _onStopGenerating(BuildContext context) async {
final canceled = await context.read<ChatBloc>().cancelCurrentRun();
Future<void> _onStopGenerating() async {
final canceled = await _chatBloc.cancelCurrentRun();
if (!mounted) {
return;
}
@@ -943,40 +905,94 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Widget _buildListeningIndicator() {
return SizedBox(
height: _inputMinHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _listeningAnimationController,
builder: (context, _) {
final t = _listeningAnimationController.value;
final waveA =
0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0));
final waveB =
0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0));
final waveC =
0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0));
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildWaveDot(scale: waveA),
const SizedBox(width: 6),
_buildWaveDot(scale: waveB),
const SizedBox(width: 6),
_buildWaveDot(scale: waveC),
],
Widget _buildWaveDots() {
return AnimatedBuilder(
animation: _listeningAnimationController,
builder: (context, _) {
final t = _listeningAnimationController.value;
final barCount = (AppSpacing.xxl * 2).toInt();
final barColor = _isCancelGestureActive
? AppColors.red500
: AppColors.blue500;
return SizedBox(
height: AppSpacing.lg,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(barCount, (index) {
final phase = (index / barCount + t) % 1;
final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Container(
width: AppSpacing.xs / 2,
height: AppSpacing.sm + AppSpacing.xs * active,
decoration: BoxDecoration(
color: barColor.withValues(alpha: 0.35 + active * 0.65),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
);
},
}),
),
const SizedBox(width: 10),
const Text(
'正在聆听...',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
);
},
);
}
Widget _buildRecordingGestureOverlay() {
final topColor = _isCancelGestureActive
? AppColors.warningBackground
: AppColors.blue50;
final bottomColor = _isCancelGestureActive
? AppColors.red400
: AppColors.blue400;
final labelColor = _isCancelGestureActive
? AppColors.red600
: AppColors.white;
final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消';
return IgnorePointer(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: AppSpacing.xxl * 7),
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xxl,
AppSpacing.xl,
AppSpacing.xxl,
),
],
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppRadius.xxl),
topRight: Radius.circular(AppRadius.xxl),
),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [topColor, bottomColor],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
label,
style: TextStyle(
fontSize: AppSpacing.xl,
color: labelColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.md),
_buildWaveDots(),
],
),
),
),
);
}
@@ -1002,20 +1018,6 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Widget _buildWaveDot({required double scale}) {
return Transform.scale(
scale: scale,
child: Container(
width: _recordingDotSize,
height: _recordingDotSize,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.red600,
),
),
);
}
Future<void> _startRecording() async {
try {
await _voiceRecorder.start();
@@ -1025,6 +1027,7 @@ class _HomeScreenState extends State<HomeScreen>
}
setState(() {
_isRecording = true;
_isCancelGestureActive = false;
});
} catch (error) {
if (!mounted) {
@@ -1045,6 +1048,7 @@ class _HomeScreenState extends State<HomeScreen>
setState(() {
_isRecording = false;
_isTranscribing = true;
_isCancelGestureActive = false;
});
if (audioPath == null || audioPath.isEmpty) {
throw StateError('录音失败,请重试');
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
@@ -170,7 +171,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCodeInput(state.code.displayError != null, state),
_buildCodeInput(state),
const SizedBox(height: 16),
_buildPasswordInput(state.newPassword.displayError != null),
const SizedBox(height: 16),
@@ -185,7 +186,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
);
}
Widget _buildCodeInput(bool hasError, ResetPasswordState state) {
Widget _buildCodeInput(ResetPasswordState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -201,18 +202,26 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
Row(
children: [
Expanded(
child: TextField(
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '修改密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
decoration: InputDecoration(
hintText: '请输入 6 位验证码',
errorText: hasError ? ' ' : null,
filled: true,
fillColor: AppColors.white,
),
),
),
const SizedBox(width: 12),
+2 -12
View File
@@ -5,10 +5,9 @@ import 'core/di/injection.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'core/notifications/local_notification_service.dart';
import 'features/auth/data/models/auth_response.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/calendar/data/services/mock_calendar_service.dart';
import 'features/calendar/data/services/calendar_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -18,16 +17,7 @@ void main() async {
await notificationService.initialize();
final authBloc = sl<AuthBloc>();
if (Env.isMockApi) {
authBloc.add(
AuthLoggedIn(
user: AuthUser(id: 'user_001', email: 'test@example.com'),
),
);
} else {
authBloc.add(AuthStarted());
}
authBloc.add(AuthStarted());
try {
final now = DateTime.now();
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/theme/design_tokens.dart';
typedef CodeValueChanged = void Function(String value);
class FixedLengthCodeInput extends StatefulWidget {
final TextEditingController controller;
final int length;
final CodeValueChanged? onChanged;
final TextInputType keyboardType;
final Iterable<String>? allowedCharacters;
final bool uppercase;
final String semanticLabel;
const FixedLengthCodeInput({
required this.controller,
required this.length,
required this.semanticLabel,
super.key,
this.onChanged,
this.keyboardType = TextInputType.text,
this.allowedCharacters,
this.uppercase = false,
});
@override
State<FixedLengthCodeInput> createState() => _FixedLengthCodeInputState();
}
class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
late final FocusNode _focusNode;
bool _isFocused = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChanged);
widget.controller.addListener(_onControllerChanged);
}
@override
void didUpdateWidget(covariant FixedLengthCodeInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_focusNode.removeListener(_onFocusChanged);
_focusNode.dispose();
super.dispose();
}
void _onFocusChanged() {
if (_isFocused != _focusNode.hasFocus) {
_isFocused = _focusNode.hasFocus;
}
}
void _onControllerChanged() {
if (mounted) {
setState(() {});
}
}
void _handleRawChanged(String rawValue) {
final normalized = _normalize(rawValue);
if (normalized != widget.controller.text) {
widget.controller.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
widget.onChanged?.call(normalized);
}
String _normalize(String value) {
var output = widget.uppercase ? value.toUpperCase() : value;
if (widget.allowedCharacters != null) {
final allow = widget.allowedCharacters!.toSet();
output = output.split('').where(allow.contains).join();
}
if (output.length > widget.length) {
output = output.substring(0, widget.length);
}
return output;
}
@override
Widget build(BuildContext context) {
final chars = widget.controller.text.split('');
final slotHeight = AppSpacing.xl * 2;
final slotSpacing = AppSpacing.sm;
return Semantics(
label: widget.semanticLabel,
child: GestureDetector(
onTap: () => _focusNode.requestFocus(),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: slotHeight,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 0,
child: SizedBox(
width: double.infinity,
height: slotHeight,
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
keyboardType: widget.keyboardType,
inputFormatters: [
LengthLimitingTextInputFormatter(widget.length),
],
onChanged: _handleRawChanged,
autofillHints: const [AutofillHints.oneTimeCode],
),
),
),
IgnorePointer(
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < widget.length; index++) ...[
Expanded(
child: _buildCodeCell(
index: index,
chars: chars,
slotHeight: slotHeight,
),
),
if (index != widget.length - 1)
SizedBox(width: slotSpacing),
],
],
),
),
],
),
),
),
);
}
Widget _buildCodeCell({
required int index,
required List<String> chars,
required double slotHeight,
}) {
final hasChar = index < chars.length;
final isActive =
(chars.length == index && _isFocused) ||
(chars.length >= widget.length && index == widget.length - 1);
return Container(
height: slotHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(
color: isActive ? AppColors.primary : AppColors.slate300,
),
),
child: Text(
hasChar ? chars[index] : '',
style: const TextStyle(
fontSize: AppSpacing.xl,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
class LinkButton extends StatelessWidget {
const LinkButton({
super.key,
required this.text,
required this.onTap,
this.enabled = true,
this.textAlign = TextAlign.center,
});
final String text;
final VoidCallback? onTap;
final bool enabled;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: TextButton(
onPressed: enabled ? onTap : null,
style: TextButton.styleFrom(
foregroundColor: enabled ? AppColors.slate500 : AppColors.slate300,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
textAlign: textAlign,
),
),
);
}
}
@@ -0,0 +1,248 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/theme/design_tokens.dart';
enum MessageComposerMode { text, holdToSpeak }
enum MessageComposerProcess { idle, recording, transcribing }
const messageComposerContainerKey = ValueKey('message_composer_container');
const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button');
const messageComposerRightButtonKey = ValueKey('message_composer_right_button');
const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area');
const messageComposerRecordingHintKey = ValueKey(
'message_composer_recording_hint',
);
const _holdActivateDurationMs = 120;
class MessageComposer extends StatelessWidget {
const MessageComposer({
super.key,
required this.mode,
required this.process,
required this.hasMessage,
required this.isWaitingAgent,
required this.iconSize,
required this.composerMinHeight,
required this.onTapPlus,
required this.onTapRightAction,
required this.onHoldToSpeakStart,
required this.onHoldToSpeakEnd,
required this.onHoldToSpeakMoveUpdate,
required this.onHoldToSpeakCancel,
required this.textInputChild,
required this.recordingAnimation,
this.holdToSpeakText = '按住说话',
this.recordingText = '松手发送',
this.transcribingText = '语音识别中...',
this.recordingHintText = '松开发送,上滑取消',
this.showRecordingInlineFeedback = true,
});
final MessageComposerMode mode;
final MessageComposerProcess process;
final bool hasMessage;
final bool isWaitingAgent;
final double iconSize;
final double composerMinHeight;
final VoidCallback onTapPlus;
final VoidCallback onTapRightAction;
final VoidCallback onHoldToSpeakStart;
final VoidCallback onHoldToSpeakEnd;
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel;
final Widget textInputChild;
final Widget recordingAnimation;
final String holdToSpeakText;
final String recordingText;
final String transcribingText;
final String recordingHintText;
final bool showRecordingInlineFeedback;
bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak;
bool get _isRecording => process == MessageComposerProcess.recording;
bool get _isTranscribing => process == MessageComposerProcess.transcribing;
@override
Widget build(BuildContext context) {
return Container(
key: messageComposerContainerKey,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.slate200),
boxShadow: const [
BoxShadow(
color: AppColors.slate200,
blurRadius: AppRadius.lg,
offset: Offset(AppSpacing.none, AppSpacing.xs),
),
BoxShadow(
color: AppColors.white,
blurRadius: AppRadius.md,
offset: Offset(AppSpacing.none, -AppSpacing.xs),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IgnorePointer(
ignoring: _isRecording && _isHoldMode,
child: Opacity(
opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1,
child: IconButton(
key: messageComposerPlusButtonKey,
visualDensity: VisualDensity.compact,
onPressed: onTapPlus,
icon: Icon(
LucideIcons.plus,
size: iconSize,
color: AppColors.slate500,
),
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(child: _buildCenterArea()),
const SizedBox(width: AppSpacing.sm),
IconButton(
key: messageComposerRightButtonKey,
visualDensity: VisualDensity.compact,
onPressed: onTapRightAction,
icon: _isTranscribing
? const SizedBox(
width: AppSpacing.lg,
height: AppSpacing.lg,
child: CircularProgressIndicator(
strokeWidth: AppSpacing.xs / 2,
color: AppColors.blue600,
),
)
: Icon(
_resolveRightIcon(),
size: iconSize,
color: _resolveRightIconColor(),
),
),
],
),
);
}
Widget _buildCenterArea() {
return SizedBox(
height: composerMinHeight,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
child: _isHoldMode
? _buildHoldToSpeakArea(key: const ValueKey('hold_mode'))
: _buildTextInputArea(key: const ValueKey('text_mode')),
),
);
}
Widget _buildTextInputArea({required Key key}) {
return SizedBox(key: key, height: composerMinHeight, child: textInputChild);
}
Widget _buildHoldToSpeakArea({required Key key}) {
return RawGestureDetector(
key: messageComposerHoldAreaKey,
behavior: HitTestBehavior.opaque,
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
duration: const Duration(milliseconds: _holdActivateDurationMs),
),
(instance) {
instance.onLongPressStart = (_) => onHoldToSpeakStart();
instance.onLongPressEnd = (_) => onHoldToSpeakEnd();
instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate;
instance.onLongPressCancel = onHoldToSpeakCancel;
},
),
},
child: Container(
key: key,
width: double.infinity,
height: composerMinHeight,
alignment: Alignment.center,
child: _buildHoldToSpeakContent(),
),
);
}
Widget _buildHoldToSpeakContent() {
if (_isRecording) {
if (!showRecordingInlineFeedback) {
return Align(
alignment: Alignment.center,
child: Text(
recordingText,
style: const TextStyle(color: AppColors.slate700),
),
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
recordingAnimation,
const SizedBox(height: AppSpacing.xs),
Text(
recordingHintText,
key: messageComposerRecordingHintKey,
style: const TextStyle(color: AppColors.slate500),
),
],
);
}
if (_isTranscribing) {
return Align(
alignment: Alignment.center,
child: Text(
transcribingText,
style: const TextStyle(color: AppColors.slate500),
),
);
}
return Align(
alignment: Alignment.center,
child: Text(
holdToSpeakText,
style: const TextStyle(color: AppColors.slate500),
),
);
}
IconData _resolveRightIcon() {
if (isWaitingAgent) {
return LucideIcons.square;
}
if (hasMessage) {
return LucideIcons.send;
}
return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic;
}
Color _resolveRightIconColor() {
if (isWaitingAgent || hasMessage) {
return AppColors.blue600;
}
return AppColors.slate500;
}
}
+16 -11
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
class PageHeader extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
@@ -32,20 +34,23 @@ class BackButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed ?? () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFF8FAFF),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFDEE7F6)),
return SizedBox(
width: AppSpacing.xxl * 2,
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: onPressed ?? () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.all(AppSpacing.none),
backgroundColor: AppColors.surfaceTertiary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
side: const BorderSide(color: AppColors.borderTertiary),
),
),
child: const Icon(
Icons.chevron_left,
size: 18,
color: Color(0xFF334155),
size: AppSpacing.lg + AppSpacing.xs,
color: AppColors.slate700,
),
),
);
@@ -1,140 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/features/calendar/data/calendar_api.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
void main() {
group('CalendarApi', () {
test('listByRange parses metadata and attachments', () async {
final client = MockApiClient();
client.registerPatternHandler(
RegExp(r'^/api/v1/schedule-items\?.*$'),
'GET',
(_) => [
{
'id': 'evt_1',
'title': '晨会',
'description': '同步',
'start_at': '2026-03-11T01:00:00Z',
'end_at': '2026-03-11T02:00:00Z',
'timezone': 'Asia/Shanghai',
'metadata': {
'color': '#4F46E5',
'location': '会议室A',
'notes': '带电脑',
'reminder_minutes': 15,
'attachments': [
{
'name': '议程文档',
'visible_to': ['u1'],
'url': 'https://example.com/a',
'note': '会前阅读',
'content': null,
'type': 'document',
},
],
'version': 1,
'new_field': 'future',
},
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-10T01:30:00Z',
},
],
);
final api = CalendarApi(client);
final result = await api.listByRange(
startAt: DateTime.utc(2026, 3, 1),
endAt: DateTime.utc(2026, 3, 31, 23, 59, 59),
);
expect(result, hasLength(1));
expect(result.first.metadata?.attachments, hasLength(1));
expect(result.first.metadata?.raw['new_field'], 'future');
expect(result.first.metadata?.reminderMinutes, 15);
expect(result.first.startAt.isUtc, isFalse);
});
test('create serializes full metadata', () async {
final client = MockApiClient();
client.registerHandler('/api/v1/schedule-items', 'POST', (request) {
final body = request.data as Map<String, dynamic>;
expect(body['metadata']['version'], 1);
expect(body['metadata']['reminder_minutes'], 15);
expect(body['metadata']['attachments'], isA<List<dynamic>>());
return {
'id': 'evt_2',
...body,
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-10T01:00:00Z',
};
});
final api = CalendarApi(client);
final created = await api.create(
ScheduleItemModel(
id: 'evt_local',
ownerId: 'user-1',
title: '评审',
startAt: DateTime.utc(2026, 3, 11, 3),
endAt: DateTime.utc(2026, 3, 11, 4),
metadata: ScheduleMetadata(
color: '#F59E0B',
location: '线上',
notes: '准备 demo',
attachments: [Attachment(name: 'PRD', type: 'document')],
reminderMinutes: 15,
version: 1,
),
),
);
expect(created.id, 'evt_2');
expect(created.metadata?.location, '线上');
});
test('update does not send unknown metadata fields', () async {
final client = MockApiClient();
client.registerHandler('/api/v1/schedule-items/evt_3', 'PATCH', (
request,
) {
final body = request.data as Map<String, dynamic>;
final metadata = body['metadata'] as Map<String, dynamic>;
expect(metadata.containsKey('new_field'), isFalse);
expect(metadata['reminder_minutes'], 30);
return {
'id': 'evt_3',
...body,
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-11T01:00:00Z',
};
});
final api = CalendarApi(client);
final event = ScheduleItemModel(
id: 'evt_3',
ownerId: 'user-1',
title: '同步会',
startAt: DateTime.utc(2026, 3, 11, 1),
metadata: ScheduleMetadata.fromJson({
'color': '#3B82F6',
'location': '会议室B',
'notes': '更新周报',
'attachments': const [],
'version': 1,
'reminder_minutes': 30,
'new_field': 'future',
}),
);
final updated = await api.update(event);
expect(updated.id, 'evt_3');
});
});
}
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/data/services/mock_calendar_service.dart';
import 'package:social_app/features/calendar/ui/screens/calendar_event_detail_screen.dart';
class _FakeCalendarService extends CalendarService {
final ScheduleItemModel? event;
_FakeCalendarService({required this.event}) : super(apiClient: null);
@override
Future<ScheduleItemModel?> getEventById(String id) async {
return event;
}
}
void main() {
final getIt = GetIt.instance;
setUp(() async {
await getIt.reset();
});
testWidgets('详情页显示结构化提醒时间并不显示metadata原样区块', (tester) async {
sl.registerSingleton<CalendarService>(
_FakeCalendarService(
event: ScheduleItemModel(
id: 'evt_1',
ownerId: 'user-1',
title: '评审会',
startAt: DateTime(2026, 3, 11, 15, 0),
endAt: DateTime(2026, 3, 11, 16, 0),
metadata: ScheduleMetadata(
color: '#4F46E5',
location: '会议室A',
reminderMinutes: 15,
version: 1,
),
),
),
);
await tester.pumpWidget(
const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_1')),
);
await tester.pumpAndSettle();
expect(find.text('提醒时间'), findsOneWidget);
expect(find.text('开始前15分钟'), findsOneWidget);
expect(find.text('metadata'), findsNothing);
});
testWidgets('提醒分钟为空时显示无', (tester) async {
sl.registerSingleton<CalendarService>(
_FakeCalendarService(
event: ScheduleItemModel(
id: 'evt_2',
ownerId: 'user-1',
title: '同步会',
startAt: DateTime(2026, 3, 12, 10, 0),
metadata: ScheduleMetadata(version: 1),
),
),
);
await tester.pumpWidget(
const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_2')),
);
await tester.pumpAndSettle();
expect(find.text('提醒时间'), findsOneWidget);
expect(find.text(''), findsOneWidget);
});
}
@@ -1,603 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
class TestableAgUiService extends AgUiService {
TestableAgUiService({super.onEvent});
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {
await mockEventStream(content);
}
Future<void> mockEventStream(String content) async {
final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}';
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
final engine = AiDecisionEngine();
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
final forceTrigger = engine.tryForceTrigger(content);
if (forceTrigger != null) {
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
}
final replies = generateReplies(content, engine);
if (replies.isNotEmpty) {
await mockTextMessageStream(replies);
}
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
}
Future<void> mockToolCallFlowWithArgs(
String toolName,
Map<String, dynamic> args,
) async {
final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}';
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}'));
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
if (toolName == 'front.navigate_to_route') {
return;
}
final validation = ToolRegistry.validateArgs(toolName, args);
if (!validation.ok) {
onEvent(
ToolCallErrorEvent(
toolCallId: toolCallId,
error: validation.error ?? 'Validation failed',
code: 'VALIDATION_ERROR',
),
);
return;
}
try {
ToolRegistry.initialize();
await ToolRegistry.execute(toolName, args);
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(
ToolCallResultEvent(
messageId: messageId,
toolCallId: toolCallId,
content: '{"result":{"ok":true}}',
),
);
} catch (e) {
onEvent(
ToolCallErrorEvent(
toolCallId: toolCallId,
error: e.toString(),
code: 'EXECUTION_ERROR',
),
);
}
}
List<String> generateReplies(String content, AiDecisionEngine engine) {
final intent = engine.matchIntent(content);
switch (intent) {
case Intent.createEvent:
return ['好的,我已经为您创建了日程安排。'];
case Intent.searchEvent:
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
case Intent.unknown:
return ['我理解了您的问题,让我来帮您处理。'];
}
}
Future<void> mockTextMessageStream(List<String> replies) async {
for (final reply in replies) {
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
onEvent(TextMessageContentEvent(messageId: messageId, delta: reply));
onEvent(TextMessageEndEvent(messageId: messageId));
}
}
}
void main() {
late TestableAgUiService service;
late List<AgUiEvent> capturedEvents;
setUp(() {
capturedEvents = [];
ToolRegistry.initialize();
RouteNavigationTool.instance.clearNavigator();
service = TestableAgUiService(
onEvent: (event) {
capturedEvents.add(event);
},
);
});
group('AgUiService', () {
test('sendMessage first emits RunStartedEvent', () async {
await service.sendMessage('你好');
expect(capturedEvents.first, isA<RunStartedEvent>());
});
test('sendMessage last emits RunFinishedEvent', () async {
await service.sendMessage('你好');
expect(capturedEvents.last, isA<RunFinishedEvent>());
});
test('sendMessage emits events in correct order', () async {
await service.sendMessage('你好');
expect(capturedEvents.first, isA<RunStartedEvent>());
expect(capturedEvents.last, isA<RunFinishedEvent>());
final types = capturedEvents.map((e) => e.type).toList();
expect(types.first, AgUiEventType.runStarted);
expect(types.last, AgUiEventType.runFinished);
});
test(
'creating schedule text does not trigger frontend tool call events',
() async {
await service.sendMessage('提醒我明天10点开会');
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
final toolCallEnds = capturedEvents
.whereType<ToolCallEndEvent>()
.toList();
final toolCallResults = capturedEvents
.whereType<ToolCallResultEvent>()
.toList();
expect(toolCallStarts.isEmpty, true);
expect(toolCallEnds.isEmpty, true);
expect(toolCallResults.isEmpty, true);
},
);
test('force trigger with #tool syntax', () async {
await service.sendMessage(
'#tool:front.navigate_to_route {"target": "/calendar/dayweek"}',
);
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route');
});
test('text message events are emitted for unknown intent', () async {
await service.sendMessage('你好');
final textStarts = capturedEvents
.whereType<TextMessageStartEvent>()
.toList();
final textContents = capturedEvents
.whereType<TextMessageContentEvent>()
.toList();
final textEnds = capturedEvents.whereType<TextMessageEndEvent>().toList();
expect(textStarts.isNotEmpty, true);
expect(textContents.isNotEmpty, true);
expect(textEnds.isNotEmpty, true);
});
test('search intent does not trigger tool calls', () async {
await service.sendMessage('今天有什么日程');
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isEmpty, true);
});
test('frontend tool call keeps pending state before approval', () async {
await service.sendMessage('#tool:front.navigate_to_route {}');
final toolCallErrors = capturedEvents
.whereType<ToolCallErrorEvent>()
.toList();
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallErrors.isEmpty, true);
});
});
group('AgUiService real api-path mock', () {
test('sendMessage posts only current user message to run API', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
Map<String, dynamic>? postedRunInput;
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
postedRunInput = request.data as Map<String, dynamic>;
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
await service.sendMessage('只发送当前输入');
expect(postedRunInput, isNotNull);
final messages = postedRunInput!['messages'] as List<dynamic>;
expect(messages.length, 1);
final first = messages.first as Map<String, dynamic>;
expect(first['role'], 'user');
expect(first['content'], '只发送当前输入');
});
test('sendMessage uploads images then posts binary url blocks', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
var uploadCalls = 0;
final uploadedPath = 'agent-inputs/user/thread-1/upload-1.png';
client.registerHandler('/api/v1/agent/attachments', 'POST', (request) {
uploadCalls += 1;
return {
'attachment': {
'bucket': 'bucket-test',
'path': uploadedPath,
'mimeType': 'image/png',
'url': 'https://signed.example/$uploadedPath',
},
};
});
Map<String, dynamic>? postedRunInput;
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
postedRunInput = request.data as Map<String, dynamic>;
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
final image = XFile.fromData(
Uint8List.fromList(<int>[1, 2, 3]),
mimeType: 'image/png',
name: 'demo.png',
);
await service.sendMessage('图文消息', images: [image]);
expect(uploadCalls, 1);
expect(postedRunInput, isNotNull);
final messages = postedRunInput!['messages'] as List<dynamic>;
final first = messages.first as Map<String, dynamic>;
final content = first['content'] as List<dynamic>;
expect((content.first as Map<String, dynamic>)['type'], 'text');
expect((content[1] as Map<String, dynamic>)['type'], 'binary');
expect(
(content[1] as Map<String, dynamic>)['url'],
'https://signed.example/$uploadedPath',
);
final forwardedProps =
postedRunInput!['forwardedProps'] as Map<String, dynamic>;
final attachments = forwardedProps['attachments'] as List<dynamic>;
expect((attachments.first as Map<String, dynamic>)['path'], uploadedPath);
});
test('approveToolCall posts only tool message to resume API', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
final _ = replace;
});
client.clearMocks();
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
var eventCallCount = 0;
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
eventCallCount += 1;
if (eventCallCount == 1) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
}
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
'',
];
});
Map<String, dynamic>? postedResumeInput;
client.registerHandler('/api/v1/agent/runs/thread-1/resume', 'POST', (
request,
) {
postedResumeInput = request.data as Map<String, dynamic>;
return {
'taskId': 'task-2',
'threadId': 'thread-1',
'runId': 'run-2',
'created': false,
};
});
await service.sendMessage('初始化会话');
await service.approveToolCall(
toolCallId: 'call-1',
toolName: 'front.navigate_to_route',
args: {
'target': '/calendar/dayweek',
'replace': false,
'__nonce': 'nonce-1',
},
);
expect(postedResumeInput, isNotNull);
final messages = postedResumeInput!['messages'] as List<dynamic>;
expect(messages.length, 1);
final first = messages.first as Map<String, dynamic>;
expect(first['role'], 'tool');
expect(first.containsKey('toolCallId'), true);
});
test('approveToolCall resumes and emits TOOL_CALL_RESULT', () async {
final events = <AgUiEvent>[];
final realService = AgUiService(onEvent: events.add);
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
final _ = replace;
});
await realService.sendMessage('打开日历页面');
final toolStart = events.whereType<ToolCallStartEvent>().first;
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
(e) => e.toolCallId == toolStart.toolCallId,
);
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
expect(toolStart.toolCallName, 'front.navigate_to_route');
expect(
events
.whereType<ToolCallResultEvent>()
.where((e) => e.toolCallId == toolStart.toolCallId)
.isEmpty,
true,
);
await realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'front.navigate_to_route',
args: toolArgs,
);
final results = events
.whereType<ToolCallResultEvent>()
.where((e) => e.toolCallId == toolStart.toolCallId)
.toList();
expect(results.isNotEmpty, true);
});
test('approveToolCall aborts when local tool execution fails', () async {
final events = <AgUiEvent>[];
final realService = AgUiService(onEvent: events.add);
await realService.sendMessage('打开日历页面');
final toolStart = events.whereType<ToolCallStartEvent>().first;
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
(e) => e.toolCallId == toolStart.toolCallId,
);
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
// replace navigator -> true 会失败,因为未绑定 navigator。
toolArgs['target'] = '/settings';
expect(
() => realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'front.navigate_to_route',
args: toolArgs,
),
throwsA(isA<StateError>()),
);
});
test('stream ignores malformed SSE payload and continues', () async {
final events = <AgUiEvent>[];
final client = MockApiClient();
final service = AgUiService(onEvent: events.add, apiClient: client);
client.clearMocks();
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: TEXT_MESSAGE_CONTENT',
'data: {bad-json',
'',
'event: TEXT_MESSAGE_CONTENT',
'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1","delta":"ok"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
await service.sendMessage('hi');
expect(events.whereType<RunStartedEvent>().length, 1);
expect(events.whereType<TextMessageContentEvent>().length, 1);
expect(events.whereType<RunFinishedEvent>().length, 1);
});
test('subsequent SSE requests carry Last-Event-ID header', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
var runCount = 0;
final seenLastEventIds = <String?>[];
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
runCount += 1;
return {
'taskId': 'task-$runCount',
'threadId': 'thread-1',
'runId': 'run-$runCount',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (
request,
) {
seenLastEventIds.add(request.headers?['Last-Event-ID']);
if (runCount == 1) {
return <String>[
'id: 1-0',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'id: 2-0',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
}
return <String>[
'id: 3-0',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
'',
'id: 4-0',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
'',
];
});
await service.sendMessage('first');
await service.sendMessage('second');
expect(seenLastEventIds.length, 2);
expect(seenLastEventIds[0], isNull);
expect(seenLastEventIds[1], '2-0');
});
test('stream parses backend TOOL_CALL_RESULT payload with ui field', () async {
final events = <AgUiEvent>[];
final client = MockApiClient();
final service = AgUiService(onEvent: events.add, apiClient: client);
client.clearMocks();
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: TOOL_CALL_RESULT',
'data: {"type":"TOOL_CALL_RESULT","messageId":"tool-result-1","toolCallId":"call-1","callId":"call-1","toolName":"calendar_write","result":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"ui":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"content":"已创建日程:项目评审(明天 10:00)"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
await service.sendMessage('创建日程');
final result = events.whereType<ToolCallResultEvent>().toList();
expect(result.length, 1);
expect(result.first.ui?.cardType, 'calendar_operation.v1');
});
test('fetchAttachmentPreview returns binary bytes', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
client.registerHandler(
'/api/v1/agent/runs/t1/attachments/m1/0',
'GET',
(_) => <int>[1, 2, 3, 4],
);
final data = await service.fetchAttachmentPreview(
'/api/v1/agent/runs/t1/attachments/m1/0',
);
expect(data, [1, 2, 3, 4]);
});
});
}
-410
View File
@@ -1,410 +0,0 @@
import 'dart:typed_data';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
class MockAgUiService extends AgUiService {
MockAgUiService() : super(onEvent: (_) {});
int previewCalls = 0;
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
previewCalls += 1;
await Future<void>.delayed(const Duration(milliseconds: 10));
return Uint8List.fromList(<int>[1, 2, 3]);
}
}
class _ThrowingAgUiService extends AgUiService {
_ThrowingAgUiService() : super(onEvent: (_) {});
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {
throw StateError('network down');
}
}
void main() {
late ChatBloc chatBloc;
late AgUiService service;
setUp(() {
service = MockAgUiService();
chatBloc = ChatBloc(service: service);
});
tearDown(() {
chatBloc.close();
});
group('ChatBloc', () {
test('initial state is empty', () {
expect(chatBloc.state.items, isEmpty);
expect(chatBloc.state.isLoading, false);
expect(chatBloc.state.isSending, false);
expect(chatBloc.state.isWaitingFirstToken, false);
expect(chatBloc.state.isStreaming, false);
expect(chatBloc.state.currentMessageId, isNull);
expect(chatBloc.state.error, isNull);
});
blocTest<ChatBloc, ChatState>(
'sendMessage adds user message to items',
build: () => chatBloc,
act: (bloc) => bloc.sendMessage('Hello'),
expect: () => [
isA<ChatState>()
.having((state) => state.items.length, 'items length', 1)
.having((state) => state.isSending, 'isSending', true)
.having(
(state) => state.isWaitingFirstToken,
'isWaitingFirstToken',
true,
)
.having(
(state) => state.items.first,
'first item',
isA<TextMessageItem>().having(
(item) => item.content,
'content',
'Hello',
),
),
],
);
blocTest<ChatBloc, ChatState>(
'textMessageStart event adds AI message with streaming',
build: () => chatBloc,
act: (bloc) {
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
service.onEvent(
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
);
},
expect: () => [
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
isA<ChatState>()
.having((s) => s.items.length, 'items length', 1)
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
.having(
(s) => s.items.first,
'first item',
isA<TextMessageItem>()
.having((item) => item.isStreaming, 'isStreaming', true)
.having((item) => item.sender, 'sender', MessageSender.ai),
),
],
);
blocTest<ChatBloc, ChatState>(
'textMessageContent event appends content',
build: () => chatBloc,
seed: () => ChatState(
items: [
TextMessageItem(
id: 'msg_1',
content: '',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
),
],
currentMessageId: 'msg_1',
),
act: (bloc) {
service.onEvent(
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
);
},
expect: () => [
isA<ChatState>().having(
(s) => (s.items.first as TextMessageItem).content,
'content',
'Hello',
),
],
);
blocTest<ChatBloc, ChatState>(
'textMessageEnd event sets isStreaming to false',
build: () => chatBloc,
seed: () => ChatState(
items: [
TextMessageItem(
id: 'msg_1',
content: 'Hello World',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
),
],
currentMessageId: 'msg_1',
),
act: (bloc) {
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
},
expect: () => [
isA<ChatState>()
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
.having((s) => s.isStreaming, 'isStreaming', false)
.having(
(s) => (s.items.first as TextMessageItem).isStreaming,
'isStreaming',
false,
),
],
);
blocTest<ChatBloc, ChatState>(
'runStarted sets isLoading to true',
build: () => chatBloc,
act: (bloc) {
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', true)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
.having((s) => s.error, 'error', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runFinished sets isLoading to false',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) {
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'step events update currentStage',
build: () => chatBloc,
act: (bloc) {
service.onEvent(StepStartedEvent(stepName: 'execution'));
service.onEvent(StepFinishedEvent(stepName: 'execution'));
},
expect: () => [
isA<ChatState>().having(
(s) => s.currentStage,
'currentStage',
AgentStage.execution,
),
isA<ChatState>().having((s) => s.currentStage, 'currentStage', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runError sets error message',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) {
service.onEvent(
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
);
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
.having((s) => s.error, 'error', 'Something went wrong'),
],
);
blocTest<ChatBloc, ChatState>(
'cancelCurrentRun exits waiting states',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) => bloc.cancelCurrentRun(),
expect: () => [
isA<ChatState>().having((s) => s.isCancelling, 'isCancelling', true),
isA<ChatState>()
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
.having((s) => s.isStreaming, 'isStreaming', false)
.having((s) => s.isCancelling, 'isCancelling', false),
],
);
blocTest<ChatBloc, ChatState>(
'sendMessage failure emits error and exits waiting state',
build: () => ChatBloc(service: _ThrowingAgUiService()),
act: (bloc) => bloc.sendMessage('hello'),
expect: () => [
isA<ChatState>()
.having((s) => s.isSending, 'isSending', true)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true),
isA<ChatState>()
.having((s) => s.isSending, 'isSending', false)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
.having((s) => s.error, 'error', contains('network down')),
],
);
blocTest<ChatBloc, ChatState>(
'clearError removes error',
build: () => chatBloc,
seed: () => const ChatState(error: 'Some error'),
act: (bloc) => bloc.clearError(),
expect: () => [isA<ChatState>().having((s) => s.error, 'error', isNull)],
);
blocTest<ChatBloc, ChatState>(
'toolCallStart adds ToolCallItem',
build: () => chatBloc,
act: (bloc) {
service.onEvent(
ToolCallStartEvent(
toolCallId: 'tc_1',
toolCallName: 'back.mutate_calendar_event',
),
);
},
expect: () => [
isA<ChatState>().having(
(s) {
final item = s.items.first;
return item is ToolCallItem &&
item.toolName == 'back.mutate_calendar_event' &&
item.status == ToolCallStatus.pending;
},
'has pending tool call',
true,
),
],
);
blocTest<ChatBloc, ChatState>(
'toolCallResult without ui removes pending tool call and does not add empty card',
build: () => chatBloc,
seed: () => ChatState(
items: [
ToolCallItem(
id: 'tc_1',
callId: 'tc_1',
toolName: 'front.navigate_to_route',
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
status: ToolCallStatus.executing,
timestamp: DateTime.now(),
sender: MessageSender.ai,
),
],
),
act: (bloc) {
service.onEvent(
ToolCallResultEvent(
messageId: 'msg_tool_1',
toolCallId: 'tc_1',
content: '{"result":{"ok":true}}',
),
);
},
expect: () => [
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
],
);
blocTest<ChatBloc, ChatState>(
'toolCallResult with ui in payload.result adds ToolResultItem',
build: () => chatBloc,
seed: () => ChatState(
items: [
ToolCallItem(
id: 'tc_2',
callId: 'tc_2',
toolName: 'back.mutate_calendar_event',
args: {'operation': 'create'},
status: ToolCallStatus.executing,
timestamp: DateTime.now(),
sender: MessageSender.ai,
),
],
),
act: (bloc) {
service.onEvent(
ToolCallResultEvent(
messageId: 'msg_tool_2',
toolCallId: 'tc_2',
content:
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
),
);
},
expect: () => [
isA<ChatState>().having(
(s) => s.items.first is ToolResultItem,
'first item is ToolResultItem',
true,
),
],
);
blocTest<ChatBloc, ChatState>(
'state snapshot user message keeps attachments',
build: () => chatBloc,
act: (bloc) {
service.onEvent(
StateSnapshotEvent(
snapshot: {
'scope': 'history_day',
'messages': [
{
'id': 'u1',
'role': 'user',
'content': '请分析这张图',
'attachments': [
{'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'},
],
},
],
},
),
);
},
expect: () => [
isA<ChatState>().having(
(s) {
final item = s.items.first;
return item is TextMessageItem && item.attachments.length == 1;
},
'user attachment count',
true,
),
],
);
test(
'loadAttachmentPreview deduplicates in-flight and caches result',
() async {
final mock = service as MockAgUiService;
final results = await Future.wait<Uint8List?>([
chatBloc.loadAttachmentPreview('/api/preview/1'),
chatBloc.loadAttachmentPreview('/api/preview/1'),
]);
final secondRound = await chatBloc.loadAttachmentPreview(
'/api/preview/1',
);
expect(results.first, isNotNull);
expect(results.last, isNotNull);
expect(secondRound, isNotNull);
expect(mock.previewCalls, 1);
},
);
});
}
@@ -1,323 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/api/api_exception.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
import 'package:social_app/features/home/data/voice_recorder.dart';
import 'package:social_app/features/home/ui/screens/home_screen.dart';
import 'package:social_app/features/messages/data/inbox_api.dart';
class _FakeVoiceRecorder implements VoiceRecorder {
bool started = false;
String? stoppedPath;
@override
Future<void> start() async {
started = true;
}
@override
Future<String?> stop() async {
started = false;
stoppedPath ??=
'${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav';
return stoppedPath;
}
@override
Future<void> dispose() async {}
}
class _WaitingAgUiService extends AgUiService {
_WaitingAgUiService() : super(onEvent: (_) {});
final Completer<void> _pending = Completer<void>();
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
return _pending.future;
}
void emitStepStarted(String stepName) {
onEvent(StepStartedEvent(stepName: stepName));
}
}
void main() {
setUpAll(() {
if (!sl.isRegistered<InboxApi>()) {
sl.registerSingleton<InboxApi>(InboxApi(MockApiClient()));
}
});
IconData _inputActionIcon(WidgetTester tester) {
final icon = tester.widget<Icon>(
find.byKey(const ValueKey('home_input_action_icon')),
);
return icon.icon!;
}
group('HomeScreen Widget Tests', () {
testWidgets('displays input field', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('输入消息...'), findsOneWidget);
});
testWidgets('displays header icons', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget);
});
testWidgets('displays send or mic icon based on input', (
WidgetTester tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
});
testWidgets('tap mic starts recording and shows listening state', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
expect(fakeRecorder.started, true);
expect(find.text('正在聆听...'), findsOneWidget);
expect(_inputActionIcon(tester), LucideIcons.send);
});
testWidgets('tap send while recording transcribes and auto sends message', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
String? sentTranscript;
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (filePath) async {
expect(filePath.endsWith('.wav'), true);
return '语音自动发送';
},
onAutoSendTranscript: (transcript) async {
sentTranscript = transcript;
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(sentTranscript, '语音自动发送');
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
});
testWidgets('tap stop enters transcribing state', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (filePath) async {
expect(filePath.endsWith('.wav'), true);
return '语音转文字结果';
},
onAutoSendTranscript: (_) async {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('语音识别中...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1));
});
testWidgets('tap stop shows readable unauthorized message', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) async {
throw const UnauthorizedException();
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('请重新登录'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
});
testWidgets('tap stop shows message when transcript is empty', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) async => '',
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
});
testWidgets('shows transcribing indicator while waiting ASR result', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
final completer = Completer<String>();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) => completer.future,
onAutoSendTranscript: (_) async {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('语音识别中...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete('识别完成');
});
testWidgets('tap send unfocuses text input after sending', (
WidgetTester tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.enterText(find.byType(TextField), 'hello');
await tester.pump();
final editableBefore = tester.state<EditableTextState>(
find.byType(EditableText),
);
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
final editableAfter = tester.state<EditableTextState>(
find.byType(EditableText),
);
expect(editableAfter.widget.focusNode.hasFocus, isFalse);
await tester.pump(const Duration(milliseconds: 300));
});
testWidgets('shows stop icon and waiting indicator while waiting agent', (
WidgetTester tester,
) async {
final waitingService = _WaitingAgUiService();
final chatBloc = ChatBloc(service: waitingService);
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hello');
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(_inputActionIcon(tester), LucideIcons.square);
expect(find.text('正在思考...'), findsOneWidget);
waitingService.emitStepStarted('intent');
await tester.pump();
expect(find.text('意图识别中'), findsOneWidget);
expect(find.text('正在思考...'), findsNothing);
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('已停止等待回复'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
await chatBloc.close();
});
});
}
@@ -0,0 +1,237 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/shared/widgets/message_composer.dart';
Widget _buildTestApp({
required MessageComposerMode mode,
required MessageComposerProcess process,
required bool hasMessage,
required bool isWaitingAgent,
VoidCallback? onHoldStart,
VoidCallback? onHoldEnd,
VoidCallback? onHoldCancel,
}) {
return MaterialApp(
home: Scaffold(
body: MessageComposer(
mode: mode,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
iconSize: 24,
composerMinHeight: 48,
onTapPlus: () {},
onTapRightAction: () {},
onHoldToSpeakStart: onHoldStart ?? () {},
onHoldToSpeakEnd: onHoldEnd ?? () {},
onHoldToSpeakMoveUpdate: (_) {},
onHoldToSpeakCancel: onHoldCancel ?? () {},
textInputChild: const SizedBox.shrink(),
recordingAnimation: const SizedBox.shrink(),
),
),
);
}
void main() {
group('MessageComposer', () {
testWidgets('renders one unified rounded composer container', (
tester,
) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerContainerKey), findsOneWidget);
final containerFinder = find.byKey(messageComposerContainerKey);
final plusFinder = find.byKey(messageComposerPlusButtonKey);
final rightFinder = find.byKey(messageComposerRightButtonKey);
expect(
find.descendant(of: containerFinder, matching: plusFinder),
findsOneWidget,
);
expect(
find.descendant(of: containerFinder, matching: rightFinder),
findsOneWidget,
);
});
testWidgets('right action icon follows state priority', (tester) async {
Future<IconData> rightIconFor({
required MessageComposerMode mode,
required MessageComposerProcess process,
required bool hasMessage,
required bool isWaitingAgent,
}) async {
await tester.pumpWidget(
_buildTestApp(
mode: mode,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
),
);
final iconFinder = find.descendant(
of: find.byKey(messageComposerRightButtonKey),
matching: find.byType(Icon),
);
final iconWidget = tester.widget<Icon>(iconFinder.first);
return iconWidget.icon!;
}
expect(
await rightIconFor(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: true,
),
LucideIcons.square,
);
expect(
await rightIconFor(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: true,
isWaitingAgent: false,
),
LucideIcons.send,
);
expect(
await rightIconFor(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
LucideIcons.keyboard,
);
expect(
await rightIconFor(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
LucideIcons.mic,
);
});
testWidgets('recording hint appears only while recording', (tester) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerRecordingHintKey), findsNothing);
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.recording,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget);
expect(find.text('松开发送,上滑取消'), findsOneWidget);
});
testWidgets('composer height remains stable across mode switches', (
tester,
) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
final textHeight = tester.getSize(
find.byKey(messageComposerContainerKey),
);
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
final holdHeight = tester.getSize(
find.byKey(messageComposerContainerKey),
);
expect(textHeight.height, holdHeight.height);
});
testWidgets('invokes long press start/end callbacks in hold mode', (
tester,
) async {
var started = false;
var ended = false;
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
onHoldStart: () => started = true,
onHoldEnd: () => ended = true,
),
);
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
final gesture = await tester.startGesture(center);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
await gesture.up();
await tester.pump();
expect(started, isTrue);
expect(ended, isTrue);
});
testWidgets('invokes long press cancel callback when gesture canceled', (
tester,
) async {
var canceled = false;
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
onHoldCancel: () => canceled = true,
),
);
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
final gesture = await tester.startGesture(center);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
await gesture.cancel();
await tester.pump();
expect(canceled, isTrue);
});
});
}
-87
View File
@@ -1,87 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/main.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/auth/data/auth_repository.dart';
import 'package:social_app/core/di/injection.dart';
class MockAuthBloc extends Mock implements AuthBloc {}
class MockAuthRepository extends Mock implements AuthRepository {}
class FakeAuthState extends Fake implements AuthState {}
void main() {
setUpAll(() {
registerFallbackValue(FakeAuthState());
});
setUp(() async {
if (sl.isRegistered<AuthRepository>()) {
await sl.reset();
}
sl.registerSingleton<AuthRepository>(MockAuthRepository());
});
testWidgets('Login screen loads correctly', (WidgetTester tester) async {
final mockAuthBloc = MockAuthBloc();
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
when(
() => mockAuthBloc.stream,
).thenAnswer((_) => Stream.value(AuthInitial()));
await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc));
expect(find.text('linksy'), findsOneWidget);
expect(find.text('登录'), findsOneWidget);
expect(find.text('还没有账号?去注册'), findsOneWidget);
});
testWidgets('Main content is vertically centered above footer', (
WidgetTester tester,
) async {
final mockAuthBloc = MockAuthBloc();
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
when(
() => mockAuthBloc.stream,
).thenAnswer((_) => Stream.value(AuthInitial()));
await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc));
final safeAreaRect = tester.getRect(find.byType(SafeArea));
final mainRect = tester.getRect(
find.byKey(const Key('login_main_content')),
);
final footerRect = tester.getRect(find.byKey(const Key('login_footer')));
final topSpace = mainRect.top - safeAreaRect.top;
final bottomSpace = footerRect.top - mainRect.bottom;
expect((topSpace - bottomSpace).abs(), lessThanOrEqualTo(2));
});
testWidgets('Login screen does not overflow when keyboard is visible', (
WidgetTester tester,
) async {
final mockAuthBloc = MockAuthBloc();
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
when(
() => mockAuthBloc.stream,
).thenAnswer((_) => Stream.value(AuthInitial()));
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
size: Size(390, 844),
viewInsets: EdgeInsets.only(bottom: 320),
),
child: LinksyApp(authBloc: mockAuthBloc),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('登录'), findsOneWidget);
});
}