Files
PeachDeployments/lib/Peach/see/history/HistoryState.dart
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

338 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectHistory.dart';
import 'History.dart';
import 'ViewHistory.dart';
class HistoryState extends State<History> implements ConnectHistory {
ViewHistory? _model;
List<DeploymentRecord> _history = [];
@override
void initState() {
super.initState();
_history = widget.history;
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHistory>.reactive(
viewModelBuilder: () => ViewHistory(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Deployment history',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
if (_history.isNotEmpty)
IconButton(
tooltip: 'Clear history',
onPressed: _onClear,
icon: const Icon(Icons.delete_sweep),
),
const SizedBox(width: 8),
],
),
body: _history.isEmpty ? _empty() : _timeline(),
),
);
}
void _onReady(ViewHistory model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_model?.loadHistory();
}
// ── derived state ──────────────────────────────────────────────────────────
/// Records (already newest-first) grouped by calendar day.
List<MapEntry<DateTime, List<DeploymentRecord>>> get _groups {
final map = <DateTime, List<DeploymentRecord>>{};
for (final r in _history) {
final day = DateTime(r.time.year, r.time.month, r.time.day);
map.putIfAbsent(day, () => []).add(r);
}
return map.entries.toList()..sort((a, b) => b.key.compareTo(a.key));
}
String _dayLabel(DateTime d) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final diff = today.difference(d).inDays;
if (diff == 0) return 'Today';
if (diff == 1) return 'Yesterday';
if (diff < 7) return '$diff days ago';
String two(int n) => n.toString().padLeft(2, '0');
return '${d.year}-${two(d.month)}-${two(d.day)}';
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onClear() {
_model?.showApplicationNotification(
NotificationType.warning,
'Clear history?',
'This deletes all tracked deployment records. It cannot be undone.',
true,
true,
null,
action: 'Clear history',
positiveAction: _onConfirmClear,
);
}
void _onConfirmClear() {
Navigator.pop(context);
_model?.clearHistory();
}
// ── ConnectHistory ─────────────────────────────────────────────────────────
@override
void onHistoryLoaded(List<DeploymentRecord> history) {
if (!mounted) return;
setState(() => _history = history);
}
@override
void onHistoryCleared() {
if (!mounted) return;
setState(() => _history = []);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _empty() => Center(
child: text(
'No deployments recorded yet.',
14,
TextType.Regular,
color: context.jp.textDim,
),
);
Widget _timeline() {
final groups = _groups;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
itemCount: groups.length,
itemBuilder: (context, i) => _dayGroup(
_dayLabel(groups[i].key),
groups[i].value,
i == groups.length - 1,
),
);
}
Widget _dayGroup(String label, List<DeploymentRecord> records, bool isLast) {
final jp = context.jp;
final ok = records.where((r) => r.status == DeployStatus.success).length;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline rail: dot + connecting line.
Column(
children: [
Container(
width: 14,
height: 14,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: PeachTheme.seed,
shape: BoxShape.circle,
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 10,
),
),
),
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: isLast ? Colors.transparent : jp.border,
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 26),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text(label, 16, TextType.Bold, color: jp.text),
const SizedBox(width: 8),
text(
'${records.length} deploys · $ok ok',
12,
TextType.Regular,
color: jp.textFaint,
),
],
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, c) {
const gap = 12.0;
final cols = (c.maxWidth / 300).floor().clamp(1, 4);
final w = (c.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final r in records)
SizedBox(width: w, child: _timelineTile(r)),
],
);
},
),
],
),
),
),
],
),
);
}
Widget _timelineTile(DeploymentRecord record) {
final jp = context.jp;
final (color, icon) = switch (record.status) {
DeployStatus.success => (PeachTheme.seed, Icons.check_circle),
DeployStatus.failure => (PeachTheme.danger, Icons.error),
DeployStatus.reverted => (PeachTheme.warn, Icons.undo),
};
String two(int n) => n.toString().padLeft(2, '0');
final clock = '${two(record.time.hour)}:${two(record.time.minute)}';
return Container(
padding: const EdgeInsets.all(14),
decoration: PeachTheme.panel(context, radius: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(clock, 15, TextType.Bold, color: jp.text),
text(
timeAgo(record.time),
11,
TextType.Regular,
color: jp.textFaint,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
),
child: text(
record.status.label,
10.5,
TextType.Bold,
color: color,
),
),
],
),
const SizedBox(height: 12),
text(
'${record.serverName} · ${record.serviceLabel}',
14,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_metaRow(
Icons.inventory_2,
record.jarName.isEmpty ? '—' : record.jarName,
12,
jp.textDim,
),
const SizedBox(height: 3),
_metaRow(
Icons.folder_outlined,
record.remotePath,
11.5,
jp.textFaint,
),
if (record.message.isNotEmpty) ...[
const SizedBox(height: 8),
text(
record.message,
11.5,
TextType.Regular,
color: jp.textDim,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
Widget _metaRow(IconData icon, String label, double size, Color color) => Row(
children: [
Icon(icon, size: 12, color: context.jp.textFaint),
const SizedBox(width: 5),
Expanded(
child: text(
label,
size,
TextType.Regular,
color: color,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}