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:
@@ -57,7 +57,7 @@ class CalendarApi {
|
||||
|
||||
Future<void> 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,
|
||||
|
||||
@@ -46,7 +46,7 @@ class CalendarShareDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
||||
final _emailController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
bool _permissionView = true;
|
||||
bool _permissionEdit = false;
|
||||
bool _permissionInvite = false;
|
||||
@@ -54,14 +54,14 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<CalendarShareDialog> {
|
||||
final api = sl<CalendarApi>();
|
||||
await api.share(
|
||||
widget.eventId,
|
||||
email: email,
|
||||
phone: phone,
|
||||
view: _permissionView,
|
||||
edit: _permissionEdit,
|
||||
invite: _permissionInvite,
|
||||
@@ -127,15 +127,15 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
||||
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)),
|
||||
|
||||
@@ -46,6 +46,8 @@ class AgUiService {
|
||||
EventCallback onEvent;
|
||||
final Map<String, String> _lastEventIdByThread = {};
|
||||
int _activeStreamToken = 0;
|
||||
StreamSubscription<String>? _activeSseSubscription;
|
||||
Completer<void>? _activeSseDoneCompleter;
|
||||
|
||||
String? _threadId;
|
||||
bool _hasMoreHistory = false;
|
||||
@@ -58,6 +60,7 @@ class AgUiService {
|
||||
String content, {
|
||||
List<XFile>? images,
|
||||
}) async {
|
||||
await _cancelActiveSseSubscription();
|
||||
final streamToken = ++_activeStreamToken;
|
||||
final runInputPayload = await _buildRunInput(
|
||||
content: content,
|
||||
@@ -149,6 +152,21 @@ class AgUiService {
|
||||
|
||||
Future<void> cancelCurrentRun() async {
|
||||
_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(
|
||||
@@ -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<void>();
|
||||
late final StreamSubscription<String> 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<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;
|
||||
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<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 =
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class AddContactScreen extends StatefulWidget {
|
||||
|
||||
class _AddContactScreenState extends State<AddContactScreen> {
|
||||
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<AddContactScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_remarkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -35,7 +35,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
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<AddContactScreen> {
|
||||
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<AddContactScreen> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入邮箱搜索用户',
|
||||
hintText: '输入用户名或手机号',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -341,7 +333,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
),
|
||||
),
|
||||
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<ContactsScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'搜索邮箱添加好友开始聊天吧',
|
||||
'搜索手机号添加好友开始聊天吧',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.slate400),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<HomeScreen>
|
||||
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<HomeScreen>
|
||||
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<HomeScreen>
|
||||
bool _routeAwareSubscribed = false;
|
||||
double? _historyViewportPixels;
|
||||
double? _historyViewportMaxExtent;
|
||||
final GlobalKey<HomeInputHostState> _inputHostKey =
|
||||
GlobalKey<HomeInputHostState>();
|
||||
double _stableKeyboardInset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -143,7 +143,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_messageFocusNode.dispose();
|
||||
_scrollController.removeListener(_handleScrollChanged);
|
||||
_scrollController.dispose();
|
||||
_listeningAnimationController.dispose();
|
||||
@@ -222,16 +221,22 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
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<HomeScreen>
|
||||
|
||||
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<HomeScreen>
|
||||
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<HomeScreen>
|
||||
}
|
||||
|
||||
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<HomeScreen>
|
||||
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<HomeScreen>
|
||||
|
||||
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<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() {
|
||||
HapticFeedback.selectionClick();
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
final content = _messageController.text.trim();
|
||||
final content = (overrideContent ?? _messageController.text).trim();
|
||||
if (content.isEmpty && _selectedImages.isEmpty) return;
|
||||
|
||||
final images = List<XFile>.from(_selectedImages);
|
||||
|
||||
FocusScope.of(context).unfocus();
|
||||
final currentFocus = FocusManager.instance.primaryFocus;
|
||||
currentFocus?.unfocus();
|
||||
_messageController.clear();
|
||||
setState(() {
|
||||
_isSendingMessage = true;
|
||||
|
||||
@@ -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<XFile> selectedImages;
|
||||
@@ -43,8 +43,8 @@ class HomeComposerStack extends StatelessWidget {
|
||||
final VoidCallback onHoldToSpeakEnd;
|
||||
final ValueChanged<LongPressMoveUpdateDetails> 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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildMenuItem(
|
||||
icon: Icons.lock,
|
||||
title: '修改密码',
|
||||
onTap: () => context.push('/change-password'),
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildMenuItem(
|
||||
icon: Icons.logout,
|
||||
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(
|
||||
title: '编辑资料',
|
||||
onBack: () => context.pop(),
|
||||
resizeOnKeyboard: false,
|
||||
maintainBottomViewPadding: true,
|
||||
body: _isLoading
|
||||
? const Center(
|
||||
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/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<SettingsScreen> {
|
||||
}
|
||||
|
||||
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<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
email,
|
||||
phone,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: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<String> sseLines;
|
||||
final Stream<String> Function()? sseLineStreamFactory;
|
||||
final String Function()? runIdFactory;
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
@@ -24,6 +32,10 @@ class _FakeApiClient implements IApiClient {
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final streamFactory = sseLineStreamFactory;
|
||||
if (streamFactory != null) {
|
||||
return streamFactory();
|
||||
}
|
||||
return Stream<String>.fromIterable(sseLines);
|
||||
}
|
||||
|
||||
@@ -34,10 +46,11 @@ class _FakeApiClient implements IApiClient {
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
final runIdFactory = this.runIdFactory;
|
||||
final payload = <String, dynamic>{
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-new',
|
||||
'runId': runIdFactory != null ? runIdFactory() : 'run-new',
|
||||
'created': true,
|
||||
};
|
||||
return Response<T>(
|
||||
@@ -149,4 +162,110 @@ void main() {
|
||||
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', (
|
||||
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<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', (
|
||||
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<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;
|
||||
|
||||
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<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user