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
@@ -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;