Files

258 lines
6.9 KiB
Dart

import 'package:ac_web/Api/AcApi.dart';
import 'package:ac_web/Api/Dtos/AgentDto.dart';
import 'package:ac_web/Api/Dtos/AgentTaskDto.dart';
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
import 'package:ac_web/Widgets/AgentTaskCard.dart';
import 'package:ac_web/Widgets/AgentTypeIcons.dart';
import 'package:ac_web/Widgets/AutoRefreshState.dart';
import 'package:flutter/material.dart';
/// Page that shows every detail of a single agent, including all of its tasks
/// together with the status updates the agent reported for them.
class AgentDetails extends StatefulWidget {
const AgentDetails({super.key, required this.agent});
/// The agent as it was known when the page got opened.
final AgentDto agent;
@override
State<AgentDetails> createState() => _AgentDetailsState();
}
class _AgentDetailsState extends State<AgentDetails>
with AutoRefreshState<AgentDetails> {
late AgentDto mAgent;
bool mIsReloading = false;
@override
void initState() {
super.initState();
mAgent = widget.agent;
}
/// Reload triggered by the user through the app bar or by pulling the page
/// down. Reports what happened, even when nothing changed.
Future<void> ReloadAgent() => _reloadAgent(silent: false);
/// Automatic reload. It runs without the user asking for it, so it neither
/// disables the reload button nor pops up a message - the page simply keeps
/// showing the agent it already has.
@override
Future<void> RefreshData() => _reloadAgent(silent: true);
/// The api only offers the whole list of agents, so the agent this page
/// shows is looked up by its name again.
Future<void> _reloadAgent({required bool silent}) async {
if (mIsReloading) {
return;
}
mIsReloading = true;
if (!silent) {
setState(() {});
}
try {
final List<AgentDto> agents = await AcApi.instance.agents.getAgents();
final AgentDto? updated = agents
.where((agent) => agent.name == mAgent.name)
.firstOrNull;
if (!mounted) {
return;
}
setState(() {
mIsReloading = false;
if (updated != null) {
mAgent = updated;
}
});
if (updated == null && !silent) {
_showMessage("This agent is no longer reported by the api.");
}
} catch (error) {
if (!mounted) {
return;
}
setState(() {
mIsReloading = false;
});
if (!silent) {
_showMessage("Could not reload the agent: $error");
}
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(mAgent.name.isEmpty ? "Agent" : mAgent.name),
actions: [
IconButton(
onPressed: mIsReloading ? null : ReloadAgent,
icon: const Icon(Icons.refresh),
tooltip: "Reload",
),
],
),
body: RefreshIndicator(
onRefresh: ReloadAgent,
child: ListView(
padding: EdgeInsetsGeometry.all(16),
children: [
_buildHeader(context),
const SizedBox(height: 12),
_buildInfoRow(context, "Type", mAgent.type.displayName),
const SizedBox(height: 8),
_buildStatusRow(context),
const SizedBox(height: 8),
_buildInfoRow(context, "Last seen", _formatTimestamp(mAgent.lastSeen.toLocal())),
const SizedBox(height: 8),
_buildInfoRow(context, "Tasks", _taskCountLabel()),
const Divider(height: 32),
Text("Tasks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
..._buildTasks(context),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
child: Icon(mAgent.type.icon, color: Theme.of(context).colorScheme.onSurface),
),
Expanded(
child: Text(
mAgent.name.isEmpty ? "Unnamed Agent" : mAgent.name,
style: Theme.of(context).textTheme.headlineSmall,
),
),
],
);
}
/// Same layout as the other info rows, but with the colored status circle.
Widget _buildStatusRow(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
SizedBox(
width: 96,
child: Text("Status", style: Theme.of(context).textTheme.labelLarge),
),
AgentStatusIndicator(status: mAgent.status, size: 14),
const SizedBox(width: 4),
Expanded(child: Text(mAgent.status.displayName)),
],
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
SizedBox(
width: 96,
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
),
Expanded(child: SelectableText(value)),
],
);
}
/// One card per task, newest first, with the newest one already expanded.
List<Widget> _buildTasks(BuildContext context) {
if (!mAgent.hasTasks) {
return [
Text(
"This agent is currently not working on any task.",
style: Theme.of(context).textTheme.bodyMedium,
),
];
}
final List<AgentTaskDto> tasks = mAgent.tasks.toList()
..sort(_byStartTimeDescending);
return [
for (int i = 0; i < tasks.length; i++)
AgentTaskCard(task: tasks[i], initiallyExpanded: i == 0),
];
}
/// Newest task first. Tasks without a start time are kept at the end.
int _byStartTimeDescending(AgentTaskDto left, AgentTaskDto right) {
if (left.startTime == null && right.startTime == null) {
return 0;
}
if (left.startTime == null) {
return 1;
}
if (right.startTime == null) {
return -1;
}
return right.startTime!.compareTo(left.startTime!);
}
String _taskCountLabel() {
final int count = mAgent.tasks.length;
if (count == 0) {
return "No running tasks";
}
return count == 1 ? "1 running task" : "$count running tasks";
}
/// "15. August 2025, 14:32"
String _formatTimestamp(DateTime timestamp) {
final String hour = timestamp.hour.toString().padLeft(2, '0');
final String minute = timestamp.minute.toString().padLeft(2, '0');
return "${timestamp.day}. ${_monthNames[timestamp.month - 1]} ${timestamp.year}, $hour:$minute";
}
static const List<String> _monthNames = <String>[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
}