Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,723 @@
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<Home> implements ConnectHome {
static const _viewKey = 'peach.homeview';
ViewHome? _model;
List<ServerProfile> _servers = [];
List<DeploymentRecord> _history = [];
HomeView _view = HomeView.grid;
@override
void initState() {
super.initState();
_servers = widget.servers;
_history = widget.history;
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHome>.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<void> _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<void> _loadView() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_viewKey);
if (stored == HomeView.list.name && mounted) {
setState(() => _view = HomeView.list);
}
}
Future<void> _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<void> _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<void> _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<void> _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<ServerProfile> servers,
List<DeploymentRecord> 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),
),
],
),
);
}
}