feat(apps): update UI screens and shared components

- Update home screen with new composer and interactions
- Update settings screens with new profile flow
- Update calendar share dialog
- Update contacts screen
- Add new shared widgets: confirm_sheet, phone_prefix_selector
- Add new formatters: phone_display_formatter
- Update tests for modified components
This commit is contained in:
qzl
2026-03-19 18:43:08 +08:00
parent f0af44d840
commit 8d4a14150b
24 changed files with 868 additions and 989 deletions
@@ -57,7 +57,7 @@ class CalendarApi {
Future<void> share( Future<void> share(
String itemId, { String itemId, {
required String email, required String phone,
bool view = true, bool view = true,
bool edit = false, bool edit = false,
bool invite = false, bool invite = false,
@@ -65,7 +65,7 @@ class CalendarApi {
await _client.post( await _client.post(
'$_prefix/$itemId/share', '$_prefix/$itemId/share',
data: { data: {
'email': email, 'phone': phone,
'permission_view': view, 'permission_view': view,
'permission_edit': edit, 'permission_edit': edit,
'permission_invite': invite, 'permission_invite': invite,
@@ -46,7 +46,7 @@ class CalendarShareDialog extends StatefulWidget {
} }
class _CalendarShareDialogState extends State<CalendarShareDialog> { class _CalendarShareDialogState extends State<CalendarShareDialog> {
final _emailController = TextEditingController(); final _phoneController = TextEditingController();
bool _permissionView = true; bool _permissionView = true;
bool _permissionEdit = false; bool _permissionEdit = false;
bool _permissionInvite = false; bool _permissionInvite = false;
@@ -54,14 +54,14 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
@override @override
void dispose() { void dispose() {
_emailController.dispose(); _phoneController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _handleShare() async { Future<void> _handleShare() async {
final email = _emailController.text.trim(); final phone = _phoneController.text.trim();
if (email.isEmpty) { if (phone.isEmpty) {
Toast.show(context, '请输入邮箱地址', type: ToastType.error); Toast.show(context, '请输入手机号', type: ToastType.error);
return; return;
} }
@@ -71,7 +71,7 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
final api = sl<CalendarApi>(); final api = sl<CalendarApi>();
await api.share( await api.share(
widget.eventId, widget.eventId,
email: email, phone: phone,
view: _permissionView, view: _permissionView,
edit: _permissionEdit, edit: _permissionEdit,
invite: _permissionInvite, invite: _permissionInvite,
@@ -127,15 +127,15 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
Text(widget.eventTitle, style: const TextStyle(fontSize: 16)), Text(widget.eventTitle, style: const TextStyle(fontSize: 16)),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
TextField( TextField(
controller: _emailController, controller: _phoneController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '邮箱地址', labelText: '手机号',
hintText: '输入对方的邮箱', hintText: '输入对方的 +86 手机号',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
), ),
), ),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.phone,
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)), const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)),
@@ -46,6 +46,8 @@ class AgUiService {
EventCallback onEvent; EventCallback onEvent;
final Map<String, String> _lastEventIdByThread = {}; final Map<String, String> _lastEventIdByThread = {};
int _activeStreamToken = 0; int _activeStreamToken = 0;
StreamSubscription<String>? _activeSseSubscription;
Completer<void>? _activeSseDoneCompleter;
String? _threadId; String? _threadId;
bool _hasMoreHistory = false; bool _hasMoreHistory = false;
@@ -58,6 +60,7 @@ class AgUiService {
String content, { String content, {
List<XFile>? images, List<XFile>? images,
}) async { }) async {
await _cancelActiveSseSubscription();
final streamToken = ++_activeStreamToken; final streamToken = ++_activeStreamToken;
final runInputPayload = await _buildRunInput( final runInputPayload = await _buildRunInput(
content: content, content: content,
@@ -149,6 +152,21 @@ class AgUiService {
Future<void> cancelCurrentRun() async { Future<void> cancelCurrentRun() async {
_activeStreamToken += 1; _activeStreamToken += 1;
await _cancelActiveSseSubscription();
}
Future<void> _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<void> _streamEventsFromApi( Future<void> _streamEventsFromApi(
@@ -170,80 +188,129 @@ class AgUiService {
String? eventId; String? eventId;
var hasBoundExpectedRun = false; var hasBoundExpectedRun = false;
final dataBuffer = StringBuffer(); final dataBuffer = StringBuffer();
await for (final line in sseLines) { final done = Completer<void>();
if (streamToken != _activeStreamToken) { late final StreamSubscription<String> subscription;
break;
void stopStream({Object? error, StackTrace? stackTrace}) {
if (!done.isCompleted) {
if (error == null) {
done.complete();
} else {
done.completeError(error, stackTrace);
}
} }
if (line.isEmpty) { unawaited(subscription.cancel());
if (dataBuffer.isNotEmpty) { }
final raw = dataBuffer.toString();
dataBuffer.clear();
Map<String, dynamic>? decoded;
String? eventRunId;
String? eventThreadId;
try {
final parsed = jsonDecode(raw);
if (parsed is Map<String, dynamic>) {
decoded = parsed;
final runId = parsed['runId'];
final thread = parsed['threadId'];
eventRunId = runId is String ? runId : null;
eventThreadId = thread is String ? thread : null;
final isRunStarted = eventType == AgUiEventTypeWire.runStarted; subscription = sseLines.listen(
final isTargetRun = eventRunId == expectedRunId; (line) {
if (isRunStarted && isTargetRun) { try {
hasBoundExpectedRun = true; if (streamToken != _activeStreamToken) {
stopStream();
return;
}
if (line.isEmpty) {
if (dataBuffer.isNotEmpty) {
final raw = dataBuffer.toString();
dataBuffer.clear();
String? eventRunId;
String? eventThreadId;
Map<String, dynamic>? parsedMap;
try {
final parsed = jsonDecode(raw);
if (parsed is Map<String, dynamic>) {
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 = final isThreadMatched =
eventThreadId == null || eventThreadId == threadId; eventThreadId == null || eventThreadId == threadId;
final shouldDispatch = if (isTerminalEvent &&
isTargetRun || (hasBoundExpectedRun && isThreadMatched); (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
if (shouldDispatch) { stopStream();
final event = AgUiEvent.fromJson(parsed); return;
onEvent(event);
} }
} }
} catch (_) { eventType = null;
// Ignore malformed SSE payload and keep stream alive. eventId = null;
return;
} }
final currentEventId = eventId; if (line.startsWith(':')) {
if (currentEventId != null && currentEventId.isNotEmpty) { return;
_lastEventIdByThread[threadId] = currentEventId;
} }
final isTerminalEvent = if (line.startsWith('id:')) {
eventType == AgUiEventTypeWire.runFinished || eventId = line.substring(3).trim();
eventType == AgUiEventTypeWire.runError; return;
final isTargetRun = eventRunId == expectedRunId;
final isThreadMatched =
eventThreadId == null || eventThreadId == threadId;
if (isTerminalEvent &&
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
break;
} }
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; onError: (Object error, StackTrace stackTrace) {
continue; 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(':')) { if (identical(_activeSseDoneCompleter, done)) {
continue; _activeSseDoneCompleter = null;
}
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);
} }
} }
} }
@@ -18,7 +18,7 @@ class AddContactScreen extends StatefulWidget {
class _AddContactScreenState extends State<AddContactScreen> { class _AddContactScreenState extends State<AddContactScreen> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _emailController = TextEditingController(); final _phoneController = TextEditingController();
final _remarkController = TextEditingController(); final _remarkController = TextEditingController();
bool get isEditing => widget.contactId != null; bool get isEditing => widget.contactId != null;
@@ -26,7 +26,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_emailController.dispose(); _phoneController.dispose();
_remarkController.dispose(); _remarkController.dispose();
super.dispose(); super.dispose();
} }
@@ -35,7 +35,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.surfaceSecondary, backgroundColor: AppColors.surfaceSecondary,
resizeToAvoidBottomInset: false,
body: SafeArea( body: SafeArea(
maintainBottomViewPadding: true,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -122,10 +124,10 @@ class _AddContactScreenState extends State<AddContactScreen> {
AppInput(label: '昵称', hint: '请输入昵称', controller: _nameController), AppInput(label: '昵称', hint: '请输入昵称', controller: _nameController),
const SizedBox(height: 14), const SizedBox(height: 14),
AppInput( AppInput(
label: '邮箱', label: '手机号',
hint: '请输入邮箱', hint: '+86 请输入 11 位手机号',
controller: _emailController, controller: _phoneController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
AppInput( AppInput(
@@ -152,10 +154,10 @@ class _AddContactScreenState extends State<AddContactScreen> {
void _handleConfirm() { void _handleConfirm() {
final name = _nameController.text.trim(); final name = _nameController.text.trim();
final email = _emailController.text.trim(); final phone = _phoneController.text.trim();
if (name.isEmpty || email.isEmpty) { if (name.isEmpty || phone.isEmpty) {
Toast.show(context, '请填写昵称和邮箱', type: ToastType.warning); Toast.show(context, '请填写昵称和手机号', type: ToastType.warning);
return; return;
} }
@@ -63,21 +63,11 @@ class _ContactsScreenState extends State<ContactsScreen> {
} }
} }
bool _isValidEmail(String email) {
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
return emailRegex.hasMatch(email);
}
Future<void> _onSearch() async { Future<void> _onSearch() async {
final query = _searchController.text.trim(); final query = _searchController.text.trim();
if (query.isEmpty) { if (query.isEmpty) {
Toast.show(context, '请输入邮箱地址', type: ToastType.warning); Toast.show(context, '请输入用户名或手机号', type: ToastType.warning);
return;
}
if (!_isValidEmail(query)) {
Toast.show(context, '请输入有效的邮箱地址', type: ToastType.warning);
return; return;
} }
@@ -265,7 +255,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.surfaceSecondary, backgroundColor: AppColors.surfaceSecondary,
resizeToAvoidBottomInset: false,
body: SafeArea( body: SafeArea(
maintainBottomViewPadding: true,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -323,7 +315,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: '输入邮箱搜索用户', hintText: '输入用户名或手机号',
hintStyle: TextStyle( hintStyle: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -341,7 +333,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
), ),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.text,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: (_) => _onSearch(), onSubmitted: (_) => _onSearch(),
onChanged: (value) { onChanged: (value) {
@@ -562,7 +554,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'搜索邮箱添加好友开始聊天吧', '搜索手机号添加好友开始聊天吧',
style: TextStyle(fontSize: 13, color: AppColors.slate400), style: TextStyle(fontSize: 13, color: AppColors.slate400),
), ),
], ],
@@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,9 +23,9 @@ import '../../../../shared/widgets/full_screen_loading.dart';
import 'home_sheet.dart'; import 'home_sheet.dart';
import '../widgets/home_background_field.dart'; import '../widgets/home_background_field.dart';
import '../widgets/home_chat_item_renderer.dart'; import '../widgets/home_chat_item_renderer.dart';
import '../widgets/home_composer_stack.dart';
import '../widgets/home_conversation_chrome.dart'; import '../widgets/home_conversation_chrome.dart';
import '../widgets/home_floating_header.dart'; import '../widgets/home_floating_header.dart';
import '../widgets/home_input_host.dart';
import '../widgets/home_recording_overlay.dart'; import '../widgets/home_recording_overlay.dart';
import '../widgets/home_unread_badge.dart'; import '../widgets/home_unread_badge.dart';
@@ -72,7 +71,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware { with SingleTickerProviderStateMixin, RouteAware {
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
final FocusNode _messageFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc; late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder; late final VoiceRecorder _voiceRecorder;
@@ -81,7 +79,6 @@ class _HomeScreenState extends State<HomeScreen>
late final AnimationController _listeningAnimationController; late final AnimationController _listeningAnimationController;
bool _isRecording = false; bool _isRecording = false;
bool _isRecordingStarting = false; bool _isRecordingStarting = false;
bool _isHoldToSpeakMode = true;
bool _isTranscribing = false; bool _isTranscribing = false;
bool _isCancelGestureActive = false; bool _isCancelGestureActive = false;
bool _shouldCancelWhenStartCompletes = false; bool _shouldCancelWhenStartCompletes = false;
@@ -101,6 +98,9 @@ class _HomeScreenState extends State<HomeScreen>
bool _routeAwareSubscribed = false; bool _routeAwareSubscribed = false;
double? _historyViewportPixels; double? _historyViewportPixels;
double? _historyViewportMaxExtent; double? _historyViewportMaxExtent;
final GlobalKey<HomeInputHostState> _inputHostKey =
GlobalKey<HomeInputHostState>();
double _stableKeyboardInset = 0;
@override @override
void initState() { void initState() {
@@ -143,7 +143,6 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void dispose() { void dispose() {
_messageController.dispose(); _messageController.dispose();
_messageFocusNode.dispose();
_scrollController.removeListener(_handleScrollChanged); _scrollController.removeListener(_handleScrollChanged);
_scrollController.dispose(); _scrollController.dispose();
_listeningAnimationController.dispose(); _listeningAnimationController.dispose();
@@ -222,16 +221,22 @@ class _HomeScreenState extends State<HomeScreen>
builder: (context, state) { builder: (context, state) {
return Scaffold( return Scaffold(
backgroundColor: _chatBgColor, backgroundColor: _chatBgColor,
resizeToAvoidBottomInset: false,
body: SafeArea( body: SafeArea(
maintainBottomViewPadding: true,
child: Stack( child: Stack(
children: [ children: [
const Positioned.fill(child: HomeBackgroundField()), Positioned.fill(child: HomeBackgroundField()),
Column( GestureDetector(
crossAxisAlignment: CrossAxisAlignment.stretch, behavior: HitTestBehavior.translucent,
children: [ onTap: _dismissKeyboard,
_buildHeader(context), child: Column(
Expanded(child: _buildChatArea(context, state)), crossAxisAlignment: CrossAxisAlignment.stretch,
], children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
],
),
), ),
if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(), if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(),
_buildBottomInputStack(context, state), _buildBottomInputStack(context, state),
@@ -261,17 +266,18 @@ class _HomeScreenState extends State<HomeScreen>
Widget _buildChatArea(BuildContext context, ChatState state) { Widget _buildChatArea(BuildContext context, ChatState state) {
final showWaitingIndicator = _isAgentWaiting(state); final showWaitingIndicator = _isAgentWaiting(state);
final inputBottomInset = _effectiveKeyboardInset(context);
if (state.isLoadingHistory && state.items.isEmpty) { if (state.isLoadingHistory && state.items.isEmpty) {
return const FullScreenLoading(); return const FullScreenLoading();
} }
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
_defaultPadding, _defaultPadding,
0, 0,
_defaultPadding, _defaultPadding,
_bottomStackReservedHeight, _bottomStackReservedHeight + inputBottomInset,
), ),
child: KeyedSubtree( child: KeyedSubtree(
key: homeConversationStageKey, key: homeConversationStageKey,
@@ -286,6 +292,8 @@ class _HomeScreenState extends State<HomeScreen>
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: AppSpacing.sm, top: AppSpacing.sm,
bottom: showWaitingIndicator bottom: showWaitingIndicator
@@ -349,9 +357,10 @@ class _HomeScreenState extends State<HomeScreen>
} }
Widget _buildUnreadBadge() { Widget _buildUnreadBadge() {
final inputBottomInset = _effectiveKeyboardInset(context);
return Positioned( return Positioned(
right: _defaultPadding, right: _defaultPadding,
bottom: _bottomStackReservedHeight + AppSpacing.md, bottom: _bottomStackReservedHeight + AppSpacing.md + inputBottomInset,
child: HomeUnreadBadge( child: HomeUnreadBadge(
count: _chatUnreadBadgeCount, count: _chatUnreadBadgeCount,
onTap: () { onTap: () {
@@ -524,12 +533,32 @@ class _HomeScreenState extends State<HomeScreen>
return 0; return 0;
} }
final position = _scrollController.position; final position = _scrollController.position;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom; final keyboardInset = _effectiveKeyboardInset(context);
return (position.maxScrollExtent - position.pixels - bottomInset) return (position.maxScrollExtent - position.pixels - keyboardInset)
.clamp(0, double.infinity) .clamp(0, double.infinity)
.toDouble(); .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) { void _applyViewportDecision(ViewportDecision decision) {
switch (decision.action) { switch (decision.action) {
case ViewportAction.jumpBottom: case ViewportAction.jumpBottom:
@@ -589,26 +618,26 @@ class _HomeScreenState extends State<HomeScreen>
Widget _buildBottomInputStack(BuildContext context, ChatState state) { Widget _buildBottomInputStack(BuildContext context, ChatState state) {
final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state);
return HomeComposerStack( final inputBottomInset = _effectiveKeyboardInset(context);
return HomeInputHost(
key: _inputHostKey,
selectedImages: _selectedImages, selectedImages: _selectedImages,
onRemoveImage: _removeImage, onRemoveImage: _removeImage,
isHoldToSpeakMode: _isHoldToSpeakMode,
isRecording: _isRecording, isRecording: _isRecording,
isCancelGestureActive: _isCancelGestureActive, isCancelGestureActive: _isCancelGestureActive,
isTranscribing: _isTranscribing, isTranscribing: _isTranscribing,
isWaitingAgent: isWaitingAgent, isWaitingAgent: isWaitingAgent,
messageController: _messageController, messageController: _messageController,
messageFocusNode: _messageFocusNode,
onTapPlus: _isRecording onTapPlus: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: false) ? () => _stopRecording(autoSendAfterTranscribe: false)
: () => _showBottomSheet(context), : () => _showBottomSheet(context),
onTapRightAction: () => _onRightActionTap(context, state), onStopGenerating: _onStopGenerating,
onHoldToSpeakStart: _onHoldToSpeakStart, onHoldToSpeakStart: _onHoldToSpeakStart,
onHoldToSpeakEnd: _onHoldToSpeakEnd, onHoldToSpeakEnd: _onHoldToSpeakEnd,
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
onHoldToSpeakCancel: _onHoldToSpeakCancel, onHoldToSpeakCancel: _onHoldToSpeakCancel,
onTextFieldTap: _onTextFieldTap, onSubmitText: (text) => _sendMessage(context, overrideContent: text),
onSubmit: () => _sendMessage(context), keyboardInset: inputBottomInset,
); );
} }
@@ -618,54 +647,6 @@ class _HomeScreenState extends State<HomeScreen>
}); });
} }
void _onTextFieldTap() {
final alreadyFocused = _messageFocusNode.hasFocus;
if (!alreadyFocused) {
_messageFocusNode.requestFocus();
return;
}
if (!_supportsProgrammaticKeyboardShow()) {
return;
}
SystemChannels.textInput.invokeMethod<void>('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() { void _onHoldToSpeakStart() {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() { setState(() {
@@ -27,17 +27,21 @@ extension _HomeScreenInteractions on _HomeScreenState {
} }
} }
Future<void> _sendMessage(BuildContext context) async { Future<void> _sendMessage(
BuildContext context, {
String? overrideContent,
}) async {
if (_isSendingMessage) { if (_isSendingMessage) {
return; return;
} }
final content = _messageController.text.trim(); final content = (overrideContent ?? _messageController.text).trim();
if (content.isEmpty && _selectedImages.isEmpty) return; if (content.isEmpty && _selectedImages.isEmpty) return;
final images = List<XFile>.from(_selectedImages); final images = List<XFile>.from(_selectedImages);
FocusScope.of(context).unfocus(); final currentFocus = FocusManager.instance.primaryFocus;
currentFocus?.unfocus();
_messageController.clear(); _messageController.clear();
setState(() { setState(() {
_isSendingMessage = true; _isSendingMessage = true;
@@ -24,8 +24,8 @@ class HomeComposerStack extends StatelessWidget {
required this.onHoldToSpeakEnd, required this.onHoldToSpeakEnd,
required this.onHoldToSpeakMoveUpdate, required this.onHoldToSpeakMoveUpdate,
required this.onHoldToSpeakCancel, required this.onHoldToSpeakCancel,
required this.onTextFieldTap,
required this.onSubmit, required this.onSubmit,
required this.keyboardInset,
}); });
final List<XFile> selectedImages; final List<XFile> selectedImages;
@@ -43,8 +43,8 @@ class HomeComposerStack extends StatelessWidget {
final VoidCallback onHoldToSpeakEnd; final VoidCallback onHoldToSpeakEnd;
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate; final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel; final VoidCallback onHoldToSpeakCancel;
final VoidCallback onTextFieldTap;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final double keyboardInset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -57,7 +57,12 @@ class HomeComposerStack extends StatelessWidget {
return Align( return Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg), padding: EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.lg + keyboardInset,
),
child: KeyedSubtree( child: KeyedSubtree(
key: const ValueKey('home_bottom_input_stack'), key: const ValueKey('home_bottom_input_stack'),
child: Column( child: Column(
@@ -141,7 +146,6 @@ class HomeComposerStack extends StatelessWidget {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
filled: false, filled: false,
), ),
onTap: onTextFieldTap,
onSubmitted: (_) => onSubmit(), onSubmitted: (_) => onSubmit(),
), ),
), ),
@@ -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<XFile> selectedImages;
final ValueChanged<int> 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<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel;
final Future<void> Function(String text) onSubmitText;
final double keyboardInset;
@override
State<HomeInputHost> createState() => HomeInputHostState();
}
class HomeInputHostState extends State<HomeInputHost> {
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<void>('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);
}
}
@@ -49,12 +49,6 @@ class AccountScreen extends StatelessWidget {
onTap: () => context.push('/edit-profile'), onTap: () => context.push('/edit-profile'),
), ),
_buildDivider(), _buildDivider(),
_buildMenuItem(
icon: Icons.lock,
title: '修改密码',
onTap: () => context.push('/change-password'),
),
_buildDivider(),
_buildMenuItem( _buildMenuItem(
icon: Icons.logout, icon: Icons.logout,
title: '退出登录', title: '退出登录',
@@ -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<AuthRepository>()),
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<AuthBloc>().state;
if (authState is AuthAuthenticated) {
return authState.user.email;
}
return '';
}
@override
void dispose() {
_codeController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
final email = _resolveUserEmail();
if (email.isEmpty) {
Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning);
return;
}
final cubit = context.read<ResetPasswordCubit>();
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<ResetPasswordCubit, ResetPasswordState>(
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<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return _buildSubmitButton(state);
},
),
),
);
}
Widget _buildForm() {
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
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<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().emailChanged(
userEmail,
);
context.read<ResetPasswordCubit>().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<ResetPasswordCubit>().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<ResetPasswordCubit>().newPasswordChanged(value),
);
}
Widget _buildConfirmPasswordInput(bool hasError) {
return _buildPasswordField(
label: '确认密码',
controller: _confirmPasswordController,
hintText: '请再次输入新密码',
hasError: hasError,
isObscured: _obscureConfirmPassword,
onToggleVisibility: () =>
setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
onChanged: (value) =>
context.read<ResetPasswordCubit>().confirmPasswordChanged(value),
);
}
Widget _buildPasswordField({
required String label,
required TextEditingController controller,
required String hintText,
required bool hasError,
required bool isObscured,
required VoidCallback onToggleVisibility,
required ValueChanged<String> 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,
),
);
}
}
@@ -122,6 +122,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
return SettingsPageScaffold( return SettingsPageScaffold(
title: '编辑资料', title: '编辑资料',
onBack: () => context.pop(), onBack: () => context.pop(),
resizeOnKeyboard: false,
maintainBottomViewPadding: true,
body: _isLoading body: _isLoading
? const Center( ? const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface), child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
@@ -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/app_loading_indicator.dart';
import 'package:social_app/shared/widgets/toast/toast.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/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/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/models/user_response.dart';
@@ -98,7 +99,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
final username = _user?.username ?? '未设置'; final username = _user?.username ?? '未设置';
final email = _user?.email ?? '未设置'; final phone = _user?.phone == null
? '未设置'
: formatPhoneForDisplay(_user?.phone);
return Container( return Container(
width: double.infinity, width: double.infinity,
@@ -195,7 +198,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
email, phone,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -10,18 +10,24 @@ class SettingsPageScaffold extends StatelessWidget {
required this.body, required this.body,
this.footer, this.footer,
this.onBack, this.onBack,
this.resizeOnKeyboard = true,
this.maintainBottomViewPadding = false,
}); });
final String title; final String title;
final Widget body; final Widget body;
final Widget? footer; final Widget? footer;
final VoidCallback? onBack; final VoidCallback? onBack;
final bool resizeOnKeyboard;
final bool maintainBottomViewPadding;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.surfaceSecondary, backgroundColor: AppColors.surfaceSecondary,
resizeToAvoidBottomInset: resizeOnKeyboard,
body: SafeArea( body: SafeArea(
maintainBottomViewPadding: maintainBottomViewPadding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -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;
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
Future<bool> showConfirmSheet(
BuildContext context, {
required String title,
required String message,
String confirmText = '确认',
String cancelText = '取消',
bool isDestructive = false,
}) async {
final result = await showModalBottomSheet<bool>(
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;
}
@@ -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<String> items;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.sm),
child: PopupMenuButton<String>(
onSelected: onChanged,
itemBuilder: (context) => items
.map(
(item) => PopupMenuItem<String>(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,
),
],
),
),
);
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/api/i_api_client.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'; import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
class _FakeApiClient implements IApiClient { class _FakeApiClient implements IApiClient {
_FakeApiClient({required this.sseLines}); _FakeApiClient({
required this.sseLines,
this.sseLineStreamFactory,
this.runIdFactory,
});
final List<String> sseLines; final List<String> sseLines;
final Stream<String> Function()? sseLineStreamFactory;
final String Function()? runIdFactory;
@override @override
Future<Response<T>> delete<T>(String path, {data, Options? options}) { Future<Response<T>> delete<T>(String path, {data, Options? options}) {
@@ -24,6 +32,10 @@ class _FakeApiClient implements IApiClient {
String path, { String path, {
Map<String, String>? headers, Map<String, String>? headers,
}) async { }) async {
final streamFactory = sseLineStreamFactory;
if (streamFactory != null) {
return streamFactory();
}
return Stream<String>.fromIterable(sseLines); return Stream<String>.fromIterable(sseLines);
} }
@@ -34,10 +46,11 @@ class _FakeApiClient implements IApiClient {
@override @override
Future<Response<T>> post<T>(String path, {data, Options? options}) async { Future<Response<T>> post<T>(String path, {data, Options? options}) async {
final runIdFactory = this.runIdFactory;
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'taskId': 'task-1', 'taskId': 'task-1',
'threadId': 'thread-1', 'threadId': 'thread-1',
'runId': 'run-new', 'runId': runIdFactory != null ? runIdFactory() : 'run-new',
'created': true, 'created': true,
}; };
return Response<T>( return Response<T>(
@@ -149,4 +162,110 @@ void main() {
expect(events[2], isA<RunFinishedEvent>()); expect(events[2], isA<RunFinishedEvent>());
}, },
); );
test('cancelCurrentRun actively closes current SSE subscription', () async {
var streamCancelled = false;
final streamController = StreamController<String>(
onCancel: () {
streamCancelled = true;
},
);
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: const <String>[],
sseLineStreamFactory: () => streamController.stream,
),
);
final sendFuture = service.sendMessage('hello');
await Future<void>.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<String>(
onCancel: () {
firstStreamCancelled = true;
},
);
final secondController = StreamController<String>();
final streamQueue = <StreamController<String>>[
firstController,
secondController,
];
var streamIndex = 0;
var runIndex = 0;
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: const <String>[],
sseLineStreamFactory: () => streamQueue[streamIndex++].stream,
runIdFactory: () {
runIndex += 1;
return 'run-$runIndex';
},
),
);
final firstSendFuture = service.sendMessage('first');
await Future<void>.delayed(Duration.zero);
final secondSendFuture = service.sendMessage('second');
await Future<void>.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: <String>[
..._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<StateError>()));
});
} }
@@ -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);
});
}
@@ -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<Container>(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<IconData> rightIconFor({
required MessageComposerMode mode,
required MessageComposerProcess process,
required bool hasMessage,
required bool isWaitingAgent,
}) async {
await tester.pumpWidget(
_buildTestApp(
mode: mode,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
),
);
final iconFinder = find.descendant(
of: find.byKey(messageComposerRightButtonKey),
matching: find.byType(Icon),
);
expect(iconFinder, findsOneWidget);
final iconWidget = tester.widget<Icon>(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);
});
});
}
@@ -195,9 +195,7 @@ void main() {
}, },
); );
testWidgets('switching to text mode does not auto focus input', ( testWidgets('switching to text mode auto focuses input', (tester) async {
tester,
) async {
await pumpHomeScreen(tester); await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey)); await tester.tap(find.byKey(messageComposerRightButtonKey));
@@ -205,12 +203,10 @@ void main() {
await tester.pump(); await tester.pump();
final editable = tester.widget<EditableText>(find.byType(EditableText)); final editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.focusNode.hasFocus, isFalse); expect(editable.focusNode.hasFocus, isTrue);
}); });
testWidgets('single tap on input focuses text field after mode switch', ( testWidgets('single tap on input keeps text field focused', (tester) async {
tester,
) async {
await pumpHomeScreen(tester); await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey)); await tester.tap(find.byKey(messageComposerRightButtonKey));
@@ -224,7 +220,9 @@ void main() {
expect(editable.focusNode.hasFocus, isTrue); 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; var showCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.textInput, (call) async { .setMockMethodCallHandler(SystemChannels.textInput, (call) async {
@@ -242,15 +240,92 @@ void main() {
await tester.tap(find.byKey(messageComposerRightButtonKey)); await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump(); await tester.pump();
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();
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<EditableText>(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; showCalls = 0;
await tester.tap(find.byType(EditableText)); await tester.tap(find.byType(EditableText));
await tester.pump(); 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<EditableText>(find.byType(EditableText));
expect(editable.focusNode.hasFocus, isTrue);
expect(tester.takeException(), isNull);
}); });
testWidgets('release during delayed start continues to transcribe path', ( testWidgets('release during delayed start continues to transcribe path', (
@@ -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<AuthRepository>(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<void> pumpScreen(WidgetTester tester) async {
await tester.pumpWidget(
BlocProvider<AuthBloc>.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<ElevatedButton>(
find.widgetWithText(ElevatedButton, '确认修改'),
);
expect(confirmButton.onPressed, isNull);
expect(find.text('设置新密码'), findsNothing);
});
testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async {
final completer = Completer<void>();
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();
});
}
@@ -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);
});
}
@@ -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');
});
});
}