Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend. Implements CQRS architecture, OpenAPI contract-first API design. BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama FRONTEND: Cross-platform UI with strict typing and Result-based error handling Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
468 lines
14 KiB
Dart
468 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:iconsax/iconsax.dart';
|
|
import 'package:animate_do/animate_do.dart';
|
|
import 'package:timeago/timeago.dart' as timeago;
|
|
import '../api/api.dart';
|
|
|
|
/// Conversations management page
|
|
///
|
|
/// Displays all conversations with details about messages, activity, and status.
|
|
/// Integrates with backend CQRS API for conversation listing and management.
|
|
class ConversationsPage extends StatefulWidget {
|
|
const ConversationsPage({super.key});
|
|
|
|
@override
|
|
State<ConversationsPage> createState() => _ConversationsPageState();
|
|
}
|
|
|
|
class _ConversationsPageState extends State<ConversationsPage> {
|
|
final CqrsApiClient _apiClient = CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
);
|
|
|
|
List<ConversationListItemDto>? _conversations;
|
|
bool _isLoading = true;
|
|
String? _errorMessage;
|
|
String _filterStatus = 'all'; // all, active, inactive
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadConversations();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_apiClient.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadConversations() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final Result<List<ConversationListItemDto>> result =
|
|
await _apiClient.listConversations();
|
|
|
|
result.when(
|
|
success: (List<ConversationListItemDto> conversations) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_conversations = conversations;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
},
|
|
error: (ApiErrorInfo error) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = error.message;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
List<ConversationListItemDto> get _filteredConversations {
|
|
if (_conversations == null) return [];
|
|
|
|
switch (_filterStatus) {
|
|
case 'active':
|
|
return _conversations!.where((c) => c.isActive).toList();
|
|
case 'inactive':
|
|
return _conversations!.where((c) => !c.isActive).toList();
|
|
default:
|
|
return _conversations!;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header with actions
|
|
_buildHeader(colorScheme),
|
|
const SizedBox(height: 24),
|
|
|
|
// Filter chips
|
|
_buildFilterChips(colorScheme),
|
|
const SizedBox(height: 24),
|
|
|
|
// Conversations list
|
|
Expanded(
|
|
child: _buildConversationsList(colorScheme),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(ColorScheme colorScheme) {
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
Iconsax.messages_3,
|
|
color: colorScheme.primary,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Conversations',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
_conversations != null
|
|
? '${_conversations!.length} total conversations'
|
|
: 'Loading...',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Iconsax.refresh, color: colorScheme.primary),
|
|
onPressed: _loadConversations,
|
|
tooltip: 'Refresh conversations',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChips(ColorScheme colorScheme) {
|
|
return Row(
|
|
children: [
|
|
_buildFilterChip('All', 'all', colorScheme),
|
|
const SizedBox(width: 8),
|
|
_buildFilterChip('Active', 'active', colorScheme),
|
|
const SizedBox(width: 8),
|
|
_buildFilterChip('Inactive', 'inactive', colorScheme),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip(
|
|
String label,
|
|
String value,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final bool isSelected = _filterStatus == value;
|
|
|
|
return FilterChip(
|
|
label: Text(label),
|
|
selected: isSelected,
|
|
onSelected: (bool selected) {
|
|
setState(() {
|
|
_filterStatus = value;
|
|
});
|
|
},
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
selectedColor: colorScheme.primaryContainer,
|
|
labelStyle: TextStyle(
|
|
color: isSelected
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurface,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildConversationsList(ColorScheme colorScheme) {
|
|
if (_isLoading) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(color: colorScheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Loading conversations...',
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_errorMessage != null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Iconsax.warning_2,
|
|
size: 48,
|
|
color: colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load conversations',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_errorMessage!,
|
|
style: TextStyle(color: colorScheme.error),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _loadConversations,
|
|
icon: const Icon(Iconsax.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final List<ConversationListItemDto> filtered = _filteredConversations;
|
|
|
|
if (filtered.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Iconsax.message_text,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No conversations found',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_filterStatus == 'all'
|
|
? 'Start a conversation to see it here'
|
|
: 'No $_filterStatus conversations',
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: filtered.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return FadeInUp(
|
|
duration: Duration(milliseconds: 300 + (index * 50)),
|
|
child: _buildConversationCard(filtered[index], colorScheme),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildConversationCard(
|
|
ConversationListItemDto conversation,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
// TODO: Navigate to conversation detail
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content:
|
|
Text('View conversation: ${conversation.title} (not implemented)'),
|
|
),
|
|
);
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Icon
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: conversation.isActive
|
|
? colorScheme.primaryContainer
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Iconsax.messages_3,
|
|
color: conversation.isActive
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurfaceVariant,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Content
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Title and status
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
conversation.title,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: conversation.isActive
|
|
? colorScheme.primaryContainer
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
conversation.isActive ? 'Active' : 'Inactive',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: conversation.isActive
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Summary
|
|
if (conversation.summary != null)
|
|
Text(
|
|
conversation.summary!,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Stats
|
|
Row(
|
|
children: [
|
|
_buildStat(
|
|
Iconsax.message_text,
|
|
'${conversation.messageCount}',
|
|
'messages',
|
|
colorScheme,
|
|
),
|
|
const SizedBox(width: 16),
|
|
_buildStat(
|
|
Iconsax.flash_1,
|
|
'${conversation.executionCount}',
|
|
'executions',
|
|
colorScheme,
|
|
),
|
|
const Spacer(),
|
|
Icon(
|
|
Iconsax.clock,
|
|
size: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
timeago.format(conversation.lastMessageAt),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStat(
|
|
IconData icon,
|
|
String value,
|
|
String label,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 16,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|