TermRemoteCtl/apps/mobile_app/lib/features/projects/project_detail_page.dart

377 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/app_theme.dart';
import '../../app/ui_shell.dart';
import '../../core/network/agent_connection_providers.dart';
import '../../core/network/agent_error_formatter.dart';
import '../sessions/session.dart';
import '../terminal/terminal_page.dart';
import 'project.dart';
class ProjectDetailPage extends ConsumerStatefulWidget {
const ProjectDetailPage({super.key, required this.project});
final Project project;
@override
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
}
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
late Future<ProjectDetail> _detailFuture;
@override
void initState() {
super.initState();
_detailFuture = _loadDetail();
}
Future<ProjectDetail> _loadDetail() {
return ref
.read(projectRepositoryProvider)
.getProjectDetail(widget.project.projectId);
}
Future<void> _refreshDetail() async {
setState(() {
_detailFuture = _loadDetail();
});
await _detailFuture;
}
Future<void> _openNewTerminal() async {
final repository = ref.read(sessionRepositoryProvider);
try {
final session = await repository.createSession(
projectId: widget.project.projectId,
);
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TerminalPage(
session: session,
agentBaseUri: ref.read(agentBaseUriProvider),
project: widget.project,
),
),
);
await _refreshDetail();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to open terminal.'),
),
),
);
}
}
Future<void> _editProject(Project project) async {
final nameController = TextEditingController(text: project.name);
final pathController = TextEditingController(
text: project.workingDirectory,
);
try {
final shouldSave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Project name'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Working directory',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Save'),
),
],
),
);
if (shouldSave != true) {
return;
}
await ref
.read(projectRepositoryProvider)
.updateProject(
projectId: project.projectId,
name: nameController.text.trim(),
workingDirectory: pathController.text.trim(),
);
await _refreshDetail();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to update project.'),
),
),
);
}
}
Future<void> _openExistingSession(Session session, Project project) async {
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TerminalPage(
session: session,
agentBaseUri: ref.read(agentBaseUriProvider),
project: project,
),
),
);
if (!mounted) {
return;
}
await _refreshDetail();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to refresh project.'),
),
),
);
}
}
Future<void> _deleteProject(Project project) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete project'),
content: Text(
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (shouldDelete != true) {
return;
}
try {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
ref.invalidate(projectsProvider);
ref.invalidate(sessionsProvider);
if (!mounted) {
return;
}
Navigator.of(context).pop();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to delete project.'),
),
),
);
}
}
@override
Widget build(BuildContext context) {
final isCompact = MediaQuery.sizeOf(context).width < 460;
return Scaffold(
appBar: AppBar(
title: Text(widget.project.name),
actions: [
IconButton(
onPressed: _refreshDetail,
tooltip: 'Refresh project',
icon: const Icon(Icons.refresh),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openNewTerminal,
icon: const Icon(Icons.add),
label: const Text('Open terminal'),
),
body: FutureBuilder<ProjectDetail>(
future: _detailFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
formatAgentError(
snapshot.error!,
fallback: 'Failed to load project.',
),
),
),
);
}
final detail = snapshot.data!;
final project = detail.project;
return ListView(
padding: AppTheme.pagePadding,
children: [
AppPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
project.workingDirectory,
maxLines: isCompact ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
if (isCompact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: _openNewTerminal,
icon: const Icon(Icons.terminal),
label: const Text('Open terminal'),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _editProject(project),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _deleteProject(project),
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
),
],
),
],
)
else
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: _openNewTerminal,
icon: const Icon(Icons.terminal),
label: const Text('Open terminal'),
),
OutlinedButton.icon(
onPressed: () => _editProject(project),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
OutlinedButton.icon(
onPressed: () => _deleteProject(project),
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
),
],
),
),
const SizedBox(height: 16),
Text(
'Recent sessions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
if (detail.recentSessions.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('No recent sessions for this project yet.'),
),
)
else
...detail.recentSessions.map(
(session) => Card(
child: ListTile(
onTap: () => _openExistingSession(session, project),
leading: const Icon(Icons.terminal),
title: Text(session.name),
subtitle: Text('Status: ${session.status}'),
),
),
),
],
);
},
),
);
}
}