diff --git a/apps/lib/features/calendar/data/calendar_api.dart b/apps/lib/features/calendar/data/calendar_api.dart index 27048c7..3a2e2d2 100644 --- a/apps/lib/features/calendar/data/calendar_api.dart +++ b/apps/lib/features/calendar/data/calendar_api.dart @@ -57,7 +57,7 @@ class CalendarApi { Future share( String itemId, { - required String email, + required String phone, bool view = true, bool edit = false, bool invite = false, @@ -65,7 +65,7 @@ class CalendarApi { await _client.post( '$_prefix/$itemId/share', data: { - 'email': email, + 'phone': phone, 'permission_view': view, 'permission_edit': edit, 'permission_invite': invite, diff --git a/apps/lib/features/calendar/ui/widgets/calendar_share_dialog.dart b/apps/lib/features/calendar/ui/widgets/calendar_share_dialog.dart index b75b289..1139620 100644 --- a/apps/lib/features/calendar/ui/widgets/calendar_share_dialog.dart +++ b/apps/lib/features/calendar/ui/widgets/calendar_share_dialog.dart @@ -46,7 +46,7 @@ class CalendarShareDialog extends StatefulWidget { } class _CalendarShareDialogState extends State { - final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); bool _permissionView = true; bool _permissionEdit = false; bool _permissionInvite = false; @@ -54,14 +54,14 @@ class _CalendarShareDialogState extends State { @override void dispose() { - _emailController.dispose(); + _phoneController.dispose(); super.dispose(); } Future _handleShare() async { - final email = _emailController.text.trim(); - if (email.isEmpty) { - Toast.show(context, '请输入邮箱地址', type: ToastType.error); + final phone = _phoneController.text.trim(); + if (phone.isEmpty) { + Toast.show(context, '请输入手机号', type: ToastType.error); return; } @@ -71,7 +71,7 @@ class _CalendarShareDialogState extends State { final api = sl(); await api.share( widget.eventId, - email: email, + phone: phone, view: _permissionView, edit: _permissionEdit, invite: _permissionInvite, @@ -127,15 +127,15 @@ class _CalendarShareDialogState extends State { Text(widget.eventTitle, style: const TextStyle(fontSize: 16)), const SizedBox(height: AppSpacing.lg), TextField( - controller: _emailController, + controller: _phoneController, decoration: InputDecoration( - labelText: '邮箱地址', - hintText: '输入对方的邮箱', + labelText: '手机号', + hintText: '输入对方的 +86 手机号', border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), ), ), - keyboardType: TextInputType.emailAddress, + keyboardType: TextInputType.phone, ), const SizedBox(height: AppSpacing.lg), const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)), diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 27d5878..397c7dc 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -46,6 +46,8 @@ class AgUiService { EventCallback onEvent; final Map _lastEventIdByThread = {}; int _activeStreamToken = 0; + StreamSubscription? _activeSseSubscription; + Completer? _activeSseDoneCompleter; String? _threadId; bool _hasMoreHistory = false; @@ -58,6 +60,7 @@ class AgUiService { String content, { List? images, }) async { + await _cancelActiveSseSubscription(); final streamToken = ++_activeStreamToken; final runInputPayload = await _buildRunInput( content: content, @@ -149,6 +152,21 @@ class AgUiService { Future cancelCurrentRun() async { _activeStreamToken += 1; + await _cancelActiveSseSubscription(); + } + + Future _cancelActiveSseSubscription() async { + final doneCompleter = _activeSseDoneCompleter; + if (doneCompleter != null && !doneCompleter.isCompleted) { + doneCompleter.complete(); + } + _activeSseDoneCompleter = null; + final subscription = _activeSseSubscription; + _activeSseSubscription = null; + if (subscription == null) { + return; + } + await subscription.cancel(); } Future _streamEventsFromApi( @@ -170,80 +188,129 @@ class AgUiService { String? eventId; var hasBoundExpectedRun = false; final dataBuffer = StringBuffer(); - await for (final line in sseLines) { - if (streamToken != _activeStreamToken) { - break; + final done = Completer(); + late final StreamSubscription subscription; + + void stopStream({Object? error, StackTrace? stackTrace}) { + if (!done.isCompleted) { + if (error == null) { + done.complete(); + } else { + done.completeError(error, stackTrace); + } } - if (line.isEmpty) { - if (dataBuffer.isNotEmpty) { - final raw = dataBuffer.toString(); - dataBuffer.clear(); - Map? decoded; - String? eventRunId; - String? eventThreadId; - try { - final parsed = jsonDecode(raw); - if (parsed is Map) { - decoded = parsed; - final runId = parsed['runId']; - final thread = parsed['threadId']; - eventRunId = runId is String ? runId : null; - eventThreadId = thread is String ? thread : null; + unawaited(subscription.cancel()); + } - final isRunStarted = eventType == AgUiEventTypeWire.runStarted; - final isTargetRun = eventRunId == expectedRunId; - if (isRunStarted && isTargetRun) { - hasBoundExpectedRun = true; + subscription = sseLines.listen( + (line) { + try { + if (streamToken != _activeStreamToken) { + stopStream(); + return; + } + if (line.isEmpty) { + if (dataBuffer.isNotEmpty) { + final raw = dataBuffer.toString(); + dataBuffer.clear(); + String? eventRunId; + String? eventThreadId; + Map? parsedMap; + try { + final parsed = jsonDecode(raw); + if (parsed is Map) { + parsedMap = parsed; + } + } catch (_) { + // Ignore malformed SSE payload and keep stream alive. } + if (parsedMap != null) { + final runId = parsedMap['runId']; + final thread = parsedMap['threadId']; + eventRunId = runId is String ? runId : null; + eventThreadId = thread is String ? thread : null; + final isRunStarted = eventType == AgUiEventTypeWire.runStarted; + final isTargetRun = eventRunId == expectedRunId; + if (isRunStarted && isTargetRun) { + hasBoundExpectedRun = true; + } + + final isThreadMatched = + eventThreadId == null || eventThreadId == threadId; + final shouldDispatch = + isTargetRun || (hasBoundExpectedRun && isThreadMatched); + if (shouldDispatch) { + final event = AgUiEvent.fromJson(parsedMap); + onEvent(event); + } + } + final currentEventId = eventId; + if (currentEventId != null && currentEventId.isNotEmpty) { + _lastEventIdByThread[threadId] = currentEventId; + } + final isTerminalEvent = + eventType == AgUiEventTypeWire.runFinished || + eventType == AgUiEventTypeWire.runError; + final isTargetRun = eventRunId == expectedRunId; final isThreadMatched = eventThreadId == null || eventThreadId == threadId; - final shouldDispatch = - isTargetRun || (hasBoundExpectedRun && isThreadMatched); - if (shouldDispatch) { - final event = AgUiEvent.fromJson(parsed); - onEvent(event); + if (isTerminalEvent && + (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) { + stopStream(); + return; } } - } catch (_) { - // Ignore malformed SSE payload and keep stream alive. + eventType = null; + eventId = null; + return; } - final currentEventId = eventId; - if (currentEventId != null && currentEventId.isNotEmpty) { - _lastEventIdByThread[threadId] = currentEventId; + if (line.startsWith(':')) { + return; } - final isTerminalEvent = - eventType == AgUiEventTypeWire.runFinished || - eventType == AgUiEventTypeWire.runError; - final isTargetRun = eventRunId == expectedRunId; - final isThreadMatched = - eventThreadId == null || eventThreadId == threadId; - if (isTerminalEvent && - (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) { - break; + if (line.startsWith('id:')) { + eventId = line.substring(3).trim(); + return; } + if (line.startsWith('event:')) { + eventType = line.substring(6).trim(); + return; + } + if (line.startsWith('data:')) { + final fragment = line.substring(5).trim(); + if (dataBuffer.isNotEmpty) { + dataBuffer.write('\n'); + } + dataBuffer.write(fragment); + } + } catch (error, stackTrace) { + stopStream(error: error, stackTrace: stackTrace); } - eventType = null; - eventId = null; - continue; + }, + onError: (Object error, StackTrace stackTrace) { + stopStream(error: error, stackTrace: stackTrace); + }, + onDone: () { + stopStream(); + }, + cancelOnError: false, + ); + + if (streamToken != _activeStreamToken) { + await subscription.cancel(); + return; + } + + _activeSseSubscription = subscription; + _activeSseDoneCompleter = done; + try { + await done.future; + } finally { + if (identical(_activeSseSubscription, subscription)) { + _activeSseSubscription = null; } - if (line.startsWith(':')) { - continue; - } - if (line.startsWith('id:')) { - eventId = line.substring(3).trim(); - continue; - } - if (line.startsWith('event:')) { - eventType = line.substring(6).trim(); - continue; - } - if (line.startsWith('data:')) { - final fragment = line.substring(5).trim(); - if (dataBuffer.isNotEmpty) { - dataBuffer.write('\n'); - } - dataBuffer.write(fragment); + if (identical(_activeSseDoneCompleter, done)) { + _activeSseDoneCompleter = null; } } } diff --git a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart index 0bc81f8..acccdc8 100644 --- a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart +++ b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart @@ -18,7 +18,7 @@ class AddContactScreen extends StatefulWidget { class _AddContactScreenState extends State { final _nameController = TextEditingController(); - final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); final _remarkController = TextEditingController(); bool get isEditing => widget.contactId != null; @@ -26,7 +26,7 @@ class _AddContactScreenState extends State { @override void dispose() { _nameController.dispose(); - _emailController.dispose(); + _phoneController.dispose(); _remarkController.dispose(); super.dispose(); } @@ -35,7 +35,9 @@ class _AddContactScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.surfaceSecondary, + resizeToAvoidBottomInset: false, body: SafeArea( + maintainBottomViewPadding: true, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -122,10 +124,10 @@ class _AddContactScreenState extends State { AppInput(label: '昵称', hint: '请输入昵称', controller: _nameController), const SizedBox(height: 14), AppInput( - label: '邮箱', - hint: '请输入邮箱', - controller: _emailController, - keyboardType: TextInputType.emailAddress, + label: '手机号', + hint: '+86 请输入 11 位手机号', + controller: _phoneController, + keyboardType: TextInputType.phone, ), const SizedBox(height: 14), AppInput( @@ -152,10 +154,10 @@ class _AddContactScreenState extends State { void _handleConfirm() { final name = _nameController.text.trim(); - final email = _emailController.text.trim(); + final phone = _phoneController.text.trim(); - if (name.isEmpty || email.isEmpty) { - Toast.show(context, '请填写昵称和邮箱', type: ToastType.warning); + if (name.isEmpty || phone.isEmpty) { + Toast.show(context, '请填写昵称和手机号', type: ToastType.warning); return; } diff --git a/apps/lib/features/contacts/ui/screens/contacts_screen.dart b/apps/lib/features/contacts/ui/screens/contacts_screen.dart index f9816ce..cbf9333 100644 --- a/apps/lib/features/contacts/ui/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/ui/screens/contacts_screen.dart @@ -63,21 +63,11 @@ class _ContactsScreenState extends State { } } - bool _isValidEmail(String email) { - final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$'); - return emailRegex.hasMatch(email); - } - Future _onSearch() async { final query = _searchController.text.trim(); if (query.isEmpty) { - Toast.show(context, '请输入邮箱地址', type: ToastType.warning); - return; - } - - if (!_isValidEmail(query)) { - Toast.show(context, '请输入有效的邮箱地址', type: ToastType.warning); + Toast.show(context, '请输入用户名或手机号', type: ToastType.warning); return; } @@ -265,7 +255,9 @@ class _ContactsScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.surfaceSecondary, + resizeToAvoidBottomInset: false, body: SafeArea( + maintainBottomViewPadding: true, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -323,7 +315,7 @@ class _ContactsScreenState extends State { controller: _searchController, focusNode: _searchFocusNode, decoration: const InputDecoration( - hintText: '输入邮箱搜索用户', + hintText: '输入用户名或手机号', hintStyle: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -341,7 +333,7 @@ class _ContactsScreenState extends State { ), ), style: const TextStyle(fontSize: 13), - keyboardType: TextInputType.emailAddress, + keyboardType: TextInputType.text, textInputAction: TextInputAction.search, onSubmitted: (_) => _onSearch(), onChanged: (value) { @@ -562,7 +554,7 @@ class _ContactsScreenState extends State { ), const SizedBox(height: 4), const Text( - '搜索邮箱添加好友开始聊天吧', + '搜索手机号添加好友开始聊天吧', style: TextStyle(fontSize: 13, color: AppColors.slate400), ), ], diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 8852a14..d303c1c 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,9 +23,9 @@ import '../../../../shared/widgets/full_screen_loading.dart'; import 'home_sheet.dart'; import '../widgets/home_background_field.dart'; import '../widgets/home_chat_item_renderer.dart'; -import '../widgets/home_composer_stack.dart'; import '../widgets/home_conversation_chrome.dart'; import '../widgets/home_floating_header.dart'; +import '../widgets/home_input_host.dart'; import '../widgets/home_recording_overlay.dart'; import '../widgets/home_unread_badge.dart'; @@ -72,7 +71,6 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with SingleTickerProviderStateMixin, RouteAware { final TextEditingController _messageController = TextEditingController(); - final FocusNode _messageFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; late final VoiceRecorder _voiceRecorder; @@ -81,7 +79,6 @@ class _HomeScreenState extends State late final AnimationController _listeningAnimationController; bool _isRecording = false; bool _isRecordingStarting = false; - bool _isHoldToSpeakMode = true; bool _isTranscribing = false; bool _isCancelGestureActive = false; bool _shouldCancelWhenStartCompletes = false; @@ -101,6 +98,9 @@ class _HomeScreenState extends State bool _routeAwareSubscribed = false; double? _historyViewportPixels; double? _historyViewportMaxExtent; + final GlobalKey _inputHostKey = + GlobalKey(); + double _stableKeyboardInset = 0; @override void initState() { @@ -143,7 +143,6 @@ class _HomeScreenState extends State @override void dispose() { _messageController.dispose(); - _messageFocusNode.dispose(); _scrollController.removeListener(_handleScrollChanged); _scrollController.dispose(); _listeningAnimationController.dispose(); @@ -222,16 +221,22 @@ class _HomeScreenState extends State builder: (context, state) { return Scaffold( backgroundColor: _chatBgColor, + resizeToAvoidBottomInset: false, body: SafeArea( + maintainBottomViewPadding: true, child: Stack( children: [ - const Positioned.fill(child: HomeBackgroundField()), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(context), - Expanded(child: _buildChatArea(context, state)), - ], + Positioned.fill(child: HomeBackgroundField()), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _dismissKeyboard, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + Expanded(child: _buildChatArea(context, state)), + ], + ), ), if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(), _buildBottomInputStack(context, state), @@ -261,17 +266,18 @@ class _HomeScreenState extends State Widget _buildChatArea(BuildContext context, ChatState state) { final showWaitingIndicator = _isAgentWaiting(state); + final inputBottomInset = _effectiveKeyboardInset(context); if (state.isLoadingHistory && state.items.isEmpty) { return const FullScreenLoading(); } return Padding( - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, - _bottomStackReservedHeight, + _bottomStackReservedHeight + inputBottomInset, ), child: KeyedSubtree( key: homeConversationStageKey, @@ -286,6 +292,8 @@ class _HomeScreenState extends State child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.only( top: AppSpacing.sm, bottom: showWaitingIndicator @@ -349,9 +357,10 @@ class _HomeScreenState extends State } Widget _buildUnreadBadge() { + final inputBottomInset = _effectiveKeyboardInset(context); return Positioned( right: _defaultPadding, - bottom: _bottomStackReservedHeight + AppSpacing.md, + bottom: _bottomStackReservedHeight + AppSpacing.md + inputBottomInset, child: HomeUnreadBadge( count: _chatUnreadBadgeCount, onTap: () { @@ -524,12 +533,32 @@ class _HomeScreenState extends State return 0; } final position = _scrollController.position; - final bottomInset = MediaQuery.viewInsetsOf(context).bottom; - return (position.maxScrollExtent - position.pixels - bottomInset) + final keyboardInset = _effectiveKeyboardInset(context); + return (position.maxScrollExtent - position.pixels - keyboardInset) .clamp(0, double.infinity) .toDouble(); } + double _effectiveKeyboardInset(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final rawInset = mediaQuery.viewInsets.bottom; + if (rawInset <= AppSpacing.xs) { + _stableKeyboardInset = 0; + return 0; + } + // Only update stable if new value is larger (never decrease on jitter down) + if (rawInset > _stableKeyboardInset) { + _stableKeyboardInset = rawInset; + } + return _stableKeyboardInset; + } + + void _dismissKeyboard() { + _inputHostKey.currentState?.unfocusInput(); + final focus = FocusManager.instance.primaryFocus; + focus?.unfocus(); + } + void _applyViewportDecision(ViewportDecision decision) { switch (decision.action) { case ViewportAction.jumpBottom: @@ -589,26 +618,26 @@ class _HomeScreenState extends State Widget _buildBottomInputStack(BuildContext context, ChatState state) { final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); - return HomeComposerStack( + final inputBottomInset = _effectiveKeyboardInset(context); + return HomeInputHost( + key: _inputHostKey, selectedImages: _selectedImages, onRemoveImage: _removeImage, - isHoldToSpeakMode: _isHoldToSpeakMode, isRecording: _isRecording, isCancelGestureActive: _isCancelGestureActive, isTranscribing: _isTranscribing, isWaitingAgent: isWaitingAgent, messageController: _messageController, - messageFocusNode: _messageFocusNode, onTapPlus: _isRecording ? () => _stopRecording(autoSendAfterTranscribe: false) : () => _showBottomSheet(context), - onTapRightAction: () => _onRightActionTap(context, state), + onStopGenerating: _onStopGenerating, onHoldToSpeakStart: _onHoldToSpeakStart, onHoldToSpeakEnd: _onHoldToSpeakEnd, onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, onHoldToSpeakCancel: _onHoldToSpeakCancel, - onTextFieldTap: _onTextFieldTap, - onSubmit: () => _sendMessage(context), + onSubmitText: (text) => _sendMessage(context, overrideContent: text), + keyboardInset: inputBottomInset, ); } @@ -618,54 +647,6 @@ class _HomeScreenState extends State }); } - void _onTextFieldTap() { - final alreadyFocused = _messageFocusNode.hasFocus; - if (!alreadyFocused) { - _messageFocusNode.requestFocus(); - return; - } - if (!_supportsProgrammaticKeyboardShow()) { - return; - } - SystemChannels.textInput.invokeMethod('TextInput.show'); - } - - bool _supportsProgrammaticKeyboardShow() { - if (kIsWeb) { - return false; - } - return defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - } - - void _onRightActionTap(BuildContext context, ChatState state) { - if (_isTranscribing || _isRecording) { - return; - } - if (_isSendingMessage || _isAgentWaiting(state)) { - _onStopGenerating(); - return; - } - if (_messageController.text.trim().isNotEmpty) { - _sendMessage(context); - return; - } - _toggleHoldToSpeakMode(); - } - - void _toggleHoldToSpeakMode() { - if (_isRecording || _isTranscribing) { - return; - } - final willSwitchToText = _isHoldToSpeakMode; - setState(() { - _isHoldToSpeakMode = !willSwitchToText; - }); - if (!willSwitchToText) { - _messageFocusNode.unfocus(); - } - } - void _onHoldToSpeakStart() { HapticFeedback.selectionClick(); setState(() { diff --git a/apps/lib/features/home/ui/screens/home_screen_interactions.dart b/apps/lib/features/home/ui/screens/home_screen_interactions.dart index 80bb01c..553346e 100644 --- a/apps/lib/features/home/ui/screens/home_screen_interactions.dart +++ b/apps/lib/features/home/ui/screens/home_screen_interactions.dart @@ -27,17 +27,21 @@ extension _HomeScreenInteractions on _HomeScreenState { } } - Future _sendMessage(BuildContext context) async { + Future _sendMessage( + BuildContext context, { + String? overrideContent, + }) async { if (_isSendingMessage) { return; } - final content = _messageController.text.trim(); + final content = (overrideContent ?? _messageController.text).trim(); if (content.isEmpty && _selectedImages.isEmpty) return; final images = List.from(_selectedImages); - FocusScope.of(context).unfocus(); + final currentFocus = FocusManager.instance.primaryFocus; + currentFocus?.unfocus(); _messageController.clear(); setState(() { _isSendingMessage = true; diff --git a/apps/lib/features/home/ui/widgets/home_composer_stack.dart b/apps/lib/features/home/ui/widgets/home_composer_stack.dart index 492b033..8dbcc6c 100644 --- a/apps/lib/features/home/ui/widgets/home_composer_stack.dart +++ b/apps/lib/features/home/ui/widgets/home_composer_stack.dart @@ -24,8 +24,8 @@ class HomeComposerStack extends StatelessWidget { required this.onHoldToSpeakEnd, required this.onHoldToSpeakMoveUpdate, required this.onHoldToSpeakCancel, - required this.onTextFieldTap, required this.onSubmit, + required this.keyboardInset, }); final List selectedImages; @@ -43,8 +43,8 @@ class HomeComposerStack extends StatelessWidget { final VoidCallback onHoldToSpeakEnd; final ValueChanged onHoldToSpeakMoveUpdate; final VoidCallback onHoldToSpeakCancel; - final VoidCallback onTextFieldTap; final VoidCallback onSubmit; + final double keyboardInset; @override Widget build(BuildContext context) { @@ -57,7 +57,12 @@ class HomeComposerStack extends StatelessWidget { return Align( alignment: Alignment.bottomCenter, child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg + keyboardInset, + ), child: KeyedSubtree( key: const ValueKey('home_bottom_input_stack'), child: Column( @@ -141,7 +146,6 @@ class HomeComposerStack extends StatelessWidget { contentPadding: EdgeInsets.zero, filled: false, ), - onTap: onTextFieldTap, onSubmitted: (_) => onSubmit(), ), ), diff --git a/apps/lib/features/home/ui/widgets/home_input_host.dart b/apps/lib/features/home/ui/widgets/home_input_host.dart new file mode 100644 index 0000000..abaf7e1 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_input_host.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'home_composer_stack.dart'; + +class HomeInputHost extends StatefulWidget { + const HomeInputHost({ + super.key, + required this.selectedImages, + required this.onRemoveImage, + required this.isRecording, + required this.isCancelGestureActive, + required this.isTranscribing, + required this.isWaitingAgent, + required this.messageController, + required this.onTapPlus, + required this.onStopGenerating, + required this.onHoldToSpeakStart, + required this.onHoldToSpeakEnd, + required this.onHoldToSpeakMoveUpdate, + required this.onHoldToSpeakCancel, + required this.onSubmitText, + required this.keyboardInset, + }); + + final List selectedImages; + final ValueChanged onRemoveImage; + final bool isRecording; + final bool isCancelGestureActive; + final bool isTranscribing; + final bool isWaitingAgent; + final TextEditingController messageController; + final VoidCallback onTapPlus; + final VoidCallback onStopGenerating; + final VoidCallback onHoldToSpeakStart; + final VoidCallback onHoldToSpeakEnd; + final ValueChanged onHoldToSpeakMoveUpdate; + final VoidCallback onHoldToSpeakCancel; + final Future Function(String text) onSubmitText; + final double keyboardInset; + + @override + State createState() => HomeInputHostState(); +} + +class HomeInputHostState extends State { + final FocusNode _messageFocusNode = FocusNode(); + Timer? _keyboardShowFallbackTimer; + bool _isHoldToSpeakMode = true; + + void unfocusInput() { + _messageFocusNode.unfocus(); + } + + @override + void initState() { + super.initState(); + _messageFocusNode.addListener(_handleMessageFocusChanged); + } + + @override + void dispose() { + _keyboardShowFallbackTimer?.cancel(); + _messageFocusNode.removeListener(_handleMessageFocusChanged); + _messageFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return HomeComposerStack( + selectedImages: widget.selectedImages, + onRemoveImage: widget.onRemoveImage, + isHoldToSpeakMode: _isHoldToSpeakMode, + isRecording: widget.isRecording, + isCancelGestureActive: widget.isCancelGestureActive, + isTranscribing: widget.isTranscribing, + isWaitingAgent: widget.isWaitingAgent, + messageController: widget.messageController, + messageFocusNode: _messageFocusNode, + onTapPlus: widget.onTapPlus, + onTapRightAction: _onRightActionTap, + onHoldToSpeakStart: widget.onHoldToSpeakStart, + onHoldToSpeakEnd: widget.onHoldToSpeakEnd, + onHoldToSpeakMoveUpdate: widget.onHoldToSpeakMoveUpdate, + onHoldToSpeakCancel: widget.onHoldToSpeakCancel, + onSubmit: _onSubmit, + keyboardInset: widget.keyboardInset, + ); + } + + void _onRightActionTap() { + if (widget.isTranscribing || widget.isRecording) { + return; + } + if (widget.isWaitingAgent) { + widget.onStopGenerating(); + return; + } + final draft = widget.messageController.text.trim(); + if (draft.isNotEmpty) { + _onSubmit(); + return; + } + _toggleInputMode(); + } + + void _toggleInputMode() { + if (widget.isRecording || widget.isTranscribing) { + return; + } + final switchToText = _isHoldToSpeakMode; + setState(() { + _isHoldToSpeakMode = !_isHoldToSpeakMode; + }); + if (!switchToText) { + _messageFocusNode.unfocus(); + _keyboardShowFallbackTimer?.cancel(); + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _isHoldToSpeakMode) { + return; + } + _messageFocusNode.requestFocus(); + }); + } + + void _handleMessageFocusChanged() { + if (!_messageFocusNode.hasFocus || _isHoldToSpeakMode) { + _keyboardShowFallbackTimer?.cancel(); + return; + } + _scheduleKeyboardShowFallback(); + } + + void _scheduleKeyboardShowFallback() { + if (!_supportsProgrammaticKeyboardShow() || _isKeyboardVisible()) { + return; + } + _keyboardShowFallbackTimer?.cancel(); + _keyboardShowFallbackTimer = Timer(const Duration(milliseconds: 120), () { + if (!mounted || !_messageFocusNode.hasFocus || _isHoldToSpeakMode) { + return; + } + if (_isKeyboardVisible()) { + return; + } + SystemChannels.textInput.invokeMethod('TextInput.show'); + }); + } + + bool _supportsProgrammaticKeyboardShow() { + if (kIsWeb) { + return false; + } + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + } + + bool _isKeyboardVisible() { + final mediaQuery = MediaQuery.maybeOf(context); + if (mediaQuery == null) { + return false; + } + return mediaQuery.viewInsets.bottom > 0; + } + + void _onSubmit() { + final draft = widget.messageController.text.trim(); + if (draft.isEmpty) { + return; + } + widget.onSubmitText(draft); + } +} diff --git a/apps/lib/features/settings/ui/screens/account_screen.dart b/apps/lib/features/settings/ui/screens/account_screen.dart index 7e7739d..c592571 100644 --- a/apps/lib/features/settings/ui/screens/account_screen.dart +++ b/apps/lib/features/settings/ui/screens/account_screen.dart @@ -49,12 +49,6 @@ class AccountScreen extends StatelessWidget { onTap: () => context.push('/edit-profile'), ), _buildDivider(), - _buildMenuItem( - icon: Icons.lock, - title: '修改密码', - onTap: () => context.push('/change-password'), - ), - _buildDivider(), _buildMenuItem( icon: Icons.logout, title: '退出登录', diff --git a/apps/lib/features/settings/ui/screens/change_password_screen.dart b/apps/lib/features/settings/ui/screens/change_password_screen.dart deleted file mode 100644 index 762f3bf..0000000 --- a/apps/lib/features/settings/ui/screens/change_password_screen.dart +++ /dev/null @@ -1,368 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:formz/formz.dart'; -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 '../../../auth/presentation/bloc/auth_bloc.dart'; -import '../../../auth/presentation/bloc/auth_state.dart'; -import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart'; -import '../../../../features/auth/data/auth_repository.dart'; -import '../widgets/account_section_card.dart'; -import '../widgets/settings_page_scaffold.dart'; - -class ChangePasswordScreen extends StatelessWidget { - const ChangePasswordScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ResetPasswordCubit(sl()), - child: const _ChangePasswordView(), - ); - } -} - -class _ChangePasswordView extends StatefulWidget { - const _ChangePasswordView(); - - @override - State<_ChangePasswordView> createState() => __ChangePasswordViewState(); -} - -class __ChangePasswordViewState extends State<_ChangePasswordView> { - final _codeController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _obscurePassword = true; - bool _obscureConfirmPassword = true; - - String _resolveUserEmail() { - final authState = context.read().state; - if (authState is AuthAuthenticated) { - return authState.user.email; - } - return ''; - } - - @override - void dispose() { - _codeController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - Future _handleSubmit() async { - final email = _resolveUserEmail(); - if (email.isEmpty) { - Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning); - return; - } - - final cubit = context.read(); - cubit.emailChanged(email); - cubit.codeChanged(_codeController.text); - cubit.newPasswordChanged(_passwordController.text); - cubit.confirmPasswordChanged(_confirmPasswordController.text); - - await cubit.submit(); - } - - @override - Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => - previous.status != current.status || - previous.errorMessage != current.errorMessage || - previous.codeSent != current.codeSent, - listener: (context, state) { - if (state.status == FormzSubmissionStatus.success && state.isSuccess) { - Toast.show(context, '密码修改成功', type: ToastType.success); - context.pop(); - } else if (state.status == FormzSubmissionStatus.success && - state.codeSent && - state.errorMessage == 'CODE_SENT_SUCCESS') { - Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success); - } else if (state.status == FormzSubmissionStatus.failure && - state.errorMessage != null && - state.errorMessage != '' && - state.errorMessage != 'CODE_SENT_SUCCESS') { - Toast.show(context, state.errorMessage!, type: ToastType.error); - } - }, - child: SettingsPageScaffold( - title: '修改密码', - onBack: () => context.pop(), - body: _buildForm(), - footer: BlocBuilder( - builder: (context, state) { - return _buildSubmitButton(state); - }, - ), - ), - ); - } - - Widget _buildForm() { - return BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildEmailSection(state, _resolveUserEmail()), - const SizedBox(height: AppSpacing.lg), - _buildPasswordSection( - state, - state.newPassword.displayError != null, - state.confirmPassword.displayError != null, - ), - ], - ); - }, - ); - } - - Widget _buildEmailSection(ResetPasswordState state, String userEmail) { - return AccountSectionCard( - title: '发送验证码', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - decoration: BoxDecoration( - color: AppColors.surfaceInfoLight, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.borderTertiary), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.email_outlined, - size: 20, - color: AppColors.blue600, - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - userEmail.isEmpty ? '未读取到登录邮箱' : userEmail, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.slate900, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - AppButton( - text: state.resendCountdown > 0 - ? '${state.resendCountdown} 秒后可重发' - : (state.codeSent ? '重新发送验证码' : '发送验证码'), - onPressed: - state.resendCountdown > 0 || - state.status == FormzSubmissionStatus.inProgress - ? null - : () { - if (userEmail.isEmpty) { - Toast.show( - context, - '未读取到登录邮箱,请重新登录后重试', - type: ToastType.warning, - ); - return; - } - if (state.codeSent) { - context.read().resendCode(); - } else { - context.read().emailChanged( - userEmail, - ); - context.read().sendCode(); - } - }, - isOutlined: state.codeSent, - ), - ], - ), - ); - } - - Widget _buildPasswordSection( - ResetPasswordState state, - bool passwordHasError, - bool confirmHasError, - ) { - if (!state.codeSent) { - return const SizedBox.shrink(); - } - - return AccountSectionCard( - title: '设置新密码', - backgroundColor: AppColors.white, - borderColor: AppColors.borderSecondary, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '验证码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.slate700, - ), - ), - const SizedBox(height: AppSpacing.sm), - 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().codeChanged(value); - }, - ), - const SizedBox(height: AppSpacing.lg), - _buildPasswordInput(passwordHasError), - const SizedBox(height: AppSpacing.lg), - _buildConfirmPasswordInput(confirmHasError), - ], - ), - ); - } - - Widget _buildPasswordInput(bool hasError) { - return _buildPasswordField( - label: '新密码', - controller: _passwordController, - hintText: '请输入新密码(至少 6 位)', - hasError: hasError, - isObscured: _obscurePassword, - onToggleVisibility: () => - setState(() => _obscurePassword = !_obscurePassword), - onChanged: (value) => - context.read().newPasswordChanged(value), - ); - } - - Widget _buildConfirmPasswordInput(bool hasError) { - return _buildPasswordField( - label: '确认密码', - controller: _confirmPasswordController, - hintText: '请再次输入新密码', - hasError: hasError, - isObscured: _obscureConfirmPassword, - onToggleVisibility: () => - setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), - onChanged: (value) => - context.read().confirmPasswordChanged(value), - ); - } - - Widget _buildPasswordField({ - required String label, - required TextEditingController controller, - required String hintText, - required bool hasError, - required bool isObscured, - required VoidCallback onToggleVisibility, - required ValueChanged onChanged, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate700, - ), - ), - const SizedBox(height: AppSpacing.sm), - TextField( - controller: controller, - obscureText: isObscured, - onChanged: onChanged, - decoration: InputDecoration( - hintText: hintText, - errorText: hasError ? ' ' : null, - filled: true, - fillColor: AppColors.surfaceSecondary, - hintStyle: const TextStyle(color: AppColors.slate400), - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.lg, - ), - suffixIcon: IconButton( - icon: Icon( - isObscured ? Icons.visibility_off : Icons.visibility, - size: 20, - color: AppColors.slate400, - ), - onPressed: onToggleVisibility, - ), - border: _inputBorder, - enabledBorder: _enabledBorder, - focusedBorder: _focusedBorder, - ), - ), - ], - ); - } - - static final _inputBorder = OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - borderSide: BorderSide.none, - ); - - static final _enabledBorder = OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - borderSide: const BorderSide(color: AppColors.borderTertiary), - ); - - static final _focusedBorder = OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - borderSide: const BorderSide(color: AppColors.blue500), - ); - - Widget _buildSubmitButton(ResetPasswordState state) { - final isLoading = state.status == FormzSubmissionStatus.inProgress; - final isDisabled = isLoading || !state.codeSent || !state.canSubmit; - - return SizedBox( - width: double.infinity, - height: 52, - child: AppButton( - text: '确认修改', - onPressed: isDisabled ? null : _handleSubmit, - isLoading: isLoading, - ), - ); - } -} diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index d37cc9b..8f516fc 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -122,6 +122,8 @@ class _EditProfileScreenState extends State { return SettingsPageScaffold( title: '编辑资料', onBack: () => context.pop(), + resizeOnKeyboard: false, + maintainBottomViewPadding: true, body: _isLoading ? const Center( child: AppLoadingIndicator(variant: AppLoadingVariant.surface), diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index cde877b..6b5f631 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/shared/widgets/app_loading_indicator.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; +import 'package:social_app/shared/utils/phone_display_formatter.dart'; import 'package:social_app/features/friends/data/friends_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; @@ -98,7 +99,9 @@ class _SettingsScreenState extends State { } final username = _user?.username ?? '未设置'; - final email = _user?.email ?? '未设置'; + final phone = _user?.phone == null + ? '未设置' + : formatPhoneForDisplay(_user?.phone); return Container( width: double.infinity, @@ -195,7 +198,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 6), Text( - email, + phone, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, diff --git a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart index 3fd5afa..5f2a300 100644 --- a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart +++ b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart @@ -10,18 +10,24 @@ class SettingsPageScaffold extends StatelessWidget { required this.body, this.footer, this.onBack, + this.resizeOnKeyboard = true, + this.maintainBottomViewPadding = false, }); final String title; final Widget body; final Widget? footer; final VoidCallback? onBack; + final bool resizeOnKeyboard; + final bool maintainBottomViewPadding; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.surfaceSecondary, + resizeToAvoidBottomInset: resizeOnKeyboard, body: SafeArea( + maintainBottomViewPadding: maintainBottomViewPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/apps/lib/shared/utils/phone_display_formatter.dart b/apps/lib/shared/utils/phone_display_formatter.dart new file mode 100644 index 0000000..5ca3f7b --- /dev/null +++ b/apps/lib/shared/utils/phone_display_formatter.dart @@ -0,0 +1,68 @@ +String formatPhoneForDisplay(String? rawPhone) { + final normalized = _normalizePhone(rawPhone); + if (normalized == null) { + return rawPhone?.trim() ?? ''; + } + + if (normalized.startsWith('+86') && normalized.length == 14) { + final local = normalized.substring(3); + return '${local.substring(0, 3)}****${local.substring(7)}'; + } + + if (!normalized.startsWith('+')) { + return normalized; + } + final digits = normalized.substring(1); + final countryCode = _detectCountryCode(digits); + if (countryCode == null) { + return normalized; + } + final localNumber = digits.substring(countryCode.length); + if (localNumber.length <= 4) { + return '+$countryCode $localNumber'; + } + final tail = localNumber.substring(localNumber.length - 4); + return '+$countryCode ****$tail'; +} + +String? _normalizePhone(String? rawPhone) { + if (rawPhone == null) { + return null; + } + var phone = rawPhone.trim(); + for (final separator in const [' ', '-', '(', ')']) { + phone = phone.replaceAll(separator, ''); + } + if (phone.isEmpty) { + return null; + } + if (phone.startsWith('00') && phone.length > 2) { + phone = '+${phone.substring(2)}'; + } + if (!phone.startsWith('+') && RegExp(r'^\d+$').hasMatch(phone)) { + phone = '+$phone'; + } + return phone; +} + +String? _detectCountryCode(String digits) { + const knownCodes = ['86', '1', '44', '81', '65', '33']; + for (final code in knownCodes) { + if (digits.startsWith(code) && digits.length > code.length + 3) { + return code; + } + } + for (int length = 3; length >= 1; length--) { + if (length >= digits.length) { + continue; + } + final candidate = digits.substring(0, length); + if (candidate.startsWith('0')) { + continue; + } + if (digits.length - length >= 4) { + return candidate; + } + } + return null; +} diff --git a/apps/lib/shared/widgets/confirm_sheet.dart b/apps/lib/shared/widgets/confirm_sheet.dart new file mode 100644 index 0000000..bae0c6a --- /dev/null +++ b/apps/lib/shared/widgets/confirm_sheet.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; +import 'app_button.dart'; + +Future showConfirmSheet( + BuildContext context, { + required String title, + required String message, + String confirmText = '确认', + String cancelText = '取消', + bool isDestructive = false, +}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.none, + AppSpacing.md, + AppSpacing.md, + ), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: AppColors.slate500), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: 52, + child: GestureDetector( + onTap: () => Navigator.of(sheetContext).pop(true), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDestructive + ? AppColors.feedbackErrorIcon + : AppColors.blue600, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + confirmText, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.white, + ), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 52, + child: AppButton( + text: cancelText, + isOutlined: true, + onPressed: () => Navigator.of(sheetContext).pop(false), + ), + ), + ], + ), + ), + ); + }, + ); + return result == true; +} diff --git a/apps/lib/shared/widgets/phone_prefix_selector.dart b/apps/lib/shared/widgets/phone_prefix_selector.dart new file mode 100644 index 0000000..0ac85a3 --- /dev/null +++ b/apps/lib/shared/widgets/phone_prefix_selector.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/design_tokens.dart'; + +class PhonePrefixSelector extends StatelessWidget { + const PhonePrefixSelector({ + super.key, + required this.value, + required this.items, + this.onChanged, + }); + + final String value; + final List items; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.sm), + child: PopupMenuButton( + onSelected: onChanged, + itemBuilder: (context) => items + .map( + (item) => PopupMenuItem(value: item, child: Text(item)), + ) + .toList(growable: false), + color: AppColors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.slate700, + ), + ), + const SizedBox(width: AppSpacing.xs), + const Icon( + Icons.arrow_drop_down, + size: 18, + color: AppColors.slate500, + ), + ], + ), + ), + ); + } +} diff --git a/apps/test/features/chat/data/services/ag_ui_service_test.dart b/apps/test/features/chat/data/services/ag_ui_service_test.dart index 2752ff1..b95371d 100644 --- a/apps/test/features/chat/data/services/ag_ui_service_test.dart +++ b/apps/test/features/chat/data/services/ag_ui_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/core/api/i_api_client.dart'; @@ -5,9 +7,15 @@ import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; class _FakeApiClient implements IApiClient { - _FakeApiClient({required this.sseLines}); + _FakeApiClient({ + required this.sseLines, + this.sseLineStreamFactory, + this.runIdFactory, + }); final List sseLines; + final Stream Function()? sseLineStreamFactory; + final String Function()? runIdFactory; @override Future> delete(String path, {data, Options? options}) { @@ -24,6 +32,10 @@ class _FakeApiClient implements IApiClient { String path, { Map? headers, }) async { + final streamFactory = sseLineStreamFactory; + if (streamFactory != null) { + return streamFactory(); + } return Stream.fromIterable(sseLines); } @@ -34,10 +46,11 @@ class _FakeApiClient implements IApiClient { @override Future> post(String path, {data, Options? options}) async { + final runIdFactory = this.runIdFactory; final payload = { 'taskId': 'task-1', 'threadId': 'thread-1', - 'runId': 'run-new', + 'runId': runIdFactory != null ? runIdFactory() : 'run-new', 'created': true, }; return Response( @@ -149,4 +162,110 @@ void main() { expect(events[2], isA()); }, ); + + test('cancelCurrentRun actively closes current SSE subscription', () async { + var streamCancelled = false; + final streamController = StreamController( + onCancel: () { + streamCancelled = true; + }, + ); + + final service = AgUiService( + apiClient: _FakeApiClient( + sseLines: const [], + sseLineStreamFactory: () => streamController.stream, + ), + ); + + final sendFuture = service.sendMessage('hello'); + await Future.delayed(Duration.zero); + await service.cancelCurrentRun(); + + await sendFuture; + expect(streamCancelled, isTrue); + await streamController.close(); + }); + + test( + 'new sendMessage cancels previous SSE subscription explicitly', + () async { + var firstStreamCancelled = false; + final firstController = StreamController( + onCancel: () { + firstStreamCancelled = true; + }, + ); + final secondController = StreamController(); + final streamQueue = >[ + firstController, + secondController, + ]; + var streamIndex = 0; + var runIndex = 0; + + final service = AgUiService( + apiClient: _FakeApiClient( + sseLines: const [], + sseLineStreamFactory: () => streamQueue[streamIndex++].stream, + runIdFactory: () { + runIndex += 1; + return 'run-$runIndex'; + }, + ), + ); + + final firstSendFuture = service.sendMessage('first'); + await Future.delayed(Duration.zero); + final secondSendFuture = service.sendMessage('second'); + + await Future.delayed(Duration.zero); + for (final line in _buildSseEvent( + id: '21', + type: AgUiEventTypeWire.runStarted, + payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}', + )) { + secondController.add(line); + } + for (final line in _buildSseEvent( + id: '22', + type: AgUiEventTypeWire.runFinished, + payload: + '{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}', + )) { + secondController.add(line); + } + await secondController.close(); + + await firstSendFuture; + await secondSendFuture; + + expect(firstStreamCancelled, isTrue); + await firstController.close(); + }, + ); + + test('sendMessage surfaces event callback exceptions', () async { + final service = AgUiService( + apiClient: _FakeApiClient( + sseLines: [ + ..._buildSseEvent( + id: '31', + type: AgUiEventTypeWire.runStarted, + payload: + '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}', + ), + ..._buildSseEvent( + id: '32', + type: AgUiEventTypeWire.runFinished, + payload: + '{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}', + ), + ], + ), + ); + service.onEvent = (_) => throw StateError('event callback failed'); + + await expectLater(service.sendMessage('hello'), throwsA(isA())); + }); } diff --git a/apps/test/features/home/ui/widgets/home_background_field_test.dart b/apps/test/features/home/ui/widgets/home_background_field_test.dart deleted file mode 100644 index 51552b3..0000000 --- a/apps/test/features/home/ui/widgets/home_background_field_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/home/ui/widgets/home_background_field.dart'; - -void main() { - testWidgets('home background field renders layered glow surfaces', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp(home: Scaffold(body: HomeBackgroundField())), - ); - - expect(find.byKey(homeBackgroundFieldKey), findsOneWidget); - expect(find.byKey(homeTopGlowKey), findsOneWidget); - expect(find.byKey(homeBottomGlowKey), findsOneWidget); - }); -} diff --git a/apps/test/features/home/ui/widgets/home_composer_test.dart b/apps/test/features/home/ui/widgets/home_composer_test.dart deleted file mode 100644 index 733ebde..0000000 --- a/apps/test/features/home/ui/widgets/home_composer_test.dart +++ /dev/null @@ -1,266 +0,0 @@ -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/core/theme/design_tokens.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); - expect(find.byKey(messageComposerShellKey), findsOneWidget); - expect(find.byKey(messageComposerInnerKey), findsOneWidget); - - final containerFinder = find.byKey(messageComposerContainerKey); - final shellFinder = find.byKey(messageComposerShellKey); - 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, - ); - - final container = tester.widget(shellFinder); - final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, AppColors.homeComposerShell); - expect( - (decoration.border! as Border).top.color, - AppColors.homeComposerBorder, - ); - }); - - testWidgets('recording state keeps unified floating shell', (tester) async { - await tester.pumpWidget( - _buildTestApp( - mode: MessageComposerMode.holdToSpeak, - process: MessageComposerProcess.recording, - hasMessage: false, - isWaitingAgent: false, - ), - ); - - expect(find.byKey(messageComposerShellKey), findsOneWidget); - expect(find.byKey(messageComposerInnerKey), findsOneWidget); - expect(find.text('松开发送'), findsOneWidget); - }); - - testWidgets('right action icon follows state priority', (tester) async { - Future 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), - ); - expect(iconFinder, findsOneWidget); - final iconWidget = tester.widget(iconFinder.first); - expect(iconWidget.icon, isNotNull); - 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); - }); - }); -} diff --git a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart index 637f8ff..4e3817e 100644 --- a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -195,9 +195,7 @@ void main() { }, ); - testWidgets('switching to text mode does not auto focus input', ( - tester, - ) async { + testWidgets('switching to text mode auto focuses input', (tester) async { await pumpHomeScreen(tester); await tester.tap(find.byKey(messageComposerRightButtonKey)); @@ -205,12 +203,10 @@ void main() { await tester.pump(); final editable = tester.widget(find.byType(EditableText)); - expect(editable.focusNode.hasFocus, isFalse); + expect(editable.focusNode.hasFocus, isTrue); }); - testWidgets('single tap on input focuses text field after mode switch', ( - tester, - ) async { + testWidgets('single tap on input keeps text field focused', (tester) async { await pumpHomeScreen(tester); await tester.tap(find.byKey(messageComposerRightButtonKey)); @@ -224,7 +220,9 @@ void main() { expect(editable.focusNode.hasFocus, isTrue); }); - testWidgets('tap focused input triggers keyboard show once', (tester) async { + testWidgets('switching to text mode triggers keyboard show fallback', ( + tester, + ) async { var showCalls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.textInput, (call) async { @@ -242,15 +240,92 @@ void main() { await tester.tap(find.byKey(messageComposerRightButtonKey)); await tester.pump(); await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); - await tester.tap(find.byType(EditableText)); + expect(showCalls, greaterThanOrEqualTo(1)); + }); + + testWidgets('tap center of input lane focuses text field', (tester) async { + await pumpHomeScreen(tester); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); await tester.pump(); + await tester.pump(); + + final composerRect = tester.getRect(find.byKey(messageComposerInnerKey)); + final centerLaneTap = Offset( + composerRect.left + composerRect.width * 0.5, + composerRect.center.dy, + ); + + await tester.tapAt(centerLaneTap); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + }); + + testWidgets('tap focused input triggers at most one keyboard show', ( + tester, + ) async { + var showCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.textInput, (call) async { + if (call.method == 'TextInput.show') { + showCalls += 1; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.textInput, null); + }); + + await pumpHomeScreen(tester); + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); + showCalls = 0; - await tester.tap(find.byType(EditableText)); await tester.pump(); - expect(showCalls, 1); + expect(showCalls, lessThanOrEqualTo(1)); + }); + + testWidgets('double toggle returns to hold-to-speak mode', (tester) async { + await pumpHomeScreen(tester); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + expect(find.byType(EditableText), findsOneWidget); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + + expect(find.byType(EditableText), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('rapid triple toggle ends in text mode with focused input', ( + tester, + ) async { + await pumpHomeScreen(tester); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + expect(tester.takeException(), isNull); }); testWidgets('release during delayed start continues to transcribe path', ( diff --git a/apps/test/features/settings/ui/screens/change_password_screen_test.dart b/apps/test/features/settings/ui/screens/change_password_screen_test.dart deleted file mode 100644 index 29593b9..0000000 --- a/apps/test/features/settings/ui/screens/change_password_screen_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/core/di/injection.dart'; -import 'package:social_app/features/auth/data/auth_repository.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; -import 'package:social_app/features/settings/ui/screens/change_password_screen.dart'; - -class MockAuthRepository extends Mock implements AuthRepository {} - -void main() { - late MockAuthRepository mockAuthRepository; - late AuthBloc authBloc; - - setUp(() async { - mockAuthRepository = MockAuthRepository(); - await sl.reset(); - sl.registerSingleton(mockAuthRepository); - - authBloc = AuthBloc(mockAuthRepository); - authBloc.add( - const AuthLoggedIn( - user: AuthUser(id: 'user-1', email: 'tester@example.com'), - ), - ); - }); - - tearDown(() async { - await authBloc.close(); - await sl.reset(); - }); - - Future pumpScreen(WidgetTester tester) async { - await tester.pumpWidget( - BlocProvider.value( - value: authBloc, - child: const MaterialApp(home: ChangePasswordScreen()), - ), - ); - await tester.pump(); - } - - testWidgets('确认修改按钮在验证码发送前不可点击', (tester) async { - when( - () => mockAuthRepository.requestPasswordReset(any()), - ).thenAnswer((_) async {}); - - await pumpScreen(tester); - - final confirmButton = tester.widget( - find.widgetWithText(ElevatedButton, '确认修改'), - ); - expect(confirmButton.onPressed, isNull); - expect(find.text('设置新密码'), findsNothing); - }); - - testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async { - final completer = Completer(); - when( - () => mockAuthRepository.requestPasswordReset(any()), - ).thenAnswer((_) => completer.future); - - await pumpScreen(tester); - - await tester.tap(find.widgetWithText(ElevatedButton, '发送验证码')); - await tester.pump(); - - expect(find.text('60 秒后可重发'), findsOneWidget); - expect(find.text('设置新密码'), findsOneWidget); - - verify( - () => mockAuthRepository.requestPasswordReset('tester@example.com'), - ).called(1); - - completer.complete(); - }); -} diff --git a/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart b/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart deleted file mode 100644 index 2d54bf8..0000000 --- a/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/settings/ui/widgets/account_section_card.dart'; -import 'package:social_app/features/settings/ui/widgets/settings_page_scaffold.dart'; - -void main() { - testWidgets('AccountSectionCard renders title and description', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: AccountSectionCard( - title: '基础信息', - description: '请填写公开展示资料', - child: Text('内容区'), - ), - ), - ), - ); - - expect(find.text('基础信息'), findsOneWidget); - expect(find.text('请填写公开展示资料'), findsOneWidget); - expect(find.text('内容区'), findsOneWidget); - }); - - testWidgets('SettingsPageScaffold renders header and footer', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: SettingsPageScaffold( - title: '编辑资料', - body: const Text('主体内容'), - footer: const Text('底部操作区'), - onBack: () {}, - ), - ), - ); - - expect(find.text('编辑资料'), findsOneWidget); - expect(find.text('主体内容'), findsOneWidget); - expect(find.text('底部操作区'), findsOneWidget); - }); - - testWidgets('SettingsPageScaffold renders body without footer', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: SettingsPageScaffold( - title: '账户', - body: const Text('主体内容'), - onBack: () {}, - ), - ), - ); - - expect(find.text('账户'), findsOneWidget); - expect(find.text('主体内容'), findsOneWidget); - expect(find.text('底部操作区'), findsNothing); - }); -} diff --git a/apps/test/shared/utils/phone_display_formatter_test.dart b/apps/test/shared/utils/phone_display_formatter_test.dart new file mode 100644 index 0000000..5bc59ba --- /dev/null +++ b/apps/test/shared/utils/phone_display_formatter_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/shared/utils/phone_display_formatter.dart'; + +void main() { + group('formatPhoneForDisplay', () { + test('formats +86 numbers as local masked style', () { + final formatted = formatPhoneForDisplay('+8613812345678'); + + expect(formatted, '138****5678'); + }); + + test('keeps international country code while masking middle part', () { + final formatted = formatPhoneForDisplay('+14155552671'); + + expect(formatted, '+1 ****2671'); + }); + + test('normalizes separators before formatting', () { + final formatted = formatPhoneForDisplay('(+86) 138-1234-5678'); + + expect(formatted, '138****5678'); + }); + + test('prefers longer country code in fallback detection', () { + final formatted = formatPhoneForDisplay('+33612345678'); + + expect(formatted, '+33 ****5678'); + }); + }); +}