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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user