724 lines
21 KiB
Dart
724 lines
21 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|