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(
|
Future<void> share(
|
||||||
String itemId, {
|
String itemId, {
|
||||||
required String email,
|
required String phone,
|
||||||
bool view = true,
|
bool view = true,
|
||||||
bool edit = false,
|
bool edit = false,
|
||||||
bool invite = false,
|
bool invite = false,
|
||||||
@@ -65,7 +65,7 @@ class CalendarApi {
|
|||||||
await _client.post(
|
await _client.post(
|
||||||
'$_prefix/$itemId/share',
|
'$_prefix/$itemId/share',
|
||||||
data: {
|
data: {
|
||||||
'email': email,
|
'phone': phone,
|
||||||
'permission_view': view,
|
'permission_view': view,
|
||||||
'permission_edit': edit,
|
'permission_edit': edit,
|
||||||
'permission_invite': invite,
|
'permission_invite': invite,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class CalendarShareDialog extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
||||||
final _emailController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
bool _permissionView = true;
|
bool _permissionView = true;
|
||||||
bool _permissionEdit = false;
|
bool _permissionEdit = false;
|
||||||
bool _permissionInvite = false;
|
bool _permissionInvite = false;
|
||||||
@@ -54,14 +54,14 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_phoneController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleShare() async {
|
Future<void> _handleShare() async {
|
||||||
final email = _emailController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
if (email.isEmpty) {
|
if (phone.isEmpty) {
|
||||||
Toast.show(context, '请输入邮箱地址', type: ToastType.error);
|
Toast.show(context, '请输入手机号', type: ToastType.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
|||||||
final api = sl<CalendarApi>();
|
final api = sl<CalendarApi>();
|
||||||
await api.share(
|
await api.share(
|
||||||
widget.eventId,
|
widget.eventId,
|
||||||
email: email,
|
phone: phone,
|
||||||
view: _permissionView,
|
view: _permissionView,
|
||||||
edit: _permissionEdit,
|
edit: _permissionEdit,
|
||||||
invite: _permissionInvite,
|
invite: _permissionInvite,
|
||||||
@@ -127,15 +127,15 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
|||||||
Text(widget.eventTitle, style: const TextStyle(fontSize: 16)),
|
Text(widget.eventTitle, style: const TextStyle(fontSize: 16)),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _emailController,
|
controller: _phoneController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '邮箱地址',
|
labelText: '手机号',
|
||||||
hintText: '输入对方的邮箱',
|
hintText: '输入对方的 +86 手机号',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)),
|
const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class AgUiService {
|
|||||||
EventCallback onEvent;
|
EventCallback onEvent;
|
||||||
final Map<String, String> _lastEventIdByThread = {};
|
final Map<String, String> _lastEventIdByThread = {};
|
||||||
int _activeStreamToken = 0;
|
int _activeStreamToken = 0;
|
||||||
|
StreamSubscription<String>? _activeSseSubscription;
|
||||||
|
Completer<void>? _activeSseDoneCompleter;
|
||||||
|
|
||||||
String? _threadId;
|
String? _threadId;
|
||||||
bool _hasMoreHistory = false;
|
bool _hasMoreHistory = false;
|
||||||
@@ -58,6 +60,7 @@ class AgUiService {
|
|||||||
String content, {
|
String content, {
|
||||||
List<XFile>? images,
|
List<XFile>? images,
|
||||||
}) async {
|
}) async {
|
||||||
|
await _cancelActiveSseSubscription();
|
||||||
final streamToken = ++_activeStreamToken;
|
final streamToken = ++_activeStreamToken;
|
||||||
final runInputPayload = await _buildRunInput(
|
final runInputPayload = await _buildRunInput(
|
||||||
content: content,
|
content: content,
|
||||||
@@ -149,6 +152,21 @@ class AgUiService {
|
|||||||
|
|
||||||
Future<void> cancelCurrentRun() async {
|
Future<void> cancelCurrentRun() async {
|
||||||
_activeStreamToken += 1;
|
_activeStreamToken += 1;
|
||||||
|
await _cancelActiveSseSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelActiveSseSubscription() async {
|
||||||
|
final doneCompleter = _activeSseDoneCompleter;
|
||||||
|
if (doneCompleter != null && !doneCompleter.isCompleted) {
|
||||||
|
doneCompleter.complete();
|
||||||
|
}
|
||||||
|
_activeSseDoneCompleter = null;
|
||||||
|
final subscription = _activeSseSubscription;
|
||||||
|
_activeSseSubscription = null;
|
||||||
|
if (subscription == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await subscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _streamEventsFromApi(
|
Future<void> _streamEventsFromApi(
|
||||||
@@ -170,80 +188,129 @@ class AgUiService {
|
|||||||
String? eventId;
|
String? eventId;
|
||||||
var hasBoundExpectedRun = false;
|
var hasBoundExpectedRun = false;
|
||||||
final dataBuffer = StringBuffer();
|
final dataBuffer = StringBuffer();
|
||||||
await for (final line in sseLines) {
|
final done = Completer<void>();
|
||||||
if (streamToken != _activeStreamToken) {
|
late final StreamSubscription<String> subscription;
|
||||||
break;
|
|
||||||
|
void stopStream({Object? error, StackTrace? stackTrace}) {
|
||||||
|
if (!done.isCompleted) {
|
||||||
|
if (error == null) {
|
||||||
|
done.complete();
|
||||||
|
} else {
|
||||||
|
done.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (line.isEmpty) {
|
unawaited(subscription.cancel());
|
||||||
if (dataBuffer.isNotEmpty) {
|
}
|
||||||
final raw = dataBuffer.toString();
|
|
||||||
dataBuffer.clear();
|
|
||||||
Map<String, dynamic>? decoded;
|
|
||||||
String? eventRunId;
|
|
||||||
String? eventThreadId;
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw);
|
|
||||||
if (parsed is Map<String, dynamic>) {
|
|
||||||
decoded = parsed;
|
|
||||||
final runId = parsed['runId'];
|
|
||||||
final thread = parsed['threadId'];
|
|
||||||
eventRunId = runId is String ? runId : null;
|
|
||||||
eventThreadId = thread is String ? thread : null;
|
|
||||||
|
|
||||||
final isRunStarted = eventType == AgUiEventTypeWire.runStarted;
|
subscription = sseLines.listen(
|
||||||
final isTargetRun = eventRunId == expectedRunId;
|
(line) {
|
||||||
if (isRunStarted && isTargetRun) {
|
try {
|
||||||
hasBoundExpectedRun = true;
|
if (streamToken != _activeStreamToken) {
|
||||||
|
stopStream();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (line.isEmpty) {
|
||||||
|
if (dataBuffer.isNotEmpty) {
|
||||||
|
final raw = dataBuffer.toString();
|
||||||
|
dataBuffer.clear();
|
||||||
|
String? eventRunId;
|
||||||
|
String? eventThreadId;
|
||||||
|
Map<String, dynamic>? parsedMap;
|
||||||
|
try {
|
||||||
|
final parsed = jsonDecode(raw);
|
||||||
|
if (parsed is Map<String, dynamic>) {
|
||||||
|
parsedMap = parsed;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed SSE payload and keep stream alive.
|
||||||
}
|
}
|
||||||
|
if (parsedMap != null) {
|
||||||
|
final runId = parsedMap['runId'];
|
||||||
|
final thread = parsedMap['threadId'];
|
||||||
|
eventRunId = runId is String ? runId : null;
|
||||||
|
eventThreadId = thread is String ? thread : null;
|
||||||
|
|
||||||
|
final isRunStarted = eventType == AgUiEventTypeWire.runStarted;
|
||||||
|
final isTargetRun = eventRunId == expectedRunId;
|
||||||
|
if (isRunStarted && isTargetRun) {
|
||||||
|
hasBoundExpectedRun = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isThreadMatched =
|
||||||
|
eventThreadId == null || eventThreadId == threadId;
|
||||||
|
final shouldDispatch =
|
||||||
|
isTargetRun || (hasBoundExpectedRun && isThreadMatched);
|
||||||
|
if (shouldDispatch) {
|
||||||
|
final event = AgUiEvent.fromJson(parsedMap);
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final currentEventId = eventId;
|
||||||
|
if (currentEventId != null && currentEventId.isNotEmpty) {
|
||||||
|
_lastEventIdByThread[threadId] = currentEventId;
|
||||||
|
}
|
||||||
|
final isTerminalEvent =
|
||||||
|
eventType == AgUiEventTypeWire.runFinished ||
|
||||||
|
eventType == AgUiEventTypeWire.runError;
|
||||||
|
final isTargetRun = eventRunId == expectedRunId;
|
||||||
final isThreadMatched =
|
final isThreadMatched =
|
||||||
eventThreadId == null || eventThreadId == threadId;
|
eventThreadId == null || eventThreadId == threadId;
|
||||||
final shouldDispatch =
|
if (isTerminalEvent &&
|
||||||
isTargetRun || (hasBoundExpectedRun && isThreadMatched);
|
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
|
||||||
if (shouldDispatch) {
|
stopStream();
|
||||||
final event = AgUiEvent.fromJson(parsed);
|
return;
|
||||||
onEvent(event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
eventType = null;
|
||||||
// Ignore malformed SSE payload and keep stream alive.
|
eventId = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
final currentEventId = eventId;
|
if (line.startsWith(':')) {
|
||||||
if (currentEventId != null && currentEventId.isNotEmpty) {
|
return;
|
||||||
_lastEventIdByThread[threadId] = currentEventId;
|
|
||||||
}
|
}
|
||||||
final isTerminalEvent =
|
if (line.startsWith('id:')) {
|
||||||
eventType == AgUiEventTypeWire.runFinished ||
|
eventId = line.substring(3).trim();
|
||||||
eventType == AgUiEventTypeWire.runError;
|
return;
|
||||||
final isTargetRun = eventRunId == expectedRunId;
|
|
||||||
final isThreadMatched =
|
|
||||||
eventThreadId == null || eventThreadId == threadId;
|
|
||||||
if (isTerminalEvent &&
|
|
||||||
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventType = line.substring(6).trim();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
final fragment = line.substring(5).trim();
|
||||||
|
if (dataBuffer.isNotEmpty) {
|
||||||
|
dataBuffer.write('\n');
|
||||||
|
}
|
||||||
|
dataBuffer.write(fragment);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
stopStream(error: error, stackTrace: stackTrace);
|
||||||
}
|
}
|
||||||
eventType = null;
|
},
|
||||||
eventId = null;
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
continue;
|
stopStream(error: error, stackTrace: stackTrace);
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
stopStream();
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamToken != _activeStreamToken) {
|
||||||
|
await subscription.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeSseSubscription = subscription;
|
||||||
|
_activeSseDoneCompleter = done;
|
||||||
|
try {
|
||||||
|
await done.future;
|
||||||
|
} finally {
|
||||||
|
if (identical(_activeSseSubscription, subscription)) {
|
||||||
|
_activeSseSubscription = null;
|
||||||
}
|
}
|
||||||
if (line.startsWith(':')) {
|
if (identical(_activeSseDoneCompleter, done)) {
|
||||||
continue;
|
_activeSseDoneCompleter = null;
|
||||||
}
|
|
||||||
if (line.startsWith('id:')) {
|
|
||||||
eventId = line.substring(3).trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventType = line.substring(6).trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
final fragment = line.substring(5).trim();
|
|
||||||
if (dataBuffer.isNotEmpty) {
|
|
||||||
dataBuffer.write('\n');
|
|
||||||
}
|
|
||||||
dataBuffer.write(fragment);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class AddContactScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _AddContactScreenState extends State<AddContactScreen> {
|
class _AddContactScreenState extends State<AddContactScreen> {
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
final _remarkController = TextEditingController();
|
final _remarkController = TextEditingController();
|
||||||
|
|
||||||
bool get isEditing => widget.contactId != null;
|
bool get isEditing => widget.contactId != null;
|
||||||
@@ -26,7 +26,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_emailController.dispose();
|
_phoneController.dispose();
|
||||||
_remarkController.dispose();
|
_remarkController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surfaceSecondary,
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
maintainBottomViewPadding: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -122,10 +124,10 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
AppInput(label: '昵称', hint: '请输入昵称', controller: _nameController),
|
AppInput(label: '昵称', hint: '请输入昵称', controller: _nameController),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
AppInput(
|
AppInput(
|
||||||
label: '邮箱',
|
label: '手机号',
|
||||||
hint: '请输入邮箱',
|
hint: '+86 请输入 11 位手机号',
|
||||||
controller: _emailController,
|
controller: _phoneController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
AppInput(
|
AppInput(
|
||||||
@@ -152,10 +154,10 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
|
|
||||||
void _handleConfirm() {
|
void _handleConfirm() {
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
final email = _emailController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
|
|
||||||
if (name.isEmpty || email.isEmpty) {
|
if (name.isEmpty || phone.isEmpty) {
|
||||||
Toast.show(context, '请填写昵称和邮箱', type: ToastType.warning);
|
Toast.show(context, '请填写昵称和手机号', type: ToastType.warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,21 +63,11 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isValidEmail(String email) {
|
|
||||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
|
|
||||||
return emailRegex.hasMatch(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSearch() async {
|
Future<void> _onSearch() async {
|
||||||
final query = _searchController.text.trim();
|
final query = _searchController.text.trim();
|
||||||
|
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
Toast.show(context, '请输入邮箱地址', type: ToastType.warning);
|
Toast.show(context, '请输入用户名或手机号', type: ToastType.warning);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_isValidEmail(query)) {
|
|
||||||
Toast.show(context, '请输入有效的邮箱地址', type: ToastType.warning);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +255,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surfaceSecondary,
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
maintainBottomViewPadding: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -323,7 +315,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: '输入邮箱搜索用户',
|
hintText: '输入用户名或手机号',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -341,7 +333,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.text,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onSubmitted: (_) => _onSearch(),
|
onSubmitted: (_) => _onSearch(),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@@ -562,7 +554,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'搜索邮箱添加好友开始聊天吧',
|
'搜索手机号添加好友开始聊天吧',
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.slate400),
|
style: TextStyle(fontSize: 13, color: AppColors.slate400),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -24,9 +23,9 @@ import '../../../../shared/widgets/full_screen_loading.dart';
|
|||||||
import 'home_sheet.dart';
|
import 'home_sheet.dart';
|
||||||
import '../widgets/home_background_field.dart';
|
import '../widgets/home_background_field.dart';
|
||||||
import '../widgets/home_chat_item_renderer.dart';
|
import '../widgets/home_chat_item_renderer.dart';
|
||||||
import '../widgets/home_composer_stack.dart';
|
|
||||||
import '../widgets/home_conversation_chrome.dart';
|
import '../widgets/home_conversation_chrome.dart';
|
||||||
import '../widgets/home_floating_header.dart';
|
import '../widgets/home_floating_header.dart';
|
||||||
|
import '../widgets/home_input_host.dart';
|
||||||
import '../widgets/home_recording_overlay.dart';
|
import '../widgets/home_recording_overlay.dart';
|
||||||
import '../widgets/home_unread_badge.dart';
|
import '../widgets/home_unread_badge.dart';
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ class HomeScreen extends StatefulWidget {
|
|||||||
class _HomeScreenState extends State<HomeScreen>
|
class _HomeScreenState extends State<HomeScreen>
|
||||||
with SingleTickerProviderStateMixin, RouteAware {
|
with SingleTickerProviderStateMixin, RouteAware {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
final FocusNode _messageFocusNode = FocusNode();
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late final ChatBloc _chatBloc;
|
late final ChatBloc _chatBloc;
|
||||||
late final VoiceRecorder _voiceRecorder;
|
late final VoiceRecorder _voiceRecorder;
|
||||||
@@ -81,7 +79,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
late final AnimationController _listeningAnimationController;
|
late final AnimationController _listeningAnimationController;
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isRecordingStarting = false;
|
bool _isRecordingStarting = false;
|
||||||
bool _isHoldToSpeakMode = true;
|
|
||||||
bool _isTranscribing = false;
|
bool _isTranscribing = false;
|
||||||
bool _isCancelGestureActive = false;
|
bool _isCancelGestureActive = false;
|
||||||
bool _shouldCancelWhenStartCompletes = false;
|
bool _shouldCancelWhenStartCompletes = false;
|
||||||
@@ -101,6 +98,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
bool _routeAwareSubscribed = false;
|
bool _routeAwareSubscribed = false;
|
||||||
double? _historyViewportPixels;
|
double? _historyViewportPixels;
|
||||||
double? _historyViewportMaxExtent;
|
double? _historyViewportMaxExtent;
|
||||||
|
final GlobalKey<HomeInputHostState> _inputHostKey =
|
||||||
|
GlobalKey<HomeInputHostState>();
|
||||||
|
double _stableKeyboardInset = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -143,7 +143,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_messageFocusNode.dispose();
|
|
||||||
_scrollController.removeListener(_handleScrollChanged);
|
_scrollController.removeListener(_handleScrollChanged);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_listeningAnimationController.dispose();
|
_listeningAnimationController.dispose();
|
||||||
@@ -222,16 +221,22 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _chatBgColor,
|
backgroundColor: _chatBgColor,
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
maintainBottomViewPadding: true,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Positioned.fill(child: HomeBackgroundField()),
|
Positioned.fill(child: HomeBackgroundField()),
|
||||||
Column(
|
GestureDetector(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
behavior: HitTestBehavior.translucent,
|
||||||
children: [
|
onTap: _dismissKeyboard,
|
||||||
_buildHeader(context),
|
child: Column(
|
||||||
Expanded(child: _buildChatArea(context, state)),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
],
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
Expanded(child: _buildChatArea(context, state)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(),
|
if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(),
|
||||||
_buildBottomInputStack(context, state),
|
_buildBottomInputStack(context, state),
|
||||||
@@ -261,17 +266,18 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
Widget _buildChatArea(BuildContext context, ChatState state) {
|
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||||
final showWaitingIndicator = _isAgentWaiting(state);
|
final showWaitingIndicator = _isAgentWaiting(state);
|
||||||
|
final inputBottomInset = _effectiveKeyboardInset(context);
|
||||||
|
|
||||||
if (state.isLoadingHistory && state.items.isEmpty) {
|
if (state.isLoadingHistory && state.items.isEmpty) {
|
||||||
return const FullScreenLoading();
|
return const FullScreenLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
_defaultPadding,
|
_defaultPadding,
|
||||||
0,
|
0,
|
||||||
_defaultPadding,
|
_defaultPadding,
|
||||||
_bottomStackReservedHeight,
|
_bottomStackReservedHeight + inputBottomInset,
|
||||||
),
|
),
|
||||||
child: KeyedSubtree(
|
child: KeyedSubtree(
|
||||||
key: homeConversationStageKey,
|
key: homeConversationStageKey,
|
||||||
@@ -286,6 +292,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: AppSpacing.sm,
|
top: AppSpacing.sm,
|
||||||
bottom: showWaitingIndicator
|
bottom: showWaitingIndicator
|
||||||
@@ -349,9 +357,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUnreadBadge() {
|
Widget _buildUnreadBadge() {
|
||||||
|
final inputBottomInset = _effectiveKeyboardInset(context);
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: _defaultPadding,
|
right: _defaultPadding,
|
||||||
bottom: _bottomStackReservedHeight + AppSpacing.md,
|
bottom: _bottomStackReservedHeight + AppSpacing.md + inputBottomInset,
|
||||||
child: HomeUnreadBadge(
|
child: HomeUnreadBadge(
|
||||||
count: _chatUnreadBadgeCount,
|
count: _chatUnreadBadgeCount,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -524,12 +533,32 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
final position = _scrollController.position;
|
final position = _scrollController.position;
|
||||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
final keyboardInset = _effectiveKeyboardInset(context);
|
||||||
return (position.maxScrollExtent - position.pixels - bottomInset)
|
return (position.maxScrollExtent - position.pixels - keyboardInset)
|
||||||
.clamp(0, double.infinity)
|
.clamp(0, double.infinity)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _effectiveKeyboardInset(BuildContext context) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final rawInset = mediaQuery.viewInsets.bottom;
|
||||||
|
if (rawInset <= AppSpacing.xs) {
|
||||||
|
_stableKeyboardInset = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Only update stable if new value is larger (never decrease on jitter down)
|
||||||
|
if (rawInset > _stableKeyboardInset) {
|
||||||
|
_stableKeyboardInset = rawInset;
|
||||||
|
}
|
||||||
|
return _stableKeyboardInset;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dismissKeyboard() {
|
||||||
|
_inputHostKey.currentState?.unfocusInput();
|
||||||
|
final focus = FocusManager.instance.primaryFocus;
|
||||||
|
focus?.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
void _applyViewportDecision(ViewportDecision decision) {
|
void _applyViewportDecision(ViewportDecision decision) {
|
||||||
switch (decision.action) {
|
switch (decision.action) {
|
||||||
case ViewportAction.jumpBottom:
|
case ViewportAction.jumpBottom:
|
||||||
@@ -589,26 +618,26 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
|
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
|
||||||
final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state);
|
final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state);
|
||||||
return HomeComposerStack(
|
final inputBottomInset = _effectiveKeyboardInset(context);
|
||||||
|
return HomeInputHost(
|
||||||
|
key: _inputHostKey,
|
||||||
selectedImages: _selectedImages,
|
selectedImages: _selectedImages,
|
||||||
onRemoveImage: _removeImage,
|
onRemoveImage: _removeImage,
|
||||||
isHoldToSpeakMode: _isHoldToSpeakMode,
|
|
||||||
isRecording: _isRecording,
|
isRecording: _isRecording,
|
||||||
isCancelGestureActive: _isCancelGestureActive,
|
isCancelGestureActive: _isCancelGestureActive,
|
||||||
isTranscribing: _isTranscribing,
|
isTranscribing: _isTranscribing,
|
||||||
isWaitingAgent: isWaitingAgent,
|
isWaitingAgent: isWaitingAgent,
|
||||||
messageController: _messageController,
|
messageController: _messageController,
|
||||||
messageFocusNode: _messageFocusNode,
|
|
||||||
onTapPlus: _isRecording
|
onTapPlus: _isRecording
|
||||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||||
: () => _showBottomSheet(context),
|
: () => _showBottomSheet(context),
|
||||||
onTapRightAction: () => _onRightActionTap(context, state),
|
onStopGenerating: _onStopGenerating,
|
||||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||||
onHoldToSpeakCancel: _onHoldToSpeakCancel,
|
onHoldToSpeakCancel: _onHoldToSpeakCancel,
|
||||||
onTextFieldTap: _onTextFieldTap,
|
onSubmitText: (text) => _sendMessage(context, overrideContent: text),
|
||||||
onSubmit: () => _sendMessage(context),
|
keyboardInset: inputBottomInset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,54 +647,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTextFieldTap() {
|
|
||||||
final alreadyFocused = _messageFocusNode.hasFocus;
|
|
||||||
if (!alreadyFocused) {
|
|
||||||
_messageFocusNode.requestFocus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_supportsProgrammaticKeyboardShow()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _supportsProgrammaticKeyboardShow() {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return defaultTargetPlatform == TargetPlatform.android ||
|
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onRightActionTap(BuildContext context, ChatState state) {
|
|
||||||
if (_isTranscribing || _isRecording) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isSendingMessage || _isAgentWaiting(state)) {
|
|
||||||
_onStopGenerating();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_messageController.text.trim().isNotEmpty) {
|
|
||||||
_sendMessage(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_toggleHoldToSpeakMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleHoldToSpeakMode() {
|
|
||||||
if (_isRecording || _isTranscribing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final willSwitchToText = _isHoldToSpeakMode;
|
|
||||||
setState(() {
|
|
||||||
_isHoldToSpeakMode = !willSwitchToText;
|
|
||||||
});
|
|
||||||
if (!willSwitchToText) {
|
|
||||||
_messageFocusNode.unfocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onHoldToSpeakStart() {
|
void _onHoldToSpeakStart() {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -27,17 +27,21 @@ extension _HomeScreenInteractions on _HomeScreenState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage(BuildContext context) async {
|
Future<void> _sendMessage(
|
||||||
|
BuildContext context, {
|
||||||
|
String? overrideContent,
|
||||||
|
}) async {
|
||||||
if (_isSendingMessage) {
|
if (_isSendingMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final content = _messageController.text.trim();
|
final content = (overrideContent ?? _messageController.text).trim();
|
||||||
if (content.isEmpty && _selectedImages.isEmpty) return;
|
if (content.isEmpty && _selectedImages.isEmpty) return;
|
||||||
|
|
||||||
final images = List<XFile>.from(_selectedImages);
|
final images = List<XFile>.from(_selectedImages);
|
||||||
|
|
||||||
FocusScope.of(context).unfocus();
|
final currentFocus = FocusManager.instance.primaryFocus;
|
||||||
|
currentFocus?.unfocus();
|
||||||
_messageController.clear();
|
_messageController.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSendingMessage = true;
|
_isSendingMessage = true;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ class HomeComposerStack extends StatelessWidget {
|
|||||||
required this.onHoldToSpeakEnd,
|
required this.onHoldToSpeakEnd,
|
||||||
required this.onHoldToSpeakMoveUpdate,
|
required this.onHoldToSpeakMoveUpdate,
|
||||||
required this.onHoldToSpeakCancel,
|
required this.onHoldToSpeakCancel,
|
||||||
required this.onTextFieldTap,
|
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
|
required this.keyboardInset,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<XFile> selectedImages;
|
final List<XFile> selectedImages;
|
||||||
@@ -43,8 +43,8 @@ class HomeComposerStack extends StatelessWidget {
|
|||||||
final VoidCallback onHoldToSpeakEnd;
|
final VoidCallback onHoldToSpeakEnd;
|
||||||
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
||||||
final VoidCallback onHoldToSpeakCancel;
|
final VoidCallback onHoldToSpeakCancel;
|
||||||
final VoidCallback onTextFieldTap;
|
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
|
final double keyboardInset;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -57,7 +57,12 @@ class HomeComposerStack extends StatelessWidget {
|
|||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.lg,
|
||||||
|
AppSpacing.lg,
|
||||||
|
AppSpacing.lg,
|
||||||
|
AppSpacing.lg + keyboardInset,
|
||||||
|
),
|
||||||
child: KeyedSubtree(
|
child: KeyedSubtree(
|
||||||
key: const ValueKey('home_bottom_input_stack'),
|
key: const ValueKey('home_bottom_input_stack'),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -141,7 +146,6 @@ class HomeComposerStack extends StatelessWidget {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
filled: false,
|
filled: false,
|
||||||
),
|
),
|
||||||
onTap: onTextFieldTap,
|
|
||||||
onSubmitted: (_) => onSubmit(),
|
onSubmitted: (_) => onSubmit(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import 'home_composer_stack.dart';
|
||||||
|
|
||||||
|
class HomeInputHost extends StatefulWidget {
|
||||||
|
const HomeInputHost({
|
||||||
|
super.key,
|
||||||
|
required this.selectedImages,
|
||||||
|
required this.onRemoveImage,
|
||||||
|
required this.isRecording,
|
||||||
|
required this.isCancelGestureActive,
|
||||||
|
required this.isTranscribing,
|
||||||
|
required this.isWaitingAgent,
|
||||||
|
required this.messageController,
|
||||||
|
required this.onTapPlus,
|
||||||
|
required this.onStopGenerating,
|
||||||
|
required this.onHoldToSpeakStart,
|
||||||
|
required this.onHoldToSpeakEnd,
|
||||||
|
required this.onHoldToSpeakMoveUpdate,
|
||||||
|
required this.onHoldToSpeakCancel,
|
||||||
|
required this.onSubmitText,
|
||||||
|
required this.keyboardInset,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<XFile> selectedImages;
|
||||||
|
final ValueChanged<int> onRemoveImage;
|
||||||
|
final bool isRecording;
|
||||||
|
final bool isCancelGestureActive;
|
||||||
|
final bool isTranscribing;
|
||||||
|
final bool isWaitingAgent;
|
||||||
|
final TextEditingController messageController;
|
||||||
|
final VoidCallback onTapPlus;
|
||||||
|
final VoidCallback onStopGenerating;
|
||||||
|
final VoidCallback onHoldToSpeakStart;
|
||||||
|
final VoidCallback onHoldToSpeakEnd;
|
||||||
|
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
||||||
|
final VoidCallback onHoldToSpeakCancel;
|
||||||
|
final Future<void> Function(String text) onSubmitText;
|
||||||
|
final double keyboardInset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeInputHost> createState() => HomeInputHostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomeInputHostState extends State<HomeInputHost> {
|
||||||
|
final FocusNode _messageFocusNode = FocusNode();
|
||||||
|
Timer? _keyboardShowFallbackTimer;
|
||||||
|
bool _isHoldToSpeakMode = true;
|
||||||
|
|
||||||
|
void unfocusInput() {
|
||||||
|
_messageFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_messageFocusNode.addListener(_handleMessageFocusChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyboardShowFallbackTimer?.cancel();
|
||||||
|
_messageFocusNode.removeListener(_handleMessageFocusChanged);
|
||||||
|
_messageFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return HomeComposerStack(
|
||||||
|
selectedImages: widget.selectedImages,
|
||||||
|
onRemoveImage: widget.onRemoveImage,
|
||||||
|
isHoldToSpeakMode: _isHoldToSpeakMode,
|
||||||
|
isRecording: widget.isRecording,
|
||||||
|
isCancelGestureActive: widget.isCancelGestureActive,
|
||||||
|
isTranscribing: widget.isTranscribing,
|
||||||
|
isWaitingAgent: widget.isWaitingAgent,
|
||||||
|
messageController: widget.messageController,
|
||||||
|
messageFocusNode: _messageFocusNode,
|
||||||
|
onTapPlus: widget.onTapPlus,
|
||||||
|
onTapRightAction: _onRightActionTap,
|
||||||
|
onHoldToSpeakStart: widget.onHoldToSpeakStart,
|
||||||
|
onHoldToSpeakEnd: widget.onHoldToSpeakEnd,
|
||||||
|
onHoldToSpeakMoveUpdate: widget.onHoldToSpeakMoveUpdate,
|
||||||
|
onHoldToSpeakCancel: widget.onHoldToSpeakCancel,
|
||||||
|
onSubmit: _onSubmit,
|
||||||
|
keyboardInset: widget.keyboardInset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRightActionTap() {
|
||||||
|
if (widget.isTranscribing || widget.isRecording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.isWaitingAgent) {
|
||||||
|
widget.onStopGenerating();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final draft = widget.messageController.text.trim();
|
||||||
|
if (draft.isNotEmpty) {
|
||||||
|
_onSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_toggleInputMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleInputMode() {
|
||||||
|
if (widget.isRecording || widget.isTranscribing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final switchToText = _isHoldToSpeakMode;
|
||||||
|
setState(() {
|
||||||
|
_isHoldToSpeakMode = !_isHoldToSpeakMode;
|
||||||
|
});
|
||||||
|
if (!switchToText) {
|
||||||
|
_messageFocusNode.unfocus();
|
||||||
|
_keyboardShowFallbackTimer?.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _isHoldToSpeakMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_messageFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMessageFocusChanged() {
|
||||||
|
if (!_messageFocusNode.hasFocus || _isHoldToSpeakMode) {
|
||||||
|
_keyboardShowFallbackTimer?.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scheduleKeyboardShowFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleKeyboardShowFallback() {
|
||||||
|
if (!_supportsProgrammaticKeyboardShow() || _isKeyboardVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_keyboardShowFallbackTimer?.cancel();
|
||||||
|
_keyboardShowFallbackTimer = Timer(const Duration(milliseconds: 120), () {
|
||||||
|
if (!mounted || !_messageFocusNode.hasFocus || _isHoldToSpeakMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_isKeyboardVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _supportsProgrammaticKeyboardShow() {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isKeyboardVisible() {
|
||||||
|
final mediaQuery = MediaQuery.maybeOf(context);
|
||||||
|
if (mediaQuery == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mediaQuery.viewInsets.bottom > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSubmit() {
|
||||||
|
final draft = widget.messageController.text.trim();
|
||||||
|
if (draft.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widget.onSubmitText(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,12 +49,6 @@ class AccountScreen extends StatelessWidget {
|
|||||||
onTap: () => context.push('/edit-profile'),
|
onTap: () => context.push('/edit-profile'),
|
||||||
),
|
),
|
||||||
_buildDivider(),
|
_buildDivider(),
|
||||||
_buildMenuItem(
|
|
||||||
icon: Icons.lock,
|
|
||||||
title: '修改密码',
|
|
||||||
onTap: () => context.push('/change-password'),
|
|
||||||
),
|
|
||||||
_buildDivider(),
|
|
||||||
_buildMenuItem(
|
_buildMenuItem(
|
||||||
icon: Icons.logout,
|
icon: Icons.logout,
|
||||||
title: '退出登录',
|
title: '退出登录',
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
|
||||||
import '../../../../core/di/injection.dart';
|
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
|
||||||
import '../../../../shared/widgets/fixed_length_code_input.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
||||||
import '../../../auth/presentation/bloc/auth_bloc.dart';
|
|
||||||
import '../../../auth/presentation/bloc/auth_state.dart';
|
|
||||||
import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart';
|
|
||||||
import '../../../../features/auth/data/auth_repository.dart';
|
|
||||||
import '../widgets/account_section_card.dart';
|
|
||||||
import '../widgets/settings_page_scaffold.dart';
|
|
||||||
|
|
||||||
class ChangePasswordScreen extends StatelessWidget {
|
|
||||||
const ChangePasswordScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider(
|
|
||||||
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
|
|
||||||
child: const _ChangePasswordView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChangePasswordView extends StatefulWidget {
|
|
||||||
const _ChangePasswordView();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ChangePasswordView> createState() => __ChangePasswordViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class __ChangePasswordViewState extends State<_ChangePasswordView> {
|
|
||||||
final _codeController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
bool _obscurePassword = true;
|
|
||||||
bool _obscureConfirmPassword = true;
|
|
||||||
|
|
||||||
String _resolveUserEmail() {
|
|
||||||
final authState = context.read<AuthBloc>().state;
|
|
||||||
if (authState is AuthAuthenticated) {
|
|
||||||
return authState.user.email;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_codeController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSubmit() async {
|
|
||||||
final email = _resolveUserEmail();
|
|
||||||
if (email.isEmpty) {
|
|
||||||
Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final cubit = context.read<ResetPasswordCubit>();
|
|
||||||
cubit.emailChanged(email);
|
|
||||||
cubit.codeChanged(_codeController.text);
|
|
||||||
cubit.newPasswordChanged(_passwordController.text);
|
|
||||||
cubit.confirmPasswordChanged(_confirmPasswordController.text);
|
|
||||||
|
|
||||||
await cubit.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
|
|
||||||
listenWhen: (previous, current) =>
|
|
||||||
previous.status != current.status ||
|
|
||||||
previous.errorMessage != current.errorMessage ||
|
|
||||||
previous.codeSent != current.codeSent,
|
|
||||||
listener: (context, state) {
|
|
||||||
if (state.status == FormzSubmissionStatus.success && state.isSuccess) {
|
|
||||||
Toast.show(context, '密码修改成功', type: ToastType.success);
|
|
||||||
context.pop();
|
|
||||||
} else if (state.status == FormzSubmissionStatus.success &&
|
|
||||||
state.codeSent &&
|
|
||||||
state.errorMessage == 'CODE_SENT_SUCCESS') {
|
|
||||||
Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success);
|
|
||||||
} else if (state.status == FormzSubmissionStatus.failure &&
|
|
||||||
state.errorMessage != null &&
|
|
||||||
state.errorMessage != '' &&
|
|
||||||
state.errorMessage != 'CODE_SENT_SUCCESS') {
|
|
||||||
Toast.show(context, state.errorMessage!, type: ToastType.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: SettingsPageScaffold(
|
|
||||||
title: '修改密码',
|
|
||||||
onBack: () => context.pop(),
|
|
||||||
body: _buildForm(),
|
|
||||||
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return _buildSubmitButton(state);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildForm() {
|
|
||||||
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildEmailSection(state, _resolveUserEmail()),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_buildPasswordSection(
|
|
||||||
state,
|
|
||||||
state.newPassword.displayError != null,
|
|
||||||
state.confirmPassword.displayError != null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmailSection(ResetPasswordState state, String userEmail) {
|
|
||||||
return AccountSectionCard(
|
|
||||||
title: '发送验证码',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.md,
|
|
||||||
vertical: AppSpacing.md,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceInfoLight,
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
border: Border.all(color: AppColors.borderTertiary),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.email_outlined,
|
|
||||||
size: 20,
|
|
||||||
color: AppColors.blue600,
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
userEmail.isEmpty ? '未读取到登录邮箱' : userEmail,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.slate900,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
AppButton(
|
|
||||||
text: state.resendCountdown > 0
|
|
||||||
? '${state.resendCountdown} 秒后可重发'
|
|
||||||
: (state.codeSent ? '重新发送验证码' : '发送验证码'),
|
|
||||||
onPressed:
|
|
||||||
state.resendCountdown > 0 ||
|
|
||||||
state.status == FormzSubmissionStatus.inProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
if (userEmail.isEmpty) {
|
|
||||||
Toast.show(
|
|
||||||
context,
|
|
||||||
'未读取到登录邮箱,请重新登录后重试',
|
|
||||||
type: ToastType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.codeSent) {
|
|
||||||
context.read<ResetPasswordCubit>().resendCode();
|
|
||||||
} else {
|
|
||||||
context.read<ResetPasswordCubit>().emailChanged(
|
|
||||||
userEmail,
|
|
||||||
);
|
|
||||||
context.read<ResetPasswordCubit>().sendCode();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isOutlined: state.codeSent,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPasswordSection(
|
|
||||||
ResetPasswordState state,
|
|
||||||
bool passwordHasError,
|
|
||||||
bool confirmHasError,
|
|
||||||
) {
|
|
||||||
if (!state.codeSent) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return AccountSectionCard(
|
|
||||||
title: '设置新密码',
|
|
||||||
backgroundColor: AppColors.white,
|
|
||||||
borderColor: AppColors.borderSecondary,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'验证码',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
FixedLengthCodeInput(
|
|
||||||
controller: _codeController,
|
|
||||||
length: 6,
|
|
||||||
semanticLabel: '修改密码验证码输入框',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
allowedCharacters: const {
|
|
||||||
'0',
|
|
||||||
'1',
|
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
context.read<ResetPasswordCubit>().codeChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_buildPasswordInput(passwordHasError),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_buildConfirmPasswordInput(confirmHasError),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPasswordInput(bool hasError) {
|
|
||||||
return _buildPasswordField(
|
|
||||||
label: '新密码',
|
|
||||||
controller: _passwordController,
|
|
||||||
hintText: '请输入新密码(至少 6 位)',
|
|
||||||
hasError: hasError,
|
|
||||||
isObscured: _obscurePassword,
|
|
||||||
onToggleVisibility: () =>
|
|
||||||
setState(() => _obscurePassword = !_obscurePassword),
|
|
||||||
onChanged: (value) =>
|
|
||||||
context.read<ResetPasswordCubit>().newPasswordChanged(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildConfirmPasswordInput(bool hasError) {
|
|
||||||
return _buildPasswordField(
|
|
||||||
label: '确认密码',
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
hintText: '请再次输入新密码',
|
|
||||||
hasError: hasError,
|
|
||||||
isObscured: _obscureConfirmPassword,
|
|
||||||
onToggleVisibility: () =>
|
|
||||||
setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
|
||||||
onChanged: (value) =>
|
|
||||||
context.read<ResetPasswordCubit>().confirmPasswordChanged(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPasswordField({
|
|
||||||
required String label,
|
|
||||||
required TextEditingController controller,
|
|
||||||
required String hintText,
|
|
||||||
required bool hasError,
|
|
||||||
required bool isObscured,
|
|
||||||
required VoidCallback onToggleVisibility,
|
|
||||||
required ValueChanged<String> onChanged,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.slate700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
TextField(
|
|
||||||
controller: controller,
|
|
||||||
obscureText: isObscured,
|
|
||||||
onChanged: onChanged,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hintText,
|
|
||||||
errorText: hasError ? ' ' : null,
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppColors.surfaceSecondary,
|
|
||||||
hintStyle: const TextStyle(color: AppColors.slate400),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.lg,
|
|
||||||
vertical: AppSpacing.lg,
|
|
||||||
),
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
isObscured ? Icons.visibility_off : Icons.visibility,
|
|
||||||
size: 20,
|
|
||||||
color: AppColors.slate400,
|
|
||||||
),
|
|
||||||
onPressed: onToggleVisibility,
|
|
||||||
),
|
|
||||||
border: _inputBorder,
|
|
||||||
enabledBorder: _enabledBorder,
|
|
||||||
focusedBorder: _focusedBorder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final _inputBorder = OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
);
|
|
||||||
|
|
||||||
static final _enabledBorder = OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
borderSide: const BorderSide(color: AppColors.borderTertiary),
|
|
||||||
);
|
|
||||||
|
|
||||||
static final _focusedBorder = OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
borderSide: const BorderSide(color: AppColors.blue500),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildSubmitButton(ResetPasswordState state) {
|
|
||||||
final isLoading = state.status == FormzSubmissionStatus.inProgress;
|
|
||||||
final isDisabled = isLoading || !state.codeSent || !state.canSubmit;
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52,
|
|
||||||
child: AppButton(
|
|
||||||
text: '确认修改',
|
|
||||||
onPressed: isDisabled ? null : _handleSubmit,
|
|
||||||
isLoading: isLoading,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -122,6 +122,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
return SettingsPageScaffold(
|
return SettingsPageScaffold(
|
||||||
title: '编辑资料',
|
title: '编辑资料',
|
||||||
onBack: () => context.pop(),
|
onBack: () => context.pop(),
|
||||||
|
resizeOnKeyboard: false,
|
||||||
|
maintainBottomViewPadding: true,
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(
|
? const Center(
|
||||||
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
|
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:social_app/core/theme/design_tokens.dart';
|
|||||||
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
|
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
|
||||||
import 'package:social_app/shared/widgets/toast/toast.dart';
|
import 'package:social_app/shared/widgets/toast/toast.dart';
|
||||||
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
||||||
|
import 'package:social_app/shared/utils/phone_display_formatter.dart';
|
||||||
import 'package:social_app/features/friends/data/friends_api.dart';
|
import 'package:social_app/features/friends/data/friends_api.dart';
|
||||||
import 'package:social_app/features/settings/data/settings_api.dart';
|
import 'package:social_app/features/settings/data/settings_api.dart';
|
||||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||||
@@ -98,7 +99,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final username = _user?.username ?? '未设置';
|
final username = _user?.username ?? '未设置';
|
||||||
final email = _user?.email ?? '未设置';
|
final phone = _user?.phone == null
|
||||||
|
? '未设置'
|
||||||
|
: formatPhoneForDisplay(_user?.phone);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -195,7 +198,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
email,
|
phone,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|||||||
@@ -10,18 +10,24 @@ class SettingsPageScaffold extends StatelessWidget {
|
|||||||
required this.body,
|
required this.body,
|
||||||
this.footer,
|
this.footer,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
|
this.resizeOnKeyboard = true,
|
||||||
|
this.maintainBottomViewPadding = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final Widget body;
|
final Widget body;
|
||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
|
final bool resizeOnKeyboard;
|
||||||
|
final bool maintainBottomViewPadding;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surfaceSecondary,
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
resizeToAvoidBottomInset: resizeOnKeyboard,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
maintainBottomViewPadding: maintainBottomViewPadding,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
String formatPhoneForDisplay(String? rawPhone) {
|
||||||
|
final normalized = _normalizePhone(rawPhone);
|
||||||
|
if (normalized == null) {
|
||||||
|
return rawPhone?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('+86') && normalized.length == 14) {
|
||||||
|
final local = normalized.substring(3);
|
||||||
|
return '${local.substring(0, 3)}****${local.substring(7)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.startsWith('+')) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
final digits = normalized.substring(1);
|
||||||
|
final countryCode = _detectCountryCode(digits);
|
||||||
|
if (countryCode == null) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
final localNumber = digits.substring(countryCode.length);
|
||||||
|
if (localNumber.length <= 4) {
|
||||||
|
return '+$countryCode $localNumber';
|
||||||
|
}
|
||||||
|
final tail = localNumber.substring(localNumber.length - 4);
|
||||||
|
return '+$countryCode ****$tail';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizePhone(String? rawPhone) {
|
||||||
|
if (rawPhone == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var phone = rawPhone.trim();
|
||||||
|
for (final separator in const [' ', '-', '(', ')']) {
|
||||||
|
phone = phone.replaceAll(separator, '');
|
||||||
|
}
|
||||||
|
if (phone.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (phone.startsWith('00') && phone.length > 2) {
|
||||||
|
phone = '+${phone.substring(2)}';
|
||||||
|
}
|
||||||
|
if (!phone.startsWith('+') && RegExp(r'^\d+$').hasMatch(phone)) {
|
||||||
|
phone = '+$phone';
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _detectCountryCode(String digits) {
|
||||||
|
const knownCodes = ['86', '1', '44', '81', '65', '33'];
|
||||||
|
for (final code in knownCodes) {
|
||||||
|
if (digits.startsWith(code) && digits.length > code.length + 3) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int length = 3; length >= 1; length--) {
|
||||||
|
if (length >= digits.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final candidate = digits.substring(0, length);
|
||||||
|
if (candidate.startsWith('0')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (digits.length - length >= 4) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/design_tokens.dart';
|
||||||
|
import 'app_button.dart';
|
||||||
|
|
||||||
|
Future<bool> showConfirmSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
String confirmText = '确认',
|
||||||
|
String cancelText = '取消',
|
||||||
|
bool isDestructive = false,
|
||||||
|
}) async {
|
||||||
|
final result = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.none,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.md,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
border: Border.all(color: AppColors.borderSecondary),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.slate900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(true),
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDestructive
|
||||||
|
? AppColors.feedbackErrorIcon
|
||||||
|
: AppColors.blue600,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
confirmText,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: AppButton(
|
||||||
|
text: cancelText,
|
||||||
|
isOutlined: true,
|
||||||
|
onPressed: () => Navigator.of(sheetContext).pop(false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
|
class PhonePrefixSelector extends StatelessWidget {
|
||||||
|
const PhonePrefixSelector({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.items,
|
||||||
|
this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final List<String> items;
|
||||||
|
final ValueChanged<String>? onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.sm),
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
onSelected: onChanged,
|
||||||
|
itemBuilder: (context) => items
|
||||||
|
.map(
|
||||||
|
(item) => PopupMenuItem<String>(value: item, child: Text(item)),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
color: AppColors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.slate700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.xs),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.slate500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:social_app/core/api/i_api_client.dart';
|
import 'package:social_app/core/api/i_api_client.dart';
|
||||||
@@ -5,9 +7,15 @@ import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
|||||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||||
|
|
||||||
class _FakeApiClient implements IApiClient {
|
class _FakeApiClient implements IApiClient {
|
||||||
_FakeApiClient({required this.sseLines});
|
_FakeApiClient({
|
||||||
|
required this.sseLines,
|
||||||
|
this.sseLineStreamFactory,
|
||||||
|
this.runIdFactory,
|
||||||
|
});
|
||||||
|
|
||||||
final List<String> sseLines;
|
final List<String> sseLines;
|
||||||
|
final Stream<String> Function()? sseLineStreamFactory;
|
||||||
|
final String Function()? runIdFactory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||||
@@ -24,6 +32,10 @@ class _FakeApiClient implements IApiClient {
|
|||||||
String path, {
|
String path, {
|
||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
}) async {
|
}) async {
|
||||||
|
final streamFactory = sseLineStreamFactory;
|
||||||
|
if (streamFactory != null) {
|
||||||
|
return streamFactory();
|
||||||
|
}
|
||||||
return Stream<String>.fromIterable(sseLines);
|
return Stream<String>.fromIterable(sseLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +46,11 @@ class _FakeApiClient implements IApiClient {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||||
|
final runIdFactory = this.runIdFactory;
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'taskId': 'task-1',
|
'taskId': 'task-1',
|
||||||
'threadId': 'thread-1',
|
'threadId': 'thread-1',
|
||||||
'runId': 'run-new',
|
'runId': runIdFactory != null ? runIdFactory() : 'run-new',
|
||||||
'created': true,
|
'created': true,
|
||||||
};
|
};
|
||||||
return Response<T>(
|
return Response<T>(
|
||||||
@@ -149,4 +162,110 @@ void main() {
|
|||||||
expect(events[2], isA<RunFinishedEvent>());
|
expect(events[2], isA<RunFinishedEvent>());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('cancelCurrentRun actively closes current SSE subscription', () async {
|
||||||
|
var streamCancelled = false;
|
||||||
|
final streamController = StreamController<String>(
|
||||||
|
onCancel: () {
|
||||||
|
streamCancelled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final service = AgUiService(
|
||||||
|
apiClient: _FakeApiClient(
|
||||||
|
sseLines: const <String>[],
|
||||||
|
sseLineStreamFactory: () => streamController.stream,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sendFuture = service.sendMessage('hello');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await service.cancelCurrentRun();
|
||||||
|
|
||||||
|
await sendFuture;
|
||||||
|
expect(streamCancelled, isTrue);
|
||||||
|
await streamController.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'new sendMessage cancels previous SSE subscription explicitly',
|
||||||
|
() async {
|
||||||
|
var firstStreamCancelled = false;
|
||||||
|
final firstController = StreamController<String>(
|
||||||
|
onCancel: () {
|
||||||
|
firstStreamCancelled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final secondController = StreamController<String>();
|
||||||
|
final streamQueue = <StreamController<String>>[
|
||||||
|
firstController,
|
||||||
|
secondController,
|
||||||
|
];
|
||||||
|
var streamIndex = 0;
|
||||||
|
var runIndex = 0;
|
||||||
|
|
||||||
|
final service = AgUiService(
|
||||||
|
apiClient: _FakeApiClient(
|
||||||
|
sseLines: const <String>[],
|
||||||
|
sseLineStreamFactory: () => streamQueue[streamIndex++].stream,
|
||||||
|
runIdFactory: () {
|
||||||
|
runIndex += 1;
|
||||||
|
return 'run-$runIndex';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final firstSendFuture = service.sendMessage('first');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final secondSendFuture = service.sendMessage('second');
|
||||||
|
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
for (final line in _buildSseEvent(
|
||||||
|
id: '21',
|
||||||
|
type: AgUiEventTypeWire.runStarted,
|
||||||
|
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
|
||||||
|
)) {
|
||||||
|
secondController.add(line);
|
||||||
|
}
|
||||||
|
for (final line in _buildSseEvent(
|
||||||
|
id: '22',
|
||||||
|
type: AgUiEventTypeWire.runFinished,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
|
||||||
|
)) {
|
||||||
|
secondController.add(line);
|
||||||
|
}
|
||||||
|
await secondController.close();
|
||||||
|
|
||||||
|
await firstSendFuture;
|
||||||
|
await secondSendFuture;
|
||||||
|
|
||||||
|
expect(firstStreamCancelled, isTrue);
|
||||||
|
await firstController.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('sendMessage surfaces event callback exceptions', () async {
|
||||||
|
final service = AgUiService(
|
||||||
|
apiClient: _FakeApiClient(
|
||||||
|
sseLines: <String>[
|
||||||
|
..._buildSseEvent(
|
||||||
|
id: '31',
|
||||||
|
type: AgUiEventTypeWire.runStarted,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||||
|
),
|
||||||
|
..._buildSseEvent(
|
||||||
|
id: '32',
|
||||||
|
type: AgUiEventTypeWire.runFinished,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
service.onEvent = (_) => throw StateError('event callback failed');
|
||||||
|
|
||||||
|
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:social_app/features/home/ui/widgets/home_background_field.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('home background field renders layered glow surfaces', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const MaterialApp(home: Scaffold(body: HomeBackgroundField())),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.byKey(homeBackgroundFieldKey), findsOneWidget);
|
|
||||||
expect(find.byKey(homeTopGlowKey), findsOneWidget);
|
|
||||||
expect(find.byKey(homeBottomGlowKey), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
|
||||||
import 'package:social_app/core/theme/design_tokens.dart';
|
|
||||||
import 'package:social_app/shared/widgets/message_composer.dart';
|
|
||||||
|
|
||||||
Widget _buildTestApp({
|
|
||||||
required MessageComposerMode mode,
|
|
||||||
required MessageComposerProcess process,
|
|
||||||
required bool hasMessage,
|
|
||||||
required bool isWaitingAgent,
|
|
||||||
VoidCallback? onHoldStart,
|
|
||||||
VoidCallback? onHoldEnd,
|
|
||||||
VoidCallback? onHoldCancel,
|
|
||||||
}) {
|
|
||||||
return MaterialApp(
|
|
||||||
home: Scaffold(
|
|
||||||
body: MessageComposer(
|
|
||||||
mode: mode,
|
|
||||||
process: process,
|
|
||||||
hasMessage: hasMessage,
|
|
||||||
isWaitingAgent: isWaitingAgent,
|
|
||||||
iconSize: 24,
|
|
||||||
composerMinHeight: 48,
|
|
||||||
onTapPlus: () {},
|
|
||||||
onTapRightAction: () {},
|
|
||||||
onHoldToSpeakStart: onHoldStart ?? () {},
|
|
||||||
onHoldToSpeakEnd: onHoldEnd ?? () {},
|
|
||||||
onHoldToSpeakMoveUpdate: (_) {},
|
|
||||||
onHoldToSpeakCancel: onHoldCancel ?? () {},
|
|
||||||
textInputChild: const SizedBox.shrink(),
|
|
||||||
recordingAnimation: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('MessageComposer', () {
|
|
||||||
testWidgets('renders one unified rounded composer container', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.text,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.byKey(messageComposerContainerKey), findsOneWidget);
|
|
||||||
expect(find.byKey(messageComposerShellKey), findsOneWidget);
|
|
||||||
expect(find.byKey(messageComposerInnerKey), findsOneWidget);
|
|
||||||
|
|
||||||
final containerFinder = find.byKey(messageComposerContainerKey);
|
|
||||||
final shellFinder = find.byKey(messageComposerShellKey);
|
|
||||||
final plusFinder = find.byKey(messageComposerPlusButtonKey);
|
|
||||||
final rightFinder = find.byKey(messageComposerRightButtonKey);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.descendant(of: containerFinder, matching: plusFinder),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
find.descendant(of: containerFinder, matching: rightFinder),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
|
|
||||||
final container = tester.widget<Container>(shellFinder);
|
|
||||||
final decoration = container.decoration! as BoxDecoration;
|
|
||||||
expect(decoration.color, AppColors.homeComposerShell);
|
|
||||||
expect(
|
|
||||||
(decoration.border! as Border).top.color,
|
|
||||||
AppColors.homeComposerBorder,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('recording state keeps unified floating shell', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.recording,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.byKey(messageComposerShellKey), findsOneWidget);
|
|
||||||
expect(find.byKey(messageComposerInnerKey), findsOneWidget);
|
|
||||||
expect(find.text('松开发送'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('right action icon follows state priority', (tester) async {
|
|
||||||
Future<IconData> rightIconFor({
|
|
||||||
required MessageComposerMode mode,
|
|
||||||
required MessageComposerProcess process,
|
|
||||||
required bool hasMessage,
|
|
||||||
required bool isWaitingAgent,
|
|
||||||
}) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: mode,
|
|
||||||
process: process,
|
|
||||||
hasMessage: hasMessage,
|
|
||||||
isWaitingAgent: isWaitingAgent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final iconFinder = find.descendant(
|
|
||||||
of: find.byKey(messageComposerRightButtonKey),
|
|
||||||
matching: find.byType(Icon),
|
|
||||||
);
|
|
||||||
expect(iconFinder, findsOneWidget);
|
|
||||||
final iconWidget = tester.widget<Icon>(iconFinder.first);
|
|
||||||
expect(iconWidget.icon, isNotNull);
|
|
||||||
return iconWidget.icon!;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await rightIconFor(
|
|
||||||
mode: MessageComposerMode.text,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: true,
|
|
||||||
),
|
|
||||||
LucideIcons.square,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await rightIconFor(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: true,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
LucideIcons.send,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await rightIconFor(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
LucideIcons.keyboard,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await rightIconFor(
|
|
||||||
mode: MessageComposerMode.text,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
LucideIcons.mic,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('recording hint appears only while recording', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(find.byKey(messageComposerRecordingHintKey), findsNothing);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.recording,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget);
|
|
||||||
expect(find.text('松开发送,上滑取消'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('composer height remains stable across mode switches', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.text,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final textHeight = tester.getSize(
|
|
||||||
find.byKey(messageComposerContainerKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final holdHeight = tester.getSize(
|
|
||||||
find.byKey(messageComposerContainerKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(textHeight.height, holdHeight.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('invokes long press start/end callbacks in hold mode', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var started = false;
|
|
||||||
var ended = false;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
onHoldStart: () => started = true,
|
|
||||||
onHoldEnd: () => ended = true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
|
||||||
final gesture = await tester.startGesture(center);
|
|
||||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
|
||||||
await gesture.up();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(started, isTrue);
|
|
||||||
expect(ended, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('invokes long press cancel callback when gesture canceled', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var canceled = false;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestApp(
|
|
||||||
mode: MessageComposerMode.holdToSpeak,
|
|
||||||
process: MessageComposerProcess.idle,
|
|
||||||
hasMessage: false,
|
|
||||||
isWaitingAgent: false,
|
|
||||||
onHoldCancel: () => canceled = true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
|
||||||
final gesture = await tester.startGesture(center);
|
|
||||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
|
||||||
await gesture.cancel();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(canceled, isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -195,9 +195,7 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('switching to text mode does not auto focus input', (
|
testWidgets('switching to text mode auto focuses input', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await pumpHomeScreen(tester);
|
await pumpHomeScreen(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
@@ -205,12 +203,10 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||||
expect(editable.focusNode.hasFocus, isFalse);
|
expect(editable.focusNode.hasFocus, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('single tap on input focuses text field after mode switch', (
|
testWidgets('single tap on input keeps text field focused', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await pumpHomeScreen(tester);
|
await pumpHomeScreen(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
@@ -224,7 +220,9 @@ void main() {
|
|||||||
expect(editable.focusNode.hasFocus, isTrue);
|
expect(editable.focusNode.hasFocus, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tap focused input triggers keyboard show once', (tester) async {
|
testWidgets('switching to text mode triggers keyboard show fallback', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
var showCalls = 0;
|
var showCalls = 0;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
||||||
@@ -242,15 +240,92 @@ void main() {
|
|||||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 130));
|
||||||
|
|
||||||
await tester.tap(find.byType(EditableText));
|
expect(showCalls, greaterThanOrEqualTo(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap center of input lane focuses text field', (tester) async {
|
||||||
|
await pumpHomeScreen(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final composerRect = tester.getRect(find.byKey(messageComposerInnerKey));
|
||||||
|
final centerLaneTap = Offset(
|
||||||
|
composerRect.left + composerRect.width * 0.5,
|
||||||
|
composerRect.center.dy,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(centerLaneTap);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||||
|
expect(editable.focusNode.hasFocus, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap focused input triggers at most one keyboard show', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var showCalls = 0;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
||||||
|
if (call.method == 'TextInput.show') {
|
||||||
|
showCalls += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.textInput, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pumpHomeScreen(tester);
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 130));
|
||||||
|
|
||||||
showCalls = 0;
|
showCalls = 0;
|
||||||
|
|
||||||
await tester.tap(find.byType(EditableText));
|
await tester.tap(find.byType(EditableText));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(showCalls, 1);
|
expect(showCalls, lessThanOrEqualTo(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('double toggle returns to hold-to-speak mode', (tester) async {
|
||||||
|
await pumpHomeScreen(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(EditableText), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(EditableText), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rapid triple toggle ends in text mode with focused input', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpHomeScreen(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||||
|
expect(editable.focusNode.hasFocus, isTrue);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('release during delayed start continues to transcribe path', (
|
testWidgets('release during delayed start continues to transcribe path', (
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
import 'package:social_app/core/di/injection.dart';
|
|
||||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
|
||||||
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
|
|
||||||
import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
|
|
||||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
|
||||||
import 'package:social_app/features/settings/ui/screens/change_password_screen.dart';
|
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late MockAuthRepository mockAuthRepository;
|
|
||||||
late AuthBloc authBloc;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
mockAuthRepository = MockAuthRepository();
|
|
||||||
await sl.reset();
|
|
||||||
sl.registerSingleton<AuthRepository>(mockAuthRepository);
|
|
||||||
|
|
||||||
authBloc = AuthBloc(mockAuthRepository);
|
|
||||||
authBloc.add(
|
|
||||||
const AuthLoggedIn(
|
|
||||||
user: AuthUser(id: 'user-1', email: 'tester@example.com'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await authBloc.close();
|
|
||||||
await sl.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> pumpScreen(WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
BlocProvider<AuthBloc>.value(
|
|
||||||
value: authBloc,
|
|
||||||
child: const MaterialApp(home: ChangePasswordScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('确认修改按钮在验证码发送前不可点击', (tester) async {
|
|
||||||
when(
|
|
||||||
() => mockAuthRepository.requestPasswordReset(any()),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
await pumpScreen(tester);
|
|
||||||
|
|
||||||
final confirmButton = tester.widget<ElevatedButton>(
|
|
||||||
find.widgetWithText(ElevatedButton, '确认修改'),
|
|
||||||
);
|
|
||||||
expect(confirmButton.onPressed, isNull);
|
|
||||||
expect(find.text('设置新密码'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async {
|
|
||||||
final completer = Completer<void>();
|
|
||||||
when(
|
|
||||||
() => mockAuthRepository.requestPasswordReset(any()),
|
|
||||||
).thenAnswer((_) => completer.future);
|
|
||||||
|
|
||||||
await pumpScreen(tester);
|
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(ElevatedButton, '发送验证码'));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.text('60 秒后可重发'), findsOneWidget);
|
|
||||||
expect(find.text('设置新密码'), findsOneWidget);
|
|
||||||
|
|
||||||
verify(
|
|
||||||
() => mockAuthRepository.requestPasswordReset('tester@example.com'),
|
|
||||||
).called(1);
|
|
||||||
|
|
||||||
completer.complete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:social_app/features/settings/ui/widgets/account_section_card.dart';
|
|
||||||
import 'package:social_app/features/settings/ui/widgets/settings_page_scaffold.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('AccountSectionCard renders title and description', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const MaterialApp(
|
|
||||||
home: Scaffold(
|
|
||||||
body: AccountSectionCard(
|
|
||||||
title: '基础信息',
|
|
||||||
description: '请填写公开展示资料',
|
|
||||||
child: Text('内容区'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('基础信息'), findsOneWidget);
|
|
||||||
expect(find.text('请填写公开展示资料'), findsOneWidget);
|
|
||||||
expect(find.text('内容区'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('SettingsPageScaffold renders header and footer', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: SettingsPageScaffold(
|
|
||||||
title: '编辑资料',
|
|
||||||
body: const Text('主体内容'),
|
|
||||||
footer: const Text('底部操作区'),
|
|
||||||
onBack: () {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('编辑资料'), findsOneWidget);
|
|
||||||
expect(find.text('主体内容'), findsOneWidget);
|
|
||||||
expect(find.text('底部操作区'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('SettingsPageScaffold renders body without footer', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: SettingsPageScaffold(
|
|
||||||
title: '账户',
|
|
||||||
body: const Text('主体内容'),
|
|
||||||
onBack: () {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('账户'), findsOneWidget);
|
|
||||||
expect(find.text('主体内容'), findsOneWidget);
|
|
||||||
expect(find.text('底部操作区'), findsNothing);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:social_app/shared/utils/phone_display_formatter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('formatPhoneForDisplay', () {
|
||||||
|
test('formats +86 numbers as local masked style', () {
|
||||||
|
final formatted = formatPhoneForDisplay('+8613812345678');
|
||||||
|
|
||||||
|
expect(formatted, '138****5678');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps international country code while masking middle part', () {
|
||||||
|
final formatted = formatPhoneForDisplay('+14155552671');
|
||||||
|
|
||||||
|
expect(formatted, '+1 ****2671');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes separators before formatting', () {
|
||||||
|
final formatted = formatPhoneForDisplay('(+86) 138-1234-5678');
|
||||||
|
|
||||||
|
expect(formatted, '138****5678');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers longer country code in fallback detection', () {
|
||||||
|
final formatted = formatPhoneForDisplay('+33612345678');
|
||||||
|
|
||||||
|
expect(formatted, '+33 ****5678');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user