import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/DeploymentRecord.dart'; import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/ServiceConfig.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../configs/ThemeController.dart'; import '../../designs/ActivityChart.dart'; import '../../designs/InsightsCard.dart'; import '../../designs/RecentDeploys.dart'; import '../../designs/Responsive.dart'; import '../../designs/StatCard.dart'; import '../../designs/StatsDonut.dart'; import '../../designs/navigation/SideNav.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../health/Health.dart'; import '../history/History.dart'; import '../server/detail/ServerDetail.dart'; import '../server/editor/ServerEditor.dart'; import '../server/setup/ServerSetup.dart'; import 'ConnectHome.dart'; import 'Home.dart'; import 'ViewHome.dart'; enum HomeView { list, grid } class HomeState extends State implements ConnectHome { static const _viewKey = 'peach.homeview'; ViewHome? _model; List _servers = []; List _history = []; HomeView _view = HomeView.grid; @override void initState() { super.initState(); _servers = widget.servers; _history = widget.history; } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewHome(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => LayoutBuilder( // The rail only earns its space on a real desktop width, so the // breakpoint here is wider than Responsive's tablet cut-off. builder: (context, constraints) => Responsive( mobile: _mobileView(), tablet: constraints.maxWidth >= 900 ? _desktopView(constraints.maxWidth) : _tabletView(), desktop: _desktopView(constraints.maxWidth), ), ), ); } void _onReady(ViewHome 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 { await _loadView(); _model?.loadDashboard(); } // ── derived state ────────────────────────────────────────────────────────── int get _success => _history.where((h) => h.status == DeployStatus.success).length; int get _failed => _history.where((h) => h.status == DeployStatus.failure).length; int get _reverted => _history.where((h) => h.status == DeployStatus.reverted).length; int get _rate { final attempted = _success + _failed; return attempted == 0 ? 0 : ((_success / attempted) * 100).round(); } int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length); // ── view preference ──────────────────────────────────────────────────────── Future _loadView() async { final prefs = await SharedPreferences.getInstance(); final stored = prefs.getString(_viewKey); if (stored == HomeView.list.name && mounted) { setState(() => _view = HomeView.list); } } Future _setView(HomeView v) async { setState(() => _view = v); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_viewKey, v.name); } void _onGridView() => _setView(HomeView.grid); void _onListView() => _setView(HomeView.list); // ── named handlers ───────────────────────────────────────────────────────── void _onCycleTheme() => _model?.cycleTheme(ThemeController.name); Future _onNewServer() async { final created = await PeachNavigation().navigateToPageWithData( const ServerEditor(), context, ); if (created is! ServerProfile) return; _model?.saveServer(created); if (!mounted) return; // Guided environment setup for the freshly-registered server. await PeachNavigation().navigateToPageWithData( ServerSetup(server: created), context, ); } Future _onOpenServer(ServerProfile server) async { await PeachNavigation().navigateToPageWithData( ServerDetail(server: server, history: _history), context, ); // The detail screen can add, edit or remove services and record deploys. _model?.loadDashboard(); } Future _onOpenHistory() async { await PeachNavigation().navigateToPageWithData( History(history: _history), context, ); _model?.loadDashboard(); } void _onOpenHealth() { PeachNavigation().navigateToPage( NavigatorType.justOpen, Health(servers: _servers), context, ); } // ── ConnectHome ──────────────────────────────────────────────────────────── @override void onDashboardLoaded( List servers, List history, ) { if (!mounted) return; setState(() { _servers = servers; _history = history; }); } @override void onServerSaved(ServerProfile server) { // The dashboard reload that follows carries the new value; nothing extra // to do here beyond keeping the interface honest. } @override void onThemeChanged(String mode) { ThemeController.apply(mode); } // ── layouts ──────────────────────────────────────────────────────────────── Widget _desktopView(double width) { return Scaffold( body: Row( children: [ SideNav( expanded: width >= 1140, themeMode: ThemeController.mode.value, onCycleTheme: () async => _onCycleTheme(), onNewServer: _onNewServer, items: [ NavItemData( icon: Icons.grid_view_rounded, label: 'Dashboard', selected: true, onTap: _onDashboardTap, ), NavItemData( icon: Icons.monitor_heart, label: 'Health', badge: _totalServices, onTap: _onOpenHealth, ), NavItemData( icon: Icons.history, label: 'History', badge: _history.length, onTap: _onOpenHistory, ), ], ), Expanded(child: _mainArea()), ], ), ); } /// Tablet sits between the two: the dashboard body without the side rail. Widget _tabletView() => Scaffold(body: _mainArea()); Widget _mobileView() { final jp = context.jp; final (themeIcon, themeTip) = switch (ThemeController.mode.value) { ThemeMode.dark => (Icons.dark_mode, 'Dark theme'), ThemeMode.light => (Icons.light_mode, 'Light theme'), ThemeMode.system => (Icons.brightness_auto, 'System theme'), }; return Scaffold( appBar: AppBar( titleSpacing: 20, title: Row( children: [ Icon(Icons.rocket_launch, color: colorPrimary), const SizedBox(width: 10), text('Peach', 17, TextType.Bold, color: jp.text), ], ), actions: [ IconButton( tooltip: themeTip, onPressed: _onCycleTheme, icon: Icon(themeIcon), ), _viewToggle(jp), const SizedBox(width: 4), IconButton( tooltip: 'Health', onPressed: _onOpenHealth, icon: const Icon(Icons.monitor_heart), ), IconButton( tooltip: 'History', onPressed: _onOpenHistory, icon: const Icon(Icons.history), ), const SizedBox(width: 8), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _onNewServer, icon: const Icon(Icons.add), label: text('New server', 14, TextType.Bold, color: colorWhite), ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), children: [ _statRow(), const SizedBox(height: 16), _bento(), const SizedBox(height: 24), _sectionHeader('SERVERS', _servers.length), const SizedBox(height: 12), if (_servers.isEmpty) _emptyServers() else _serversWrap(), ], ), ); } void _onDashboardTap() {} // ── screen-only widgets ──────────────────────────────────────────────────── Widget _mainArea() { final jp = context.jp; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(28, 22, 28, 8), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text('Dashboard', 26, TextType.Bold, color: jp.text), const SizedBox(height: 2), text( '${_servers.length} servers · ${_history.length} deployments', 14, TextType.Regular, color: jp.textDim, ), ], ), ), _viewToggle(jp), ], ), ), ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(28, 8, 28, 32), children: [ _statRow(), const SizedBox(height: 16), _bento(), const SizedBox(height: 24), _sectionHeader('SERVERS', _servers.length), const SizedBox(height: 12), if (_servers.isEmpty) _emptyServers() else _serversWrap(), ], ), ), ], ); } Widget _bento() { final activity = ActivityChart(history: _history); final donut = StatsDonut( success: _success, failed: _failed, reverted: _reverted, ); final insights = InsightsCard( servers: _servers, history: _history, onViewHistory: _onOpenHistory, ); final recent = RecentDeploys( history: _history, onViewAll: _onOpenHistory, max: 5, ); return LayoutBuilder( builder: (context, c) { if (c.maxWidth <= 760) { return Column( children: [ activity, const SizedBox(height: 16), donut, const SizedBox(height: 16), insights, if (_history.isNotEmpty) ...[const SizedBox(height: 16), recent], ], ); } return Column( children: [ IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(flex: 5, child: activity), const SizedBox(width: 16), Expanded(flex: 3, child: donut), ], ), ), const SizedBox(height: 16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 5, child: insights), const SizedBox(width: 16), Expanded( flex: 3, child: _history.isEmpty ? const SizedBox() : recent, ), ], ), ], ); }, ); } Widget _statRow() { return LayoutBuilder( builder: (context, c) { const gap = 14.0; final cols = c.maxWidth >= 900 ? 4 : c.maxWidth >= 560 ? 2 : 1; final w = (c.maxWidth - gap * (cols - 1)) / cols; final cards = [ StatCard( icon: Icons.rocket_launch, label: 'Deployments', value: '${_history.length}', color: PeachTheme.accent, ), StatCard( icon: Icons.verified, label: 'Success rate', value: _history.isEmpty ? '—' : '$_rate%', color: PeachTheme.seed, ), StatCard( icon: Icons.error_outline, label: 'Failed', value: '$_failed', color: PeachTheme.danger, ), StatCard( icon: Icons.dns, label: 'Servers', value: '${_servers.length}', color: PeachTheme.violet, ), ]; return Wrap( spacing: gap, runSpacing: gap, children: [for (final card in cards) SizedBox(width: w, child: card)], ); }, ); } Widget _serversWrap() { if (_view == HomeView.list) { return Column( children: [ for (final s in _servers) Padding( padding: const EdgeInsets.only(bottom: 12), child: _serverListCard(s), ), ], ); } return LayoutBuilder( builder: (context, c) { const gap = 14.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 s in _servers) SizedBox(width: w, child: _serverGridCard(s)), ], ); }, ); } int _deployCount(ServerProfile s) => _history.where((h) => h.serverId == s.id).length; Widget _serverListCard(ServerProfile server) { final jp = context.jp; final count = _deployCount(server); return Card( child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _onOpenServer(server), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ _iconTile(46, 13), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( server.name, 16, TextType.Bold, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), text( '${server.username}@${server.host}:${server.port}', 13, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), _kindChips(server), const SizedBox(width: 14), if (count > 0) text( '$count deploys', 12, TextType.Regular, color: jp.textFaint, ), const SizedBox(width: 8), Icon(Icons.chevron_right, color: jp.textFaint), ], ), ), ), ); } Widget _serverGridCard(ServerProfile server) { final jp = context.jp; final count = _deployCount(server); return Card( child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _onOpenServer(server), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _iconTile(42, 12), const Spacer(), if (count > 0) text('$count', 13, TextType.Bold, color: jp.textFaint), ], ), const SizedBox(height: 14), text( server.name, 16, TextType.Bold, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), text( '${server.username}@${server.host}', 12, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 14), _kindChips(server), ], ), ), ), ); } Widget _iconTile(double size, double radius) => Container( width: size, height: size, decoration: BoxDecoration( gradient: LinearGradient( colors: [ PeachTheme.seed.withValues(alpha: 0.28), PeachTheme.seed.withValues(alpha: 0.10), ], ), borderRadius: BorderRadius.circular(radius), border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.35)), ), child: const Icon(Icons.dns, color: PeachTheme.seed), ); Widget _kindChips(ServerProfile s) { final jp = context.jp; var jar = 0, web = 0; for (final svc in s.services) { if (svc.kind == ServiceKind.webApp) { web++; } else { jar++; } } Widget chip(IconData icon, int n, Color color) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: color), const SizedBox(width: 4), text('$n', 11, TextType.Bold, color: color), ], ), ); if (jar == 0 && web == 0) { return text('no services', 11, TextType.Regular, color: jp.textFaint); } return Wrap( spacing: 6, children: [ if (jar > 0) chip(Icons.settings_suggest, jar, PeachTheme.seed), if (web > 0) chip(Icons.language, web, PeachTheme.accent), ], ); } Widget _sectionHeader(String title, int count) { return Row( children: [ text( title, 12, TextType.Bold, color: context.jp.textDim, letterSpacing: 1.2, ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), decoration: BoxDecoration( color: PeachTheme.seed.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(20), ), child: text('$count', 11, TextType.Bold, color: PeachTheme.seed), ), ], ); } Widget _viewToggle(JpColors jp) { Widget btn(HomeView v, IconData icon, String tip, VoidCallback onTap) { final active = _view == v; return IconButton( tooltip: tip, onPressed: onTap, icon: Icon(icon, color: active ? PeachTheme.seed : jp.textFaint), ); } return Container( decoration: BoxDecoration( color: jp.surface2, borderRadius: BorderRadius.circular(12), border: Border.all(color: jp.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ btn(HomeView.grid, Icons.grid_view, 'Grid view', _onGridView), btn( HomeView.list, Icons.view_agenda_outlined, 'List view', _onListView, ), ], ), ); } Widget _emptyServers() { final jp = context.jp; return Container( padding: const EdgeInsets.symmetric(vertical: 48), decoration: PeachTheme.panel(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.dns, size: 56, color: PeachTheme.seed.withValues(alpha: 0.6), ), const SizedBox(height: 16), text('No servers yet', 20, TextType.Bold, color: jp.text), const SizedBox(height: 6), text( 'Register a server, then add its services.', 14, TextType.Regular, color: jp.textDim, ), const SizedBox(height: 20), FilledButton.icon( onPressed: _onNewServer, icon: const Icon(Icons.add), label: text('New server', 14, TextType.Bold, color: colorWhite), ), ], ), ); } }