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 implements ConnectHistory { ViewHistory? _model; List _history = []; @override void initState() { super.initState(); _history = widget.history; } @override Widget build(BuildContext context) { return ViewModelBuilder.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 _initiate() async { _model?.loadHistory(); } // ── derived state ────────────────────────────────────────────────────────── /// Records (already newest-first) grouped by calendar day. List>> get _groups { final map = >{}; 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 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 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, ), ), ], ); }