refactor: text streaming
This commit is contained in:
@@ -163,7 +163,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = (textItem as Map)['text']?.toString() ?? '';
|
||||
content =
|
||||
(textItem as Map)['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,11 +766,12 @@ Future<void> regenerateMessage(
|
||||
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
|
||||
|
||||
// Prefer provided attachments for the last user message; otherwise use message attachments
|
||||
final bool isLastUser = (i == messages.length - 1) && msg.role == 'user';
|
||||
final bool isLastUser =
|
||||
(i == messages.length - 1) && msg.role == 'user';
|
||||
final List<String> messageAttachments =
|
||||
(isLastUser && (attachments != null && attachments.isNotEmpty))
|
||||
? List<String>.from(attachments)
|
||||
: (msg.attachmentIds ?? const <String>[]);
|
||||
? List<String>.from(attachments)
|
||||
: (msg.attachmentIds ?? const <String>[]);
|
||||
|
||||
if (messageAttachments.isNotEmpty) {
|
||||
final messageMap = await _buildMessagePayloadWithAttachments(
|
||||
@@ -946,6 +948,11 @@ Future<void> regenerateMessage(
|
||||
final bool isBackgroundWebSearchPre = webSearchEnabled;
|
||||
|
||||
// Dispatch using unified send pipeline (background tools flow)
|
||||
final bool _isBackgroundFlowPre =
|
||||
isBackgroundToolsFlowPre ||
|
||||
isBackgroundWebSearchPre ||
|
||||
imageGenerationEnabled;
|
||||
final bool _passSocketSession = wantSessionBinding && _isBackgroundFlowPre;
|
||||
final response = api!.sendMessage(
|
||||
messages: conversationMessages,
|
||||
model: selectedModel.id,
|
||||
@@ -954,7 +961,7 @@ Future<void> regenerateMessage(
|
||||
enableWebSearch: webSearchEnabled,
|
||||
enableImageGeneration: imageGenerationEnabled,
|
||||
modelItem: modelItem,
|
||||
sessionIdOverride: wantSessionBinding ? socketSessionId : null,
|
||||
sessionIdOverride: _passSocketSession ? socketSessionId : null,
|
||||
toolServers: toolServers,
|
||||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
@@ -1935,7 +1942,9 @@ Future<void> _sendMessageInternal(
|
||||
content = payload['message'];
|
||||
}
|
||||
if (content.isNotEmpty) {
|
||||
ref.read(chatMessagesProvider.notifier).replaceLastMessageContent('⚠️ ' + content);
|
||||
ref
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.replaceLastMessageContent('⚠️ ' + content);
|
||||
}
|
||||
} catch (_) {}
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
@@ -1984,7 +1993,8 @@ Future<void> _sendMessageInternal(
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {}
|
||||
} else if ((type == 'files' || type == 'chat:message:files') && payload != null) {
|
||||
} else if ((type == 'files' || type == 'chat:message:files') &&
|
||||
payload != null) {
|
||||
// Handle files event from socket (image generation results)
|
||||
try {
|
||||
DebugLogger.stream(
|
||||
|
||||
@@ -152,7 +152,9 @@ class FileAttachmentService {
|
||||
int? maxHeight,
|
||||
}) async {
|
||||
try {
|
||||
foundation.debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Converting image to data URL: ${imageFile.path}',
|
||||
);
|
||||
|
||||
// Read the file as bytes
|
||||
final bytes = await imageFile.readAsBytes();
|
||||
@@ -217,41 +219,22 @@ class FileAttachmentService {
|
||||
'webp',
|
||||
].contains(ext.substring(1));
|
||||
|
||||
if (isImage) {
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Image file detected, converting to data URL instead of uploading',
|
||||
);
|
||||
// Upload ALL files (including images) to server for consistency with web client
|
||||
foundation.debugPrint('DEBUG: Uploading file to server...');
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
foundation.debugPrint(
|
||||
'DEBUG: File uploaded successfully with ID: $fileId',
|
||||
);
|
||||
|
||||
// For images, convert to data URL instead of uploading
|
||||
final dataUrl = await convertImageToDataUrl(file);
|
||||
if (dataUrl != null) {
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: dataUrl, // Use data URL as fileId for images
|
||||
isImage: true,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to convert image to data URL');
|
||||
}
|
||||
} else {
|
||||
foundation.debugPrint('DEBUG: Non-image file, uploading to server...');
|
||||
// Upload file using the API service
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
foundation.debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
isImage: isImage,
|
||||
);
|
||||
} catch (e) {
|
||||
foundation.debugPrint('DEBUG: File upload failed: $e');
|
||||
final fileName = path.basename(file.path);
|
||||
@@ -439,10 +422,10 @@ class MockFileAttachmentService {
|
||||
// Mock upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
foundation.debugPrint('DEBUG: Mock file upload for: ${file.path}');
|
||||
|
||||
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
|
||||
// Yield initial state
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
@@ -451,7 +434,7 @@ class MockFileAttachmentService {
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
|
||||
|
||||
// Simulate upload progress
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -463,7 +446,7 @@ class MockFileAttachmentService {
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Yield completed state with mock file ID
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
@@ -473,10 +456,10 @@ class MockFileAttachmentService {
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
|
||||
foundation.debugPrint('DEBUG: Mock file upload completed');
|
||||
}
|
||||
|
||||
|
||||
Future<List<String>> uploadFiles(
|
||||
List<File> files, {
|
||||
Function(int, int)? onProgress,
|
||||
@@ -484,7 +467,7 @@ class MockFileAttachmentService {
|
||||
}) async {
|
||||
// Simulate upload progress for reviewer mode
|
||||
final uploadIds = <String>[];
|
||||
|
||||
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
if (onProgress != null) {
|
||||
// Simulate progress
|
||||
@@ -496,7 +479,7 @@ class MockFileAttachmentService {
|
||||
// Generate mock upload ID
|
||||
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
|
||||
}
|
||||
|
||||
|
||||
return uploadIds;
|
||||
}
|
||||
}
|
||||
@@ -504,11 +487,11 @@ class MockFileAttachmentService {
|
||||
// Providers
|
||||
final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
|
||||
final isReviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
|
||||
if (isReviewerMode) {
|
||||
return MockFileAttachmentService();
|
||||
}
|
||||
|
||||
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
if (apiService == null) return null;
|
||||
return FileAttachmentService(apiService);
|
||||
|
||||
@@ -59,10 +59,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
bool _lastKeyboardVisible = false; // track keyboard visibility transitions
|
||||
bool _didStartupFocus = false; // one-time auto-focus on startup
|
||||
|
||||
String _formatModelDisplayName(
|
||||
String name, {
|
||||
required bool omitProvider,
|
||||
}) {
|
||||
String _formatModelDisplayName(String name, {required bool omitProvider}) {
|
||||
var display = name.trim();
|
||||
if (omitProvider) {
|
||||
// Prefer the segment after the last '/'
|
||||
@@ -295,8 +292,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Get attached files and collect uploaded file IDs (including data URLs for images)
|
||||
final attachedFiles = ref.read(attachedFilesProvider);
|
||||
final uploadedFileIds = attachedFiles
|
||||
.where((file) =>
|
||||
file.status == FileUploadStatus.completed && file.fileId != null)
|
||||
.where(
|
||||
(file) =>
|
||||
file.status == FileUploadStatus.completed &&
|
||||
file.fileId != null,
|
||||
)
|
||||
.map((file) => file.fileId!)
|
||||
.toList();
|
||||
|
||||
@@ -305,7 +305,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
// Enqueue task-based send to unify flow across text, images, and tools
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
await ref.read(taskQueueProvider.notifier).enqueueSendText(
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueSendText(
|
||||
conversationId: activeConv?.id,
|
||||
text: text,
|
||||
attachments: uploadedFileIds.isNotEmpty ? uploadedFileIds : null,
|
||||
@@ -373,22 +375,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
try {
|
||||
final ext = path.extension(file.path).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
if (isImage) {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
);
|
||||
} else {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
}
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Enqueue upload failed: $e');
|
||||
@@ -453,10 +447,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
debugPrint('DEBUG: Enqueueing image upload...');
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
try {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: image.path,
|
||||
fileName: path.basename(image.path),
|
||||
fileSize: imageSize,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Enqueue image upload failed: $e');
|
||||
@@ -709,8 +706,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
String? displayModelName;
|
||||
final rawModel = message.model;
|
||||
if (rawModel != null && rawModel.isNotEmpty) {
|
||||
final omitProvider =
|
||||
ref.watch(appSettingsProvider).omitProviderInModelName;
|
||||
final omitProvider = ref
|
||||
.watch(appSettingsProvider)
|
||||
.omitProviderInModelName;
|
||||
final modelsAsync = ref.watch(modelsProvider);
|
||||
if (modelsAsync.hasValue) {
|
||||
final models = modelsAsync.value!;
|
||||
@@ -931,7 +929,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Keyboard visibility
|
||||
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
// Whether the messages list can actually scroll (avoids showing button when not needed)
|
||||
final canScroll = _scrollController.hasClients &&
|
||||
final canScroll =
|
||||
_scrollController.hasClients &&
|
||||
_scrollController.position.maxScrollExtent > 0;
|
||||
|
||||
// On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible
|
||||
@@ -1128,10 +1127,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
label,
|
||||
style: AppTypography.headlineSmallStyle
|
||||
.copyWith(
|
||||
color:
|
||||
context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
color: context
|
||||
.conduitTheme
|
||||
.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
semanticsLabel: label,
|
||||
);
|
||||
@@ -1366,158 +1366,170 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Messages Area with pull-to-refresh
|
||||
Expanded(
|
||||
child: ConduitRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Reload active conversation messages from server
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (api != null && active != null) {
|
||||
try {
|
||||
final full = await api.getConversation(active.id);
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state = full;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to refresh conversation: $e');
|
||||
Column(
|
||||
children: [
|
||||
// Messages Area with pull-to-refresh
|
||||
Expanded(
|
||||
child: ConduitRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Reload active conversation messages from server
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (api != null && active != null) {
|
||||
try {
|
||||
final full = await api.getConversation(active.id);
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state =
|
||||
full;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to refresh conversation: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the conversations list to reconcile missed events
|
||||
// and keep timestamps/order in sync with the server.
|
||||
try {
|
||||
ref.invalidate(conversationsProvider);
|
||||
// Best-effort await to stabilize UI; ignore errors.
|
||||
await ref.read(conversationsProvider.future);
|
||||
} catch (_) {}
|
||||
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
// Also refresh the conversations list to reconcile missed events
|
||||
// and keep timestamps/order in sync with the server.
|
||||
try {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
ref.invalidate(conversationsProvider);
|
||||
// Best-effort await to stabilize UI; ignore errors.
|
||||
await ref.read(conversationsProvider.future);
|
||||
} catch (_) {}
|
||||
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
},
|
||||
child: RepaintBoundary(
|
||||
child: _buildMessagesList(theme),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
try {
|
||||
SystemChannels.textInput.invokeMethod(
|
||||
'TextInput.hide',
|
||||
);
|
||||
} catch (_) {}
|
||||
},
|
||||
child: RepaintBoundary(
|
||||
child: _buildMessagesList(theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// File attachments
|
||||
const FileAttachmentWidget(),
|
||||
// File attachments
|
||||
const FileAttachmentWidget(),
|
||||
|
||||
// Offline indicator
|
||||
const ChatOfflineOverlay(),
|
||||
// Offline indicator
|
||||
const ChatOfflineOverlay(),
|
||||
|
||||
// Modern Input (root matches input background including safe area)
|
||||
RepaintBoundary(
|
||||
child: MeasureSize(
|
||||
onChange: (size) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inputHeight = size.height;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ModernChatInput(
|
||||
enabled:
|
||||
selectedModel != null &&
|
||||
(isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: null,
|
||||
onFileAttachment: _handleFileAttachment,
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
_handleImageAttachment(fromCamera: true),
|
||||
// Modern Input (root matches input background including safe area)
|
||||
RepaintBoundary(
|
||||
child: MeasureSize(
|
||||
onChange: (size) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inputHeight = size.height;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ModernChatInput(
|
||||
enabled:
|
||||
selectedModel != null &&
|
||||
(isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: null,
|
||||
onFileAttachment: _handleFileAttachment,
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
_handleImageAttachment(fromCamera: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Floating Scroll to Bottom Button with smooth appear/disappear
|
||||
Positioned(
|
||||
bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
switchInCurve: AnimationCurves.microInteraction,
|
||||
switchOutCurve: AnimationCurves.microInteraction,
|
||||
transitionBuilder: (child, animation) {
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(animation);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: (_showScrollToBottom &&
|
||||
!keyboardVisible &&
|
||||
canScroll &&
|
||||
ref.watch(chatMessagesProvider).isNotEmpty)
|
||||
? Center(
|
||||
key: const ValueKey('scroll_to_bottom_visible'),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.75),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder
|
||||
.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
// Floating Scroll to Bottom Button with smooth appear/disappear
|
||||
Positioned(
|
||||
bottom:
|
||||
((_inputHeight > 0)
|
||||
? _inputHeight
|
||||
: (Spacing.xxl + Spacing.xxxl)) +
|
||||
Spacing.sm,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
switchInCurve: AnimationCurves.microInteraction,
|
||||
switchOutCurve: AnimationCurves.microInteraction,
|
||||
transitionBuilder: (child, animation) {
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(animation);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child:
|
||||
(_showScrollToBottom &&
|
||||
!keyboardVisible &&
|
||||
canScroll &&
|
||||
ref.watch(chatMessagesProvider).isNotEmpty)
|
||||
? Center(
|
||||
key: const ValueKey('scroll_to_bottom_visible'),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: TouchTarget.button,
|
||||
height: TouchTarget.button,
|
||||
child: IconButton(
|
||||
onPressed: _scrollToBottom,
|
||||
splashRadius: 24,
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_down
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: IconSize.lg,
|
||||
color: context.conduitTheme.iconPrimary
|
||||
.withValues(alpha: 0.9),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.75),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder
|
||||
.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: TouchTarget.button,
|
||||
height: TouchTarget.button,
|
||||
child: IconButton(
|
||||
onPressed: _scrollToBottom,
|
||||
splashRadius: 24,
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_down
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: IconSize.lg,
|
||||
color: context.conduitTheme.iconPrimary
|
||||
.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('scroll_to_bottom_hidden'),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('scroll_to_bottom_hidden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Edge overlay removed; rely on native interactive drawer drag
|
||||
// Edge overlay removed; rely on native interactive drawer drag
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import '../../../shared/widgets/loading_states.dart';
|
||||
|
||||
@@ -24,7 +25,7 @@ class FileAttachmentWidget extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Attachments',
|
||||
AppLocalizations.of(context)!.attachments,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
|
||||
Reference in New Issue
Block a user