Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
224
lib/Peach/designs/ActivityChart.dart
Normal file
224
lib/Peach/designs/ActivityChart.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../about/internal/application/DeploymentRecord.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
enum _Range { week, month, year }
|
||||
|
||||
class _Bucket {
|
||||
final String label;
|
||||
final int count;
|
||||
_Bucket(this.label, this.count);
|
||||
}
|
||||
|
||||
/// "Compute Activity" style card: a bar chart of deployment counts over time,
|
||||
/// with the peak column highlighted, and a Week / Month / Year toggle.
|
||||
class ActivityChart extends StatefulWidget {
|
||||
final List<DeploymentRecord> history;
|
||||
const ActivityChart({super.key, required this.history});
|
||||
|
||||
@override
|
||||
State<ActivityChart> createState() => _ActivityChartState();
|
||||
}
|
||||
|
||||
class _ActivityChartState extends State<ActivityChart> {
|
||||
_Range _range = _Range.week;
|
||||
|
||||
List<_Bucket> _buckets() {
|
||||
final now = DateTime.now();
|
||||
final h = widget.history;
|
||||
int between(DateTime a, DateTime b) =>
|
||||
h.where((r) => !r.time.isBefore(a) && r.time.isBefore(b)).length;
|
||||
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const mo = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
switch (_range) {
|
||||
case _Range.week:
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return List.generate(7, (i) {
|
||||
final d = today.subtract(Duration(days: 6 - i));
|
||||
return _Bucket(
|
||||
wd[d.weekday - 1],
|
||||
between(d, d.add(const Duration(days: 1))),
|
||||
);
|
||||
});
|
||||
case _Range.month:
|
||||
// last 6 weeks
|
||||
final startOfWeek = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(Duration(days: now.weekday - 1));
|
||||
return List.generate(6, (i) {
|
||||
final s = startOfWeek.subtract(Duration(days: (5 - i) * 7));
|
||||
final e = s.add(const Duration(days: 7));
|
||||
return _Bucket('${s.month}/${s.day}', between(s, e));
|
||||
});
|
||||
case _Range.year:
|
||||
// last 6 months
|
||||
return List.generate(6, (i) {
|
||||
final m = DateTime(now.year, now.month - (5 - i), 1);
|
||||
final next = DateTime(m.year, m.month + 1, 1);
|
||||
return _Bucket(mo[m.month - 1], between(m, next));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final buckets = _buckets();
|
||||
final max = buckets.fold(0, (m, b) => b.count > m ? b.count : m);
|
||||
final peakIdx = buckets.indexWhere((b) => b.count == max && max > 0);
|
||||
final total = buckets.fold(0, (n, b) => n + b.count);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
'Deployment activity',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
'$total deploys this period',
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_toggle(jp),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < buckets.length; i++)
|
||||
Expanded(child: _bar(jp, buckets[i], max, i == peakIdx)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bar(JpColors jp, _Bucket b, int max, bool peak) {
|
||||
final frac = max == 0 ? 0.0 : b.count / max;
|
||||
final h = 12 + frac * 130;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
text(
|
||||
'${b.count}',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: peak ? PeachTheme.seed : jp.textDim,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 26,
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
gradient: peak ? PeachTheme.brandGradient : null,
|
||||
color: peak ? null : jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
boxShadow: peak
|
||||
? PeachTheme.glow(PeachTheme.seed, opacity: 0.35, blur: 16)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
b.label,
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: peak ? jp.text : jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
weight: peak ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toggle(JpColors jp) {
|
||||
Widget seg(_Range r, String label) {
|
||||
final active = _range == r;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _range = r),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? jp.surface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
boxShadow: active
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: text(
|
||||
label,
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: active ? jp.text : jp.textDim,
|
||||
weight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
seg(_Range.week, 'Week'),
|
||||
seg(_Range.month, 'Month'),
|
||||
seg(_Range.year, 'Year'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/Peach/designs/Backdrop.dart
Normal file
59
lib/Peach/designs/Backdrop.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/Colors.dart';
|
||||
|
||||
/// App-wide ambient backdrop: a solid base with soft, blurred brand-tinted
|
||||
/// glows sitting behind the (transparent) scaffolds. Deliberately restrained —
|
||||
/// the warm vermilion anchors the brand, a cool counter-tone adds depth.
|
||||
class Backdrop extends StatelessWidget {
|
||||
final Widget child;
|
||||
const Backdrop({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: jp.bg),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Warm brand wash, upper-left.
|
||||
Positioned(
|
||||
top: -220,
|
||||
left: -180,
|
||||
child: _glow(PeachTheme.seed, dark ? 0.10 : 0.045, 560),
|
||||
),
|
||||
// Cool counter-tone, lower-right, for depth.
|
||||
Positioned(
|
||||
bottom: -240,
|
||||
right: -190,
|
||||
child: _glow(PeachTheme.violet, dark ? 0.10 : 0.04, 560),
|
||||
),
|
||||
// Faint high accent to lift the top of the canvas.
|
||||
Positioned(
|
||||
top: -320,
|
||||
right: 40,
|
||||
child: _glow(PeachTheme.accent, dark ? 0.05 : 0.02, 420),
|
||||
),
|
||||
Positioned.fill(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _glow(Color c, double opacity, double size) => IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
c.withValues(alpha: opacity),
|
||||
c.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
152
lib/Peach/designs/InsightsCard.dart
Normal file
152
lib/Peach/designs/InsightsCard.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../about/internal/application/ServerProfile.dart';
|
||||
import '../about/internal/application/DeploymentRecord.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// Warm gradient "insights" card that surfaces computed tips from the data.
|
||||
class InsightsCard extends StatelessWidget {
|
||||
final List<ServerProfile> servers;
|
||||
final List<DeploymentRecord> history;
|
||||
final VoidCallback onViewHistory;
|
||||
|
||||
const InsightsCard({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.history,
|
||||
required this.onViewHistory,
|
||||
});
|
||||
|
||||
List<(IconData, String)> _tips() {
|
||||
final tips = <(IconData, String)>[];
|
||||
final failed = history
|
||||
.where((h) => h.status == DeployStatus.failure)
|
||||
.length;
|
||||
final totalServices = servers.fold(0, (n, s) => n + s.services.length);
|
||||
|
||||
if (history.isEmpty) {
|
||||
tips.add((
|
||||
Icons.rocket_launch,
|
||||
'Add a service and run your first deploy to unlock insights.',
|
||||
));
|
||||
}
|
||||
if (failed > 0) {
|
||||
tips.add((
|
||||
Icons.warning_amber,
|
||||
'$failed failed ${failed == 1 ? 'deploy' : 'deploys'} — open the service logs to investigate.',
|
||||
));
|
||||
}
|
||||
// Busiest server
|
||||
if (history.isNotEmpty) {
|
||||
final counts = <String, int>{};
|
||||
for (final h in history) {
|
||||
counts[h.serverName] = (counts[h.serverName] ?? 0) + 1;
|
||||
}
|
||||
final busiest = counts.entries.reduce(
|
||||
(a, b) => a.value >= b.value ? a : b,
|
||||
);
|
||||
tips.add((
|
||||
Icons.trending_up,
|
||||
'${busiest.key} is your busiest server (${busiest.value} deploys).',
|
||||
));
|
||||
}
|
||||
final emptyServers = servers.where((s) => s.services.isEmpty).length;
|
||||
if (emptyServers > 0) {
|
||||
tips.add((
|
||||
Icons.dns,
|
||||
'$emptyServers ${emptyServers == 1 ? 'server has' : 'servers have'} no services yet — add one to start deploying.',
|
||||
));
|
||||
}
|
||||
if (totalServices > 0) {
|
||||
tips.add((
|
||||
Icons.health_and_safety,
|
||||
'Open Health to check the live status of your $totalServices ${totalServices == 1 ? 'service' : 'services'}.',
|
||||
));
|
||||
}
|
||||
return tips.take(3).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const ink = PeachTheme.ink;
|
||||
final tips = _tips();
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.peachGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, size: 18, color: ink),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'Insights',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: ink,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (final (icon, tip) in tips) ...[
|
||||
_tip(icon, tip),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onViewHistory,
|
||||
icon: const Icon(Icons.timeline, size: 16),
|
||||
label: const Text('View history'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: ink,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tip(IconData icon, String tip) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: PeachTheme.ink.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(
|
||||
tip,
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: PeachTheme.ink.withValues(alpha: 0.85),
|
||||
height: 1.35,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
249
lib/Peach/designs/LogConsole.dart
Normal file
249
lib/Peach/designs/LogConsole.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../comms/Comms.dart' show LogLine, LogLevel;
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// Reusable terminal-style log console. It stays dark in both light and dark
|
||||
/// app themes (like a code block), with colored, prefixed lines and auto-scroll.
|
||||
class LogConsole extends StatefulWidget {
|
||||
final List<LogLine> logs;
|
||||
final double? height;
|
||||
final bool live;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
/// When the caller is showing a filtered subset, the size of the unfiltered
|
||||
/// backlog — rendered as "shown / total" in the header.
|
||||
final int? totalCount;
|
||||
|
||||
/// Replaces the "Output will appear here." placeholder.
|
||||
final String? emptyMessage;
|
||||
|
||||
const LogConsole({
|
||||
super.key,
|
||||
required this.logs,
|
||||
this.height = 300,
|
||||
this.live = false,
|
||||
this.onClear,
|
||||
this.onCopy,
|
||||
this.totalCount,
|
||||
this.emptyMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LogConsole> createState() => _LogConsoleState();
|
||||
}
|
||||
|
||||
class _LogConsoleState extends State<LogConsole> {
|
||||
final _scroll = ScrollController();
|
||||
int _last = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LogConsole old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.logs.length != _last) {
|
||||
_last = widget.logs.length;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
_scroll.animateTo(
|
||||
_scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// A null height means the caller has already constrained us (e.g. inside
|
||||
// an Expanded), so we fill whatever we're given.
|
||||
return Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: context.jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: ConsoleColors.frame)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.terminal, size: 16, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'LOGS',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: ConsoleColors.header,
|
||||
letterSpacing: 1.5,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
if (widget.live) ...[
|
||||
const SizedBox(width: 10),
|
||||
const _LivePill(),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
text(
|
||||
widget.totalCount == null
|
||||
? '${widget.logs.length}'
|
||||
: '${widget.logs.length} / ${widget.totalCount}',
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: ConsoleColors.muted,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onCopy != null)
|
||||
IconButton(
|
||||
tooltip: 'Copy',
|
||||
iconSize: 16,
|
||||
color: ConsoleColors.header,
|
||||
onPressed: widget.logs.isEmpty ? null : widget.onCopy,
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
if (widget.onClear != null)
|
||||
_ClearButton(
|
||||
enabled: widget.logs.isNotEmpty,
|
||||
onTap: widget.onClear!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: widget.logs.isEmpty
|
||||
? Center(
|
||||
child: text(
|
||||
widget.emptyMessage ?? 'Output will appear here.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: ConsoleColors.disabled,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: widget.logs.length,
|
||||
itemBuilder: (_, i) => _row(widget.logs[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(LogLine l) {
|
||||
final (color, prefix) = switch (l.level) {
|
||||
LogLevel.error => (ConsoleColors.error, '✖ '),
|
||||
LogLevel.warn => (ConsoleColors.warn, '! '),
|
||||
LogLevel.success => (ConsoleColors.success, '✓ '),
|
||||
LogLevel.step => (ConsoleColors.step, '➤ '),
|
||||
LogLevel.info => (ConsoleColors.info, '· '),
|
||||
LogLevel.remote => (ConsoleColors.body, ' '),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: SelectableText(
|
||||
'$prefix${l.text}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.35,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Labeled "Clear" control: empties the on-screen backlog. New lines keep
|
||||
/// streaming in afterwards.
|
||||
class _ClearButton extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
const _ClearButton({required this.enabled, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'Clear the log backlog — new lines keep streaming',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ConsoleColors.frameRaised),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_sweep,
|
||||
size: 14,
|
||||
color: enabled ? ConsoleColors.header : ConsoleColors.disabled,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(
|
||||
'Clear',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: enabled ? ConsoleColors.header : ConsoleColors.disabled,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LivePill extends StatelessWidget {
|
||||
const _LivePill();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: ConsoleColors.live.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fiber_manual_record,
|
||||
size: 9,
|
||||
color: ConsoleColors.live,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
'LIVE',
|
||||
10,
|
||||
TextType.Regular,
|
||||
color: ConsoleColors.live,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
lib/Peach/designs/MetricsPanel.dart
Normal file
193
lib/Peach/designs/MetricsPanel.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../about/internal/application/DeploymentRecord.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
|
||||
/// Deployment metrics: success-rate ring plus stat tiles.
|
||||
class MetricsPanel extends StatelessWidget {
|
||||
final List<DeploymentRecord> history;
|
||||
const MetricsPanel({super.key, required this.history});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final total = history.length;
|
||||
final success = history
|
||||
.where((h) => h.status == DeployStatus.success)
|
||||
.length;
|
||||
final failed = history
|
||||
.where((h) => h.status == DeployStatus.failure)
|
||||
.length;
|
||||
final reverted = history
|
||||
.where((h) => h.status == DeployStatus.reverted)
|
||||
.length;
|
||||
final attempts = success + failed;
|
||||
final rate = attempts == 0 ? 0.0 : success / attempts;
|
||||
final last = history.isNotEmpty ? history.first : null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
'METRICS',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_rateRing(jp, rate, attempts),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_tile(jp, 'Total', '$total', jp.text),
|
||||
_tile(jp, 'Success', '$success', PeachTheme.seed),
|
||||
_tile(jp, 'Failed', '$failed', PeachTheme.danger),
|
||||
_tile(jp, 'Reverted', '$reverted', PeachTheme.warn),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (last != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Divider(height: 1, color: jp.border),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule, size: 14, color: jp.textFaint),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: text(
|
||||
'Last: ${last.serverName} · ${last.serviceLabel} · ${timeAgo(last.time)}',
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
_statusDot(last.status),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rateRing(JpColors jp, double rate, int attempts) {
|
||||
final pct = (rate * 100).round();
|
||||
final color = rate >= 0.8
|
||||
? PeachTheme.seed
|
||||
: rate >= 0.5
|
||||
? PeachTheme.warn
|
||||
: PeachTheme.danger;
|
||||
return SizedBox(
|
||||
width: 84,
|
||||
height: 84,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 84,
|
||||
height: 84,
|
||||
child: CircularProgressIndicator(
|
||||
value: attempts == 0 ? 0 : rate,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: jp.surface2,
|
||||
valueColor: AlwaysStoppedAnimation(color),
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
attempts == 0 ? '—' : '$pct%',
|
||||
20,
|
||||
TextType.Regular,
|
||||
color: color,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
text(
|
||||
'SUCCESS',
|
||||
8,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
letterSpacing: 1,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(JpColors jp, String label, String value, Color color) {
|
||||
return Container(
|
||||
width: 92,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.bg.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
value,
|
||||
20,
|
||||
TextType.Regular,
|
||||
color: color,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
text(
|
||||
label.toUpperCase(),
|
||||
9,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
letterSpacing: 0.8,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusDot(DeployStatus s) {
|
||||
final color = switch (s) {
|
||||
DeployStatus.success => PeachTheme.seed,
|
||||
DeployStatus.failure => PeachTheme.danger,
|
||||
DeployStatus.reverted => PeachTheme.warn,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(
|
||||
s.label,
|
||||
10,
|
||||
TextType.Regular,
|
||||
color: color,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/Peach/designs/OutputSheet.dart
Normal file
70
lib/Peach/designs/OutputSheet.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A scrollable, copyable console sheet for one-shot command output — docker
|
||||
/// logs, a kafka describe, a redis value. Raised from a screen rather than
|
||||
/// pushed, because the output is a peek, not a destination.
|
||||
void showOutputSheet(BuildContext context, String title, String output) {
|
||||
final jp = context.jp;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: jp.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) => SizedBox(
|
||||
height: MediaQuery.of(sheetContext).size.height * 0.7,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: text(title, 16, TextType.Bold, color: jp.text)),
|
||||
IconButton(
|
||||
tooltip: 'Copy',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: output));
|
||||
ScaffoldMessenger.of(
|
||||
sheetContext,
|
||||
).showSnackBar(const SnackBar(content: Text('Copied.')));
|
||||
},
|
||||
icon: const Icon(Icons.copy_all, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
output,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.4,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
110
lib/Peach/designs/RecentDeploys.dart
Normal file
110
lib/Peach/designs/RecentDeploys.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../about/internal/application/DeploymentRecord.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
|
||||
/// A compact "recently deployed" list of the latest deployments.
|
||||
class RecentDeploys extends StatelessWidget {
|
||||
final List<DeploymentRecord> history;
|
||||
final VoidCallback onViewAll;
|
||||
final int max;
|
||||
|
||||
const RecentDeploys({
|
||||
super.key,
|
||||
required this.history,
|
||||
required this.onViewAll,
|
||||
this.max = 5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final items = history.take(max).toList();
|
||||
return Container(
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.bolt, size: 16, color: PeachTheme.seed),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
'RECENTLY DEPLOYED',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: onViewAll, child: const Text('View all')),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(height: 1, indent: 16, endIndent: 16, color: jp.border),
|
||||
_row(jp, items[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(JpColors jp, DeploymentRecord r) {
|
||||
final (color, icon) = switch (r.status) {
|
||||
DeployStatus.success => (PeachTheme.seed, Icons.check_circle),
|
||||
DeployStatus.failure => (PeachTheme.danger, Icons.error),
|
||||
DeployStatus.reverted => (PeachTheme.warn, Icons.undo),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
'${r.serverName} · ${r.serviceLabel}',
|
||||
13.5,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
r.jarName.isEmpty ? r.remotePath : r.jarName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontFamily: 'monospace',
|
||||
color: jp.textFaint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
timeAgo(r.time),
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/Peach/designs/Responsive.dart
Normal file
58
lib/Peach/designs/Responsive.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// Picks one of three layouts for the current width. Screens are built as
|
||||
/// `_mobileView` / `_tabletView` / `_desktopView` methods on their State and
|
||||
/// handed to this widget, rather than as one layout littered with width checks.
|
||||
class Responsive extends StatelessWidget {
|
||||
final Widget mobile;
|
||||
final Widget tablet;
|
||||
final Widget desktop;
|
||||
|
||||
const Responsive({
|
||||
super.key,
|
||||
required this.desktop,
|
||||
required this.mobile,
|
||||
required this.tablet,
|
||||
});
|
||||
|
||||
/// mobile < 650
|
||||
static bool isMobile(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width < 650;
|
||||
|
||||
/// tablet >= 650
|
||||
static bool isTablet(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= 650;
|
||||
|
||||
/// desktop >= 1100
|
||||
static bool isDesktop(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= 1100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (isDesktop(context)) {
|
||||
return desktop;
|
||||
} else if (isTablet(context)) {
|
||||
return tablet;
|
||||
} else if (isMobile(context)) {
|
||||
return mobile;
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [text("NOT SUPPORTED", 12, TextType.Bold)],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/Peach/designs/StatCard.dart
Normal file
99
lib/Peach/designs/StatCard.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A horizontal metric card: icon chip on the left, big value + label on the
|
||||
/// right, with an optional trailing accent (e.g. a mini ring or delta).
|
||||
class StatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
final String? sub;
|
||||
final Widget? trailing;
|
||||
|
||||
const StatCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
this.sub,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, glowColor: color, radius: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
color.withValues(alpha: 0.32),
|
||||
color.withValues(alpha: 0.14),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
border: Border.all(color: color.withValues(alpha: 0.40)),
|
||||
boxShadow: PeachTheme.glow(color, opacity: 0.22, blur: 16),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
value,
|
||||
25,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
letterSpacing: -0.6,
|
||||
height: 1.05,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
label.toUpperCase(),
|
||||
10.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
letterSpacing: 0.8,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
if (sub != null)
|
||||
text(
|
||||
sub!,
|
||||
11,
|
||||
TextType.Regular,
|
||||
weight: FontWeight.w400,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/Peach/designs/StatsDonut.dart
Normal file
184
lib/Peach/designs/StatsDonut.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// Job-statistics style card: a donut gauge with the total in the centre, plus
|
||||
/// labelled progress bars underneath.
|
||||
class StatsDonut extends StatelessWidget {
|
||||
final int success;
|
||||
final int failed;
|
||||
final int reverted;
|
||||
const StatsDonut({
|
||||
super.key,
|
||||
required this.success,
|
||||
required this.failed,
|
||||
required this.reverted,
|
||||
});
|
||||
|
||||
int get _total => success + failed + reverted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final total = _total;
|
||||
double pct(int n) => total == 0 ? 0 : n / total;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text(
|
||||
'Deploy statistics',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(Icons.open_in_full, size: 14, color: jp.textDim),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 176,
|
||||
height: 176,
|
||||
child: CustomPaint(
|
||||
painter: _DonutPainter(
|
||||
track: jp.surface2,
|
||||
segments: [
|
||||
(pct(success), PeachTheme.seed),
|
||||
(pct(failed), PeachTheme.danger),
|
||||
(pct(reverted), PeachTheme.slate),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
'$total',
|
||||
38,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
height: 1,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
'Total deploys',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_bar(jp, 'Successful', success, pct(success), PeachTheme.seed),
|
||||
const SizedBox(height: 14),
|
||||
_bar(jp, 'Failed', failed, pct(failed), PeachTheme.danger),
|
||||
const SizedBox(height: 14),
|
||||
_bar(jp, 'Reverted', reverted, pct(reverted), PeachTheme.slate),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bar(JpColors jp, String label, int n, double frac, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text(
|
||||
label,
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
const Spacer(),
|
||||
text(
|
||||
'${(frac * 100).round()}%',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 8, color: jp.surface2),
|
||||
FractionallySizedBox(
|
||||
widthFactor: frac.clamp(0.0, 1.0),
|
||||
child: Container(height: 8, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DonutPainter extends CustomPainter {
|
||||
final List<(double, Color)> segments;
|
||||
final Color track;
|
||||
_DonutPainter({required this.segments, required this.track});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const stroke = 18.0;
|
||||
final rect =
|
||||
Offset(stroke / 2, stroke / 2) &
|
||||
Size(size.width - stroke, size.height - stroke);
|
||||
final trackPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..color = track;
|
||||
canvas.drawArc(rect, 0, 2 * math.pi, false, trackPaint);
|
||||
|
||||
const gap = 0.06;
|
||||
var start = -math.pi / 2;
|
||||
for (final (frac, color) in segments) {
|
||||
if (frac <= 0) continue;
|
||||
final full = frac * 2 * math.pi;
|
||||
final sweep = (full - gap).clamp(0.02, 2 * math.pi);
|
||||
final p = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color;
|
||||
canvas.drawArc(rect, start + gap / 2, sweep, false, p);
|
||||
start += full;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DonutPainter old) =>
|
||||
old.segments != segments || old.track != track;
|
||||
}
|
||||
161
lib/Peach/designs/StepList.dart
Normal file
161
lib/Peach/designs/StepList.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart' hide StepState;
|
||||
|
||||
import '../about/internal/application/StepTracker.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// Renders an ordered list of deploy/build steps with live state and, for the
|
||||
/// active step, an inline progress bar.
|
||||
class StepList extends StatelessWidget {
|
||||
final List<DeployStep> steps;
|
||||
const StepList({super.key, required this.steps});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final doneCount = steps.where((s) => s.state == StepState.done).length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text(
|
||||
'PROGRESS',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
const Spacer(),
|
||||
text(
|
||||
'$doneCount / ${steps.length}',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: PeachTheme.seed,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (var i = 0; i < steps.length; i++)
|
||||
_StepRow(step: steps[i], isLast: i == steps.length - 1, jp: jp),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepRow extends StatelessWidget {
|
||||
final DeployStep step;
|
||||
final bool isLast;
|
||||
final JpColors jp;
|
||||
const _StepRow({required this.step, required this.isLast, required this.jp});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = step.state == StepState.active;
|
||||
final (color, icon) = switch (step.state) {
|
||||
StepState.done => (PeachTheme.seed, Icons.check),
|
||||
StepState.active => (PeachTheme.seed, Icons.autorenew),
|
||||
StepState.failed => (PeachTheme.danger, Icons.close),
|
||||
StepState.skipped => (jp.textFaint, Icons.remove),
|
||||
StepState.pending => (jp.textFaint, Icons.circle_outlined),
|
||||
};
|
||||
final pct = step.progress == null
|
||||
? null
|
||||
: (step.progress! * 100).clamp(0, 100).toStringAsFixed(0);
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_dot(color, icon, active),
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
color: step.state == StepState.done
|
||||
? PeachTheme.seed.withValues(alpha: 0.4)
|
||||
: jp.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 14, top: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(
|
||||
step.label,
|
||||
13.5,
|
||||
TextType.Regular,
|
||||
color: step.state == StepState.pending
|
||||
? jp.textFaint
|
||||
: jp.text,
|
||||
weight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (pct != null && active)
|
||||
text(
|
||||
'$pct%',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: PeachTheme.seed,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (active && step.progress != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: step.progress,
|
||||
minHeight: 5,
|
||||
backgroundColor: jp.surface2,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dot(Color color, IconData icon, bool active) {
|
||||
return Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: active ? 0.18 : 0.12),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color.withValues(alpha: 0.6)),
|
||||
),
|
||||
child: active
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(5),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
)
|
||||
: Icon(icon, size: 15, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/Peach/designs/TransferCard.dart
Normal file
165
lib/Peach/designs/TransferCard.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
|
||||
import '../comms/Comms.dart' show TransferProgress;
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A descriptive, non-flat transfer panel: big percentage, a gradient progress
|
||||
/// bar, and live stats (transferred, speed, ETA).
|
||||
class TransferCard extends StatelessWidget {
|
||||
final TransferProgress progress;
|
||||
const TransferCard({super.key, required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final pct = progress.fraction == null
|
||||
? null
|
||||
: (progress.fraction! * 100).clamp(0, 100);
|
||||
final verb = progress.uploading ? 'Uploading' : 'Downloading';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.5)),
|
||||
boxShadow: [
|
||||
...PeachTheme.softShadow(Theme.of(context).brightness),
|
||||
...PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(
|
||||
progress.uploading ? Icons.cloud_upload : Icons.cloud_download,
|
||||
color: PeachTheme.seed,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
verb,
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
const Spacer(),
|
||||
text(
|
||||
pct == null ? '—' : '${pct.toStringAsFixed(0)}%',
|
||||
26,
|
||||
TextType.Regular,
|
||||
color: PeachTheme.seed,
|
||||
height: 1,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 10, color: jp.surface2),
|
||||
FractionallySizedBox(
|
||||
widthFactor: (progress.fraction ?? 0).clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [PeachTheme.seed, PeachTheme.accent],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_stat(
|
||||
jp,
|
||||
Icons.data_usage,
|
||||
'Transferred',
|
||||
'${_bytes(progress.sent)} / ${_bytes(progress.total)}',
|
||||
),
|
||||
_sep(jp),
|
||||
_stat(
|
||||
jp,
|
||||
Icons.speed,
|
||||
'Speed',
|
||||
'${_bytes(progress.bytesPerSec.round())}/s',
|
||||
),
|
||||
_sep(jp),
|
||||
_stat(jp, Icons.timer_outlined, 'ETA', _eta(progress.eta)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sep(JpColors jp) => Container(width: 1, height: 30, color: jp.border);
|
||||
|
||||
Widget _stat(JpColors jp, IconData icon, String label, String value) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: jp.textFaint),
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
label.toUpperCase(),
|
||||
9,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
letterSpacing: 0.8,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: jp.text,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _bytes(int b) {
|
||||
if (b < 1024) return '$b B';
|
||||
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(0)} KB';
|
||||
if (b < 1024 * 1024 * 1024) {
|
||||
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(b / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
static String _eta(Duration? d) {
|
||||
if (d == null) return '—';
|
||||
final s = d.inSeconds;
|
||||
if (s <= 0) return '0s';
|
||||
if (s < 60) return '${s}s';
|
||||
final m = s ~/ 60;
|
||||
final rem = s % 60;
|
||||
if (m < 60) return '${m}m ${rem}s';
|
||||
final h = m ~/ 60;
|
||||
return '${h}h ${m % 60}m';
|
||||
}
|
||||
}
|
||||
96
lib/Peach/designs/input/InputFields.dart
Normal file
96
lib/Peach/designs/input/InputFields.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
/// The standard labelled text field. Every form in the app uses this rather
|
||||
/// than building a bare [TextFormField], so labels, spacing and validation
|
||||
/// messages stay identical everywhere.
|
||||
Widget inputField({
|
||||
required String label,
|
||||
required String initial,
|
||||
required void Function(String) onSaved,
|
||||
String? hint,
|
||||
bool required = false,
|
||||
bool obscure = false,
|
||||
TextInputType? keyboardType,
|
||||
Widget? suffix,
|
||||
String? Function(String?)? validator,
|
||||
void Function(String)? onChanged,
|
||||
TextEditingController? controller,
|
||||
int? maxLines = 1,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextFormField(
|
||||
initialValue: controller == null ? initial : null,
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: obscure ? 1 : maxLines,
|
||||
enabled: enabled,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
suffixIcon: suffix,
|
||||
),
|
||||
validator:
|
||||
validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
onSaved: (v) => onSaved(v ?? ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A section heading inside a form.
|
||||
Widget inputSection(BuildContext context, String title) => Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
child: text(
|
||||
title.toUpperCase(),
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
);
|
||||
|
||||
/// A one-line explanatory note under a field.
|
||||
Widget inputHint(BuildContext context, IconData icon, String message) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 2, bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: context.jp.textFaint),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: text(
|
||||
message,
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: context.jp.textFaint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// A labelled switch row, matching the field spacing.
|
||||
Widget inputSwitch({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required void Function(bool) onChanged,
|
||||
}) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
title: text(title, 14, TextType.Regular, color: context.jp.text),
|
||||
subtitle: text(subtitle, 12, TextType.Light, color: context.jp.textDim),
|
||||
);
|
||||
281
lib/Peach/designs/navigation/SideNav.dart
Normal file
281
lib/Peach/designs/navigation/SideNav.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
|
||||
import '../../utils/Colors.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
class NavItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final int? badge;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
const NavItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.badge,
|
||||
this.selected = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Left navigation rail / sidebar. [expanded] shows labels; otherwise a
|
||||
/// compact icon rail.
|
||||
class SideNav extends StatelessWidget {
|
||||
final bool expanded;
|
||||
final List<NavItemData> items;
|
||||
final ThemeMode themeMode;
|
||||
final Future<void> Function() onCycleTheme;
|
||||
final VoidCallback onNewServer;
|
||||
|
||||
const SideNav({
|
||||
super.key,
|
||||
required this.expanded,
|
||||
required this.items,
|
||||
required this.themeMode,
|
||||
required this.onCycleTheme,
|
||||
required this.onNewServer,
|
||||
});
|
||||
|
||||
(IconData, String) get _themeIcon => switch (themeMode) {
|
||||
ThemeMode.dark => (Icons.dark_mode, 'Dark'),
|
||||
ThemeMode.light => (Icons.light_mode, 'Light'),
|
||||
ThemeMode.system => (Icons.brightness_auto, 'System'),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jp = context.jp;
|
||||
final (themeI, themeLabel) = _themeIcon;
|
||||
return Container(
|
||||
width: expanded ? 236 : 78,
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(right: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: SafeArea(
|
||||
right: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(expanded ? 20 : 0, 20, 0, 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: expanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.brandGradient,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 18,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.rocket_launch,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
text(
|
||||
'Peach',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
weight: FontWeight.w800,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
|
||||
children: [for (final it in items) _navItem(context, it)],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
|
||||
child: Column(
|
||||
children: [
|
||||
_footerButton(
|
||||
context,
|
||||
themeI,
|
||||
themeLabel,
|
||||
(_) => onCycleTheme(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_newServerButton(context),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _navItem(BuildContext context, NavItemData it) {
|
||||
final jp = context.jp;
|
||||
final inner = Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: expanded ? 14 : 0,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: it.selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.13)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: expanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
it.icon,
|
||||
size: 20,
|
||||
color: it.selected ? PeachTheme.seed : jp.textDim,
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: text(
|
||||
it.label,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: it.selected ? jp.text : jp.textDim,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
weight: it.selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (it.badge != null && it.badge! > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(
|
||||
'${it.badge}',
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
weight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
// A premium left accent bar marks the selected item.
|
||||
final content = Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 3,
|
||||
height: it.selected ? 22 : 0,
|
||||
margin: EdgeInsets.only(right: expanded ? 8 : 0),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
Expanded(child: inner),
|
||||
],
|
||||
),
|
||||
);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: it.onTap,
|
||||
child: expanded ? content : Tooltip(message: it.label, child: content),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _footerButton(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
void Function(BuildContext) onTap,
|
||||
) {
|
||||
final jp = context.jp;
|
||||
final w = InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => onTap(context),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: expanded ? 14 : 0,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: expanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 19, color: jp.textDim),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
text(
|
||||
'Theme: $label',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return expanded ? w : Tooltip(message: 'Theme: $label', child: w);
|
||||
}
|
||||
|
||||
Widget _newServerButton(BuildContext context) {
|
||||
if (!expanded) {
|
||||
return Tooltip(
|
||||
message: 'New server',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onNewServer,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.brandGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 16,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onNewServer,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New server'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/Peach/designs/text/Text.dart
Normal file
67
lib/Peach/designs/text/Text.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
|
||||
/// Resolves a [TextType] role to the font family that carries it. Display copy
|
||||
/// (Bold) uses Sora; body and secondary copy use Urbanist and PlusJakartaSans.
|
||||
String getTextType(TextType type) {
|
||||
switch (type) {
|
||||
case TextType.Bold:
|
||||
return 'Sora';
|
||||
case TextType.Regular:
|
||||
return 'Urbanist';
|
||||
case TextType.Light:
|
||||
return 'PlusJakartaSans';
|
||||
}
|
||||
}
|
||||
|
||||
/// The only way display copy is drawn. Never build a raw `Text`/`TextStyle`
|
||||
/// for user-facing strings — go through here so family, weight and colour stay
|
||||
/// consistent across every screen.
|
||||
///
|
||||
/// [color] defaults to the theme's primary text colour when a [BuildContext]
|
||||
/// is supplied, so a call site rarely needs to name a colour at all.
|
||||
Widget text(
|
||||
String text,
|
||||
double textSize,
|
||||
TextType type, {
|
||||
BuildContext? context,
|
||||
Color? color,
|
||||
TextAlign? align,
|
||||
int? maxLines,
|
||||
TextOverflow? overflow,
|
||||
double? letterSpacing,
|
||||
double? height,
|
||||
double? wordSpacing,
|
||||
TextDecoration? decoration,
|
||||
FontWeight? weight,
|
||||
}) {
|
||||
return Text(
|
||||
text,
|
||||
textAlign: align ?? TextAlign.left,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
style: TextStyle(
|
||||
decoration: decoration ?? TextDecoration.none,
|
||||
color: color ?? (context != null ? context.jp.text : colorPrimaryDark),
|
||||
fontSize: textSize,
|
||||
fontFamily: getTextType(type),
|
||||
letterSpacing: letterSpacing,
|
||||
height: height,
|
||||
wordSpacing: wordSpacing,
|
||||
fontWeight: weight ?? _weightFor(type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FontWeight _weightFor(TextType type) {
|
||||
switch (type) {
|
||||
case TextType.Bold:
|
||||
return FontWeight.w700;
|
||||
case TextType.Regular:
|
||||
return FontWeight.w500;
|
||||
case TextType.Light:
|
||||
return FontWeight.w400;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user