Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
337
lib/Peach/see/history/HistoryState.dart
Normal file
337
lib/Peach/see/history/HistoryState.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user