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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user