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(
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: [