import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/ServiceConfig.dart'; import '../../about/internal/application/TextType.dart'; import '../../comms/Comms.dart' show LogLine; import '../../designs/LogConsole.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import 'ConnectLogs.dart'; import 'Logs.dart'; import 'ViewLogs.dart'; class LogsState extends State implements ConnectLogs { ViewLogs? _model; StreamSubscription? _logSub; final _lines = []; final _searchController = TextEditingController(); bool _loading = true; bool _following = false; bool _busy = false; String _status = 'unknown'; String _query = ''; DateTimeRange? _range; ServerProfile get _server => widget.server; ServiceConfig get _service => widget.service; List get _filtered { if (_query.trim().isEmpty) return _lines; final q = _query.toLowerCase(); return _lines.where((l) => l.text.toLowerCase().contains(q)).toList(); } @override void dispose() { _logSub?.cancel(); _model?.stopLogs(); _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewLogs(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( '${_service.name} · logs', 20, TextType.Bold, color: context.jp.text, ), actions: [ IconButton( tooltip: _range == null ? 'Filter by date' : 'Change date range', onPressed: _busy ? null : _onPickRange, icon: Icon( Icons.date_range, color: _range != null ? PeachTheme.seed : null, ), ), IconButton( tooltip: 'Download logs', onPressed: _onDownload, icon: const Icon(Icons.download), ), IconButton( tooltip: 'Refresh', onPressed: _loading || _busy ? null : _onRefresh, icon: const Icon(Icons.refresh), ), const SizedBox(width: 4), ], ), body: Column( children: [ _header(), _controlBar(), _searchBar(), if (_loading || _busy) const LinearProgressIndicator(minHeight: 3), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), child: LogConsole( logs: _filtered, height: null, live: _following, totalCount: _query.isEmpty ? null : _lines.length, emptyMessage: _loading ? 'Loading…' : (_query.isNotEmpty ? 'No matches.' : 'No log output.'), onClear: _onClearBacklog, onCopy: _onCopy, ), ), ), _bottomBar(), ], ), ), ); } void _onReady(ViewLogs 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 { _logSub = _model?.logs.listen(_onLogLine); _model?.fetchLogs(_server, _service); _model?.loadStatus(_server, _service); } void _onLogLine(LogLine line) { if (!mounted) return; setState(() => _lines.add(line)); } // ── status helpers ───────────────────────────────────────────────────────── bool get _isActive => _status.contains('active') && !_status.contains('inactive'); bool get _hasUnit => _status != 'no-unit'; (Color, IconData) get _statusStyle { if (_status.contains('failed')) return (PeachTheme.danger, Icons.error); if (_isActive) return (PeachTheme.seed, Icons.play_circle); if (_status.contains('activating')) { return (PeachTheme.warn, Icons.hourglass_top); } return (context.jp.textFaint, Icons.stop_circle); } String _unitLabel() { if (_service.isWeb) { final unit = _service.nginxService.isEmpty ? 'nginx' : _service.nginxService; return 'nginx: $unit'; } return _service.serviceName.isEmpty ? '(no unit)' : 'unit: ${_service.serviceName}'; } // ── named handlers ───────────────────────────────────────────────────────── void _onRefresh() { setState(() { _loading = true; _following = false; _lines.clear(); }); _model?.fetchLogs( _server, _service, since: _range?.start, until: _range?.end, ); } void _onStart() => _control('start'); void _onStop() => _control('stop'); void _onRestart() => _control('restart'); void _control(String action) { setState(() { _busy = true; _following = false; }); _model?.control(_server, _service, action); } Future _onPickRange() async { final now = DateTime.now(); final picked = await showDateRangePicker( context: context, firstDate: DateTime(now.year - 3), lastDate: now, initialDateRange: _range, ); if (picked == null) return; setState( () => _range = DateTimeRange( start: DateTime( picked.start.year, picked.start.month, picked.start.day, ), end: DateTime( picked.end.year, picked.end.month, picked.end.day, 23, 59, 59, ), ), ); _onRefresh(); } void _onClearRange() { setState(() => _range = null); _onRefresh(); } void _onDownload() { final lines = _filtered.map((l) => l.text).toList(); if (lines.isEmpty) { _notify('Nothing to download.'); return; } _model?.saveLogs(_service.name, lines); } void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following); void _onClearBacklog() => setState(() => _lines.clear()); void _onCopy() { Clipboard.setData( ClipboardData(text: _filtered.map((l) => l.text).join('\n')), ); _notify('Logs copied.'); } void _onSearchChanged(String v) => setState(() => _query = v); void _onClearSearch() { _searchController.clear(); setState(() => _query = ''); } void _notify(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ── ConnectLogs ──────────────────────────────────────────────────────────── @override void onLogsFetched() { if (!mounted) return; setState(() => _loading = false); } @override void onStatusLoaded(String status) { if (!mounted) return; setState(() => _status = status); } @override void onControlFinished(String message) { if (!mounted) return; setState(() => _busy = false); _notify(message); } @override void onFollowChanged(bool following) { if (!mounted) return; setState(() => _following = following); } @override void onLogsSaved(String? path) { _notify(path == null ? 'Download cancelled' : 'Saved to $path'); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _header() { final jp = context.jp; final (color, icon) = _statusStyle; return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), decoration: BoxDecoration( color: jp.surface, border: Border(bottom: BorderSide(color: jp.border)), ), child: Row( children: [ const Icon(Icons.dns, size: 15, color: PeachTheme.seed), const SizedBox(width: 6), Expanded( child: text( '${_server.username}@${_server.host} · ${_unitLabel()}', 12.5, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withValues(alpha: 0.5)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 13, color: color), const SizedBox(width: 5), text( _status.toUpperCase(), 10.5, TextType.Bold, color: color, letterSpacing: 0.4, ), ], ), ), ], ), ); } Widget _controlBar() { if (!_hasUnit) return const SizedBox.shrink(); return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(12, 10, 12, 4), child: Wrap( spacing: 8, runSpacing: 8, children: [ // Contextual: Start only when stopped, Stop only when running. if (!_isActive) _ctlButton(Icons.play_arrow, 'Start', PeachTheme.seed, _onStart), if (_isActive) _ctlButton(Icons.stop, 'Stop', PeachTheme.danger, _onStop), _ctlButton( Icons.restart_alt, _service.isWeb ? 'Reload' : 'Restart', PeachTheme.accent, _onRestart, ), if (_range != null) _rangeChip(), ], ), ); } Widget _ctlButton( IconData icon, String label, Color color, VoidCallback onTap, ) { return OutlinedButton.icon( onPressed: _busy ? null : onTap, icon: Icon(icon, size: 18), label: text(label, 13, TextType.Bold, color: color), style: OutlinedButton.styleFrom( foregroundColor: color, side: BorderSide(color: color.withValues(alpha: 0.5)), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ); } Widget _rangeChip() { String d(DateTime t) => '${t.month}/${t.day}'; return InputChip( avatar: const Icon(Icons.date_range, size: 16), label: Text('${d(_range!.start)} – ${d(_range!.end)}'), onDeleted: _onClearRange, onPressed: _onPickRange, ); } Widget _searchBar() { return Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), child: TextField( controller: _searchController, onChanged: _onSearchChanged, decoration: InputDecoration( hintText: 'Search logs…', prefixIcon: const Icon(Icons.search, size: 20), suffixIcon: _query.isEmpty ? null : IconButton( icon: const Icon(Icons.close, size: 18), onPressed: _onClearSearch, ), isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 12), ), ), ); } Widget _bottomBar() { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _busy ? null : _onToggleFollow, icon: Icon(_following ? Icons.stop_circle : Icons.stream), label: text( _following ? 'Stop live tail' : 'Live tail', 14, TextType.Bold, color: colorWhite, ), style: _following ? FilledButton.styleFrom( backgroundColor: PeachTheme.danger, foregroundColor: colorWhite, ) : null, ), ), ), ); } }