Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
723
lib/Peach/see/home/HomeState.dart
Normal file
723
lib/Peach/see/home/HomeState.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user