refactor: more logs
This commit is contained in:
@@ -16,11 +16,6 @@ import '../../tools/providers/tools_providers.dart';
|
||||
import 'dart:async';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
|
||||
void debugPrint(String? message, {int? wrapWidth}) {
|
||||
if (message == null) return;
|
||||
DebugLogger.fromLegacy(message, scope: 'chat/providers');
|
||||
}
|
||||
|
||||
const bool kSocketVerboseLogging = false;
|
||||
|
||||
// Chat messages for current conversation
|
||||
@@ -105,7 +100,10 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
debugPrint('Conversation changed: ${previous?.id} -> ${next?.id}');
|
||||
DebugLogger.log(
|
||||
'Conversation changed: ${previous?.id} -> ${next?.id}',
|
||||
scope: 'chat/providers',
|
||||
);
|
||||
|
||||
// Only react when the conversation actually changes
|
||||
if (previous?.id == next?.id) {
|
||||
@@ -1886,7 +1884,10 @@ Please try sending the message again, or try without attachments.''',
|
||||
);
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
|
||||
} else if (e.toString().contains('404')) {
|
||||
debugPrint('DEBUG: Model or endpoint not found (404)');
|
||||
DebugLogger.log(
|
||||
'Model or endpoint not found (404)',
|
||||
scope: 'chat/providers',
|
||||
);
|
||||
final errorMessage = ChatMessage(
|
||||
id: const Uuid().v4(),
|
||||
role: 'assistant',
|
||||
@@ -2005,7 +2006,10 @@ Future<void> pinConversation(
|
||||
.set(activeConversation!.copyWith(pinned: pinned));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e');
|
||||
DebugLogger.log(
|
||||
'Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e',
|
||||
scope: 'chat/providers',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -2033,8 +2037,9 @@ Future<void> archiveConversation(
|
||||
// Refresh conversations list to reflect the change
|
||||
ref.invalidate(conversationsProvider);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
DebugLogger.log(
|
||||
'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e',
|
||||
scope: 'chat/providers',
|
||||
);
|
||||
|
||||
// If server operation failed and we archived locally, restore the conversation
|
||||
@@ -2060,7 +2065,7 @@ Future<String?> shareConversation(WidgetRef ref, String conversationId) async {
|
||||
|
||||
return shareId;
|
||||
} catch (e) {
|
||||
debugPrint('Error sharing conversation: $e');
|
||||
DebugLogger.log('Error sharing conversation: $e', scope: 'chat/providers');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -2081,7 +2086,7 @@ Future<void> cloneConversation(WidgetRef ref, String conversationId) async {
|
||||
// Refresh conversations list to show the new conversation
|
||||
ref.invalidate(conversationsProvider);
|
||||
} catch (e) {
|
||||
debugPrint('Error cloning conversation: $e');
|
||||
DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart' hide debugPrint;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../shared/widgets/optimized_list.dart';
|
||||
@@ -48,11 +48,6 @@ import '../../../shared/widgets/model_avatar.dart';
|
||||
import '../../../core/services/platform_service.dart' as ps;
|
||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||
|
||||
void debugPrint(String? message, {int? wrapWidth}) {
|
||||
if (message == null) return;
|
||||
DebugLogger.fromLegacy(message, scope: 'chat/page');
|
||||
}
|
||||
|
||||
class ChatPage extends ConsumerStatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
@@ -251,7 +246,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
if (!mounted) return;
|
||||
ref.read(activeConversationProvider.notifier).set(welcomeConv);
|
||||
debugPrint('Auto-loaded demo conversation');
|
||||
DebugLogger.log('Auto-loaded demo conversation', scope: 'chat/page');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,7 +261,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
break;
|
||||
}
|
||||
|
||||
debugPrint('Failed to auto-load demo conversation');
|
||||
DebugLogger.log(
|
||||
'Failed to auto-load demo conversation',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -439,18 +437,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Enqueue upload failed: $e');
|
||||
DebugLogger.log('Enqueue upload failed: $e', scope: 'chat/page');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('File selection failed: $e');
|
||||
DebugLogger.log('File selection failed: $e', scope: 'chat/page');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleImageAttachment({bool fromCamera = false}) async {
|
||||
debugPrint(
|
||||
'DEBUG: Starting image attachment process - fromCamera: $fromCamera',
|
||||
DebugLogger.log(
|
||||
'Starting image attachment process - fromCamera: $fromCamera',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
|
||||
// Check if selected model supports vision
|
||||
@@ -462,23 +461,26 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
final fileService = ref.read(fileAttachmentServiceProvider);
|
||||
if (fileService == null) {
|
||||
debugPrint('DEBUG: File service is null - cannot proceed');
|
||||
DebugLogger.log(
|
||||
'File service is null - cannot proceed',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('DEBUG: Picking image...');
|
||||
DebugLogger.log('Picking image...', scope: 'chat/page');
|
||||
final image = fromCamera
|
||||
? await fileService.takePhoto()
|
||||
: await fileService.pickImage();
|
||||
if (image == null) {
|
||||
debugPrint('DEBUG: No image selected');
|
||||
DebugLogger.log('No image selected', scope: 'chat/page');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Image selected: ${image.path}');
|
||||
DebugLogger.log('Image selected: ${image.path}', scope: 'chat/page');
|
||||
final imageSize = await image.length();
|
||||
debugPrint('DEBUG: Image size: $imageSize bytes');
|
||||
DebugLogger.log('Image size: $imageSize bytes', scope: 'chat/page');
|
||||
|
||||
// Validate file size (default 20MB limit like OpenWebUI)
|
||||
if (!validateFileSize(imageSize, 20)) {
|
||||
@@ -495,10 +497,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
// Add image to the attachment list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
||||
debugPrint('DEBUG: Image added to attachment list');
|
||||
DebugLogger.log('Image added to attachment list', scope: 'chat/page');
|
||||
|
||||
// Enqueue upload via task queue for unified retry/progress
|
||||
debugPrint('DEBUG: Enqueueing image upload...');
|
||||
DebugLogger.log('Enqueueing image upload...', scope: 'chat/page');
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
try {
|
||||
await ref
|
||||
@@ -510,10 +512,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
fileSize: imageSize,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Enqueue image upload failed: $e');
|
||||
DebugLogger.log('Enqueue image upload failed: $e', scope: 'chat/page');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Image attachment error: $e');
|
||||
DebugLogger.log('Image attachment error: $e', scope: 'chat/page');
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
@@ -886,7 +888,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
userMessage.attachmentIds,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Regenerate failed: $e');
|
||||
DebugLogger.log('Regenerate failed: $e', scope: 'chat/page');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,8 +1391,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
.read(activeConversationProvider.notifier)
|
||||
.set(full);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to refresh conversation: $e',
|
||||
DebugLogger.log(
|
||||
'Failed to refresh conversation: $e',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2101,7 +2104,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
||||
});
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('DEBUG: VoiceInputSheet stream done');
|
||||
DebugLogger.log('VoiceInputSheet stream done', scope: 'chat/page');
|
||||
setState(() {
|
||||
_isListening = false;
|
||||
});
|
||||
@@ -2112,7 +2115,10 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('DEBUG: VoiceInputSheet stream error: $error');
|
||||
DebugLogger.log(
|
||||
'VoiceInputSheet stream error: $error',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
setState(() {
|
||||
_isListening = false;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart' hide debugPrint;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'dart:convert';
|
||||
@@ -22,11 +21,6 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
import '../providers/chat_providers.dart' show sendMessage;
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
|
||||
void debugPrint(String? message, {int? wrapWidth}) {
|
||||
if (message == null) return;
|
||||
DebugLogger.fromLegacy(message, scope: 'chat/assistant');
|
||||
}
|
||||
|
||||
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
||||
final dynamic message;
|
||||
final bool isStreaming;
|
||||
@@ -76,7 +70,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
try {
|
||||
await sendMessage(ref, trimmed, null);
|
||||
} catch (err, stack) {
|
||||
debugPrint('Failed to send follow-up: $err');
|
||||
DebugLogger.log(
|
||||
'Failed to send follow-up: $err',
|
||||
scope: 'chat/assistant',
|
||||
);
|
||||
debugPrintStack(stackTrace: stack);
|
||||
}
|
||||
}
|
||||
@@ -660,15 +657,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
const SizedBox(height: Spacing.md),
|
||||
CitationListView(sources: widget.message.sources),
|
||||
],
|
||||
|
||||
if (hasFollowUps) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
FollowUpSuggestionBar(
|
||||
suggestions: widget.message.followUps,
|
||||
onSelected: _handleFollowUpTap,
|
||||
isBusy: widget.isStreaming,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -677,6 +665,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
if (!widget.isStreaming) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_buildActionButtons(),
|
||||
if (hasFollowUps) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
FollowUpSuggestionBar(
|
||||
suggestions: widget.message.followUps,
|
||||
onSelected: _handleFollowUpTap,
|
||||
isBusy: widget.isStreaming,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -1283,6 +1279,124 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantResponseSection extends StatelessWidget {
|
||||
const _AssistantResponseSection({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: theme.buttonPrimary),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: theme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
border: Border.all(
|
||||
color: theme.cardBorder.withValues(alpha: 0.6),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantSuggestionChip extends StatelessWidget {
|
||||
const _AssistantSuggestionChip({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final effectiveOnPressed = enabled ? onPressed : null;
|
||||
final iconColor = enabled
|
||||
? theme.textSecondary
|
||||
: theme.textSecondary.withValues(alpha: 0.5);
|
||||
|
||||
final background = theme.cardBackground.withValues(
|
||||
alpha: enabled ? 0.95 : 0.85,
|
||||
);
|
||||
final borderColor = theme.cardBorder.withValues(
|
||||
alpha: enabled ? 0.6 : 0.35,
|
||||
);
|
||||
|
||||
return RawChip(
|
||||
avatar: icon != null ? Icon(icon, size: 16, color: iconColor) : null,
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: enabled ? theme.textPrimary : theme.textSecondary,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
onPressed: effectiveOnPressed,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: background,
|
||||
disabledColor: background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.pill),
|
||||
side: BorderSide(color: borderColor, width: BorderWidth.thin),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusHistoryTimeline extends StatelessWidget {
|
||||
const StatusHistoryTimeline({super.key, required this.updates});
|
||||
|
||||
@@ -1290,39 +1404,24 @@ class StatusHistoryTimeline extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
if (updates.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.surfaceContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.6),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
return _AssistantResponseSection(
|
||||
title: 'Status updates',
|
||||
icon: Icons.sync_alt,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Status updates',
|
||||
style: TextStyle(
|
||||
color: theme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
const SizedBox(height: Spacing.xs),
|
||||
for (var index = 0; index < updates.length; index++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == updates.length - 1 ? 0 : Spacing.xs,
|
||||
),
|
||||
child: _StatusHistoryEntry(update: updates[index]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
...List.generate(updates.length, (index) {
|
||||
final update = updates[index];
|
||||
final isLast = index == updates.length - 1;
|
||||
return _StatusHistoryEntry(update: update, isLast: isLast);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1330,10 +1429,9 @@ class StatusHistoryTimeline extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _StatusHistoryEntry extends StatelessWidget {
|
||||
const _StatusHistoryEntry({required this.update, required this.isLast});
|
||||
const _StatusHistoryEntry({required this.update});
|
||||
|
||||
final ChatStatusUpdate update;
|
||||
final bool isLast;
|
||||
|
||||
Color _indicatorColor(ConduitThemeExtension theme) {
|
||||
if (update.done == false) {
|
||||
@@ -1372,142 +1470,159 @@ class _StatusHistoryEntry extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
||||
child: Row(
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardBackground.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: theme.cardBorder.withValues(alpha: 0.5),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_indicatorIcon(), size: 18, color: indicatorColor),
|
||||
if (!isLast)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: Spacing.xxs),
|
||||
width: 2,
|
||||
height: 32,
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
Icon(_indicatorIcon(), size: 16, color: indicatorColor),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodySmall,
|
||||
color: theme.textSecondary,
|
||||
fontWeight: update.done == true
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (update.count != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Text(
|
||||
update.count == 1
|
||||
? 'Retrieved 1 source'
|
||||
: 'Retrieved ${update.count} sources',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondary,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (timestamp != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Text(
|
||||
_formatTimestamp(timestamp),
|
||||
style: TextStyle(
|
||||
color: theme.textSecondary.withValues(alpha: 0.8),
|
||||
fontSize: AppTypography.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
color: theme.textPrimary,
|
||||
fontWeight: update.done == true
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (update.count != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Text(
|
||||
update.count == 1
|
||||
? 'Retrieved 1 source'
|
||||
: 'Retrieved ${update.count} sources',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondary,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (timestamp != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Text(
|
||||
_formatTimestamp(timestamp),
|
||||
style: TextStyle(
|
||||
color: theme.textSecondary,
|
||||
fontSize: AppTypography.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (queries.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: queries.map((query) {
|
||||
return ActionChip(
|
||||
label: Text(query),
|
||||
avatar: const Icon(Icons.search, size: 16),
|
||||
onPressed: () {
|
||||
_launchUri(
|
||||
'https://www.google.com/search?q=${Uri.encodeComponent(query)}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
if (update.urls.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: update.urls.map((url) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _launchUri(url),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: Text(
|
||||
Uri.tryParse(url)?.host ?? 'Link',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
if (update.items.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.xxs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: update.items.map((item) {
|
||||
final title = item.title?.isNotEmpty == true
|
||||
? item.title!
|
||||
: item.link ?? 'Result';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: Spacing.xxs),
|
||||
child: InkWell(
|
||||
onTap: item.link != null
|
||||
? () => _launchUri(item.link!)
|
||||
: null,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.link, size: 16),
|
||||
const SizedBox(width: Spacing.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: item.link != null
|
||||
? theme.buttonPrimary
|
||||
: theme.textSecondary,
|
||||
decoration: item.link != null
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (queries.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.sm),
|
||||
child: Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: queries.map((query) {
|
||||
return _AssistantSuggestionChip(
|
||||
label: query,
|
||||
icon: Icons.search,
|
||||
onPressed: () {
|
||||
_launchUri(
|
||||
'https://www.google.com/search?q=${Uri.encodeComponent(query)}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
if (update.urls.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.sm),
|
||||
child: Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: update.urls.map((url) {
|
||||
final host = Uri.tryParse(url)?.host ?? 'Link';
|
||||
return _AssistantSuggestionChip(
|
||||
label: host,
|
||||
icon: Icons.open_in_new,
|
||||
onPressed: () => _launchUri(url),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
if (update.items.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: update.items.map((item) {
|
||||
final title = item.title?.isNotEmpty == true
|
||||
? item.title!
|
||||
: item.link ?? 'Result';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
child: InkWell(
|
||||
onTap: item.link != null
|
||||
? () => _launchUri(item.link!)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Spacing.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
size: 16,
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: item.link != null
|
||||
? theme.buttonPrimary
|
||||
: theme.textSecondary,
|
||||
decoration: item.link != null
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1772,7 +1887,7 @@ class CitationListView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class FollowUpSuggestionBar extends StatefulWidget {
|
||||
class FollowUpSuggestionBar extends StatelessWidget {
|
||||
const FollowUpSuggestionBar({
|
||||
super.key,
|
||||
required this.suggestions,
|
||||
@@ -1784,149 +1899,37 @@ class FollowUpSuggestionBar extends StatefulWidget {
|
||||
final ValueChanged<String> onSelected;
|
||||
final bool isBusy;
|
||||
|
||||
@override
|
||||
State<FollowUpSuggestionBar> createState() => _FollowUpSuggestionBarState();
|
||||
}
|
||||
|
||||
class _FollowUpSuggestionBarState extends State<FollowUpSuggestionBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 520),
|
||||
);
|
||||
if (widget.suggestions.isNotEmpty) {
|
||||
_controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FollowUpSuggestionBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!listEquals(oldWidget.suggestions, widget.suggestions)) {
|
||||
if (widget.suggestions.isEmpty) {
|
||||
_controller.reset();
|
||||
} else {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
if (widget.suggestions.isEmpty) {
|
||||
final trimmedSuggestions = suggestions
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
|
||||
if (trimmedSuggestions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final Animation<double> headerAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0, 0.35, curve: Curves.easeOutCubic),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: headerAnimation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: headerAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - headerAnimation.value) * 10),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Try next',
|
||||
style: TextStyle(
|
||||
color: theme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
return _AssistantResponseSection(
|
||||
title: 'Suggested next steps',
|
||||
icon: Icons.auto_awesome,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: [
|
||||
for (final suggestion in trimmedSuggestions)
|
||||
_AssistantSuggestionChip(
|
||||
label: suggestion,
|
||||
onPressed: isBusy ? null : () => onSelected(suggestion),
|
||||
enabled: !isBusy,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Wrap(
|
||||
spacing: Spacing.xs,
|
||||
runSpacing: Spacing.xs,
|
||||
children: [
|
||||
for (var i = 0; i < widget.suggestions.length; i++)
|
||||
_AnimatedSuggestionChip(
|
||||
controller: _controller,
|
||||
index: i,
|
||||
total: widget.suggestions.length,
|
||||
isBusy: widget.isBusy,
|
||||
suggestion: widget.suggestions[i],
|
||||
onSelected: widget.onSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedSuggestionChip extends StatelessWidget {
|
||||
const _AnimatedSuggestionChip({
|
||||
required this.controller,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.isBusy,
|
||||
required this.suggestion,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final AnimationController controller;
|
||||
final int index;
|
||||
final int total;
|
||||
final bool isBusy;
|
||||
final String suggestion;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
Interval _intervalForIndex() {
|
||||
if (total <= 1) {
|
||||
return const Interval(0.0, 0.8, curve: Curves.easeOutCubic);
|
||||
}
|
||||
final double step = 0.6 / total;
|
||||
final double start = (index * step).clamp(0.0, 0.8);
|
||||
final double end = (start + 0.4).clamp(0.2, 1.0);
|
||||
return Interval(start, end, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final animation = CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: _intervalForIndex(),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final double t = animation.value;
|
||||
return Opacity(
|
||||
opacity: t,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - t) * 12),
|
||||
child: Transform.scale(scale: 0.95 + (t * 0.05), child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: FilledButton.tonal(
|
||||
onPressed: isBusy ? null : () => onSelected(suggestion),
|
||||
child: Text(suggestion),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1937,6 +1940,6 @@ Future<void> _launchUri(String url) async {
|
||||
try {
|
||||
await launchUrlString(url, mode: LaunchMode.externalApplication);
|
||||
} catch (err) {
|
||||
debugPrint('Unable to open url $url: $err');
|
||||
DebugLogger.log('Unable to open url $url: $err', scope: 'chat/assistant');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart' hide debugPrint;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
@@ -14,11 +14,6 @@ import '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
|
||||
void debugPrint(String? message, {int? wrapWidth}) {
|
||||
if (message == null) return;
|
||||
DebugLogger.fromLegacy(message, scope: 'chat/image-attachment');
|
||||
}
|
||||
|
||||
// Simple global cache to prevent reloading
|
||||
final _globalImageCache = <String, String>{};
|
||||
final _globalLoadingStates = <String, bool>{};
|
||||
@@ -696,7 +691,10 @@ class FullScreenImageViewer extends ConsumerWidget {
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(file.path)]));
|
||||
} catch (e) {
|
||||
// Swallowing UI feedback per requirements; keep a log for debugging
|
||||
debugPrint('Failed to share image: $e');
|
||||
DebugLogger.log(
|
||||
'Failed to share image: $e',
|
||||
scope: 'chat/image-attachment',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user