Add app source and tests

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

View File

@@ -0,0 +1,722 @@
import 'dart:async';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../about/external/data/PeachError.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/Severity.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/file/FileStorage.dart';
import '../../comms/Comms.dart';
import '../../designs/text/Text.dart';
import '../../informatics/AppDataManager.dart';
import '../../informatics/DataManager.dart';
import '../../memory/InternalMemory.dart';
import '../../utils/Colors.dart';
/// The base every ViewModel extends. It owns the three cross-cutting overlays
/// (loading, unreachable-server, error), the single [DataManager] instance, and
/// the one error decision tree — so no screen has to reimplement any of them.
///
/// The Autoreceptive original guards calls with a *connectivity* check, which
/// suits an app talking to one public API. Peach talks to whichever box the
/// user configured — often on a LAN or VPN where the device has no internet at
/// all — so the equivalent guard here is [canReach]: an actual SSH handshake
/// with the target server.
class ParentViewModel extends ChangeNotifier {
OverlayEntry? loadingEntry;
OverlayEntry? networkEntry;
OverlayEntry? errorEntry;
late DataManager dataManager;
BuildContext context;
OverlayState? overlayState;
ParentViewModel(this.context) {
overlayState = Overlay.of(context);
dataManager = AppDataManager(InternalMemory(), SshService(), FileStorage());
}
DataManager getDataManager() {
return dataManager;
}
// ── Loading overlay ────────────────────────────────────────────────────────
void showLoading(String loadingText) async {
if (loadingEntry != null) return;
_hideKeyboard();
final jp = context.jp;
loadingEntry = OverlayEntry(
builder: (context) {
return Scaffold(
backgroundColor: jp.bg.withValues(alpha: 0.86),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 5,
color: colorPrimary,
backgroundColor: jp.surface2,
),
),
const SizedBox(height: 24),
text(
loadingText,
18,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 8),
text(
'Working on the server — keep this window open.',
13,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
),
],
),
),
);
},
);
overlayState?.insert(loadingEntry!);
notifyListeners();
}
void closeLoading() {
if (loadingEntry != null) {
loadingEntry?.remove();
loadingEntry = null;
notifyListeners();
}
}
// ── Unreachable-server overlay ─────────────────────────────────────────────
/// Shown when the target server can't be reached at all. [actions] is the
/// retry closure that re-runs whatever the user was doing.
void noNetwork(Function() actions) async {
if (networkEntry != null) return;
_hideKeyboard();
final jp = context.jp;
networkEntry = OverlayEntry(
builder: (context) {
Widget hintRow(IconData icon, String label) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: colorPrimary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: colorPrimary),
),
const SizedBox(width: 12),
Expanded(
child: text(label, 13, TextType.Regular, color: jp.textDim),
),
],
),
);
return Scaffold(
backgroundColor: jp.bg,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 116,
height: 116,
decoration: BoxDecoration(
color: colorPrimary.withValues(alpha: 0.08),
shape: BoxShape.circle,
border: Border.all(
color: colorPrimary.withValues(alpha: 0.20),
width: 2,
),
),
child: Icon(
Icons.cloud_off_rounded,
size: 44,
color: colorPrimary,
),
),
const SizedBox(height: 28),
text(
"Server unreachable",
22,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 10),
text(
"Peach couldn't open an SSH session to this server.",
14,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: jp.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: jp.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
hintRow(
Icons.dns_outlined,
'Check the host and port are correct',
),
Divider(height: 1, color: jp.border),
hintRow(
Icons.vpn_key_outlined,
'Confirm the username and password still work',
),
Divider(height: 1, color: jp.border),
hintRow(
Icons.lan_outlined,
'Make sure you are on the right network or VPN',
),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: actions,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: text(
'Try again',
14,
TextType.Bold,
color: colorWhite,
),
),
),
],
),
),
),
),
);
},
);
overlayState?.insert(networkEntry!);
notifyListeners();
}
void closeNetwork() {
if (networkEntry != null) {
networkEntry?.remove();
networkEntry = null;
notifyListeners();
}
}
/// Guard every server-bound call with this: it opens (or reuses) an SSH
/// session to [server] and, on failure, raises the unreachable overlay wired
/// to [actions] and returns false so the caller can bail out early.
Future<bool> canReach(ServerProfile server, Function() actions) async {
closeNetwork();
try {
await getDataManager().connect(server);
return true;
} catch (_) {
closeLoading();
noNetwork(actions);
return false;
}
}
// ── Error overlay ──────────────────────────────────────────────────────────
void showError(
Function() actions,
Function() closeActions,
PeachError error,
String buttonText,
) async {
if (errorEntry != null) return;
_hideKeyboard();
final jp = context.jp;
final bool critical = error.severity == Severity.critical.name;
errorEntry = OverlayEntry(
builder: (context) {
return Scaffold(
backgroundColor: jp.bg,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: colorNegative.withValues(alpha: 0.10),
shape: BoxShape.circle,
border: Border.all(
color: colorNegative.withValues(alpha: 0.22),
width: 2,
),
),
child: Icon(
critical
? Icons.gpp_maybe_rounded
: Icons.error_outline_rounded,
size: 40,
color: colorNegative,
),
),
),
const SizedBox(height: 24),
text(
error.title,
22,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 10),
text(
error.message,
14,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
height: 1.5,
),
if (error.helper.isNotEmpty) ...[
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: jp.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: jp.border),
),
child: text(
error.helper,
13,
TextType.Regular,
color: jp.textDim,
height: 1.55,
),
),
],
const SizedBox(height: 12),
Center(
child: text(
'Code ${error.code}',
11,
TextType.Light,
color: jp.textFaint,
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
dismissError();
actions();
},
child: text(
buttonText,
14,
TextType.Bold,
color: colorWhite,
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () {
dismissError();
closeActions();
},
child: text(
'Dismiss',
13,
TextType.Regular,
color: jp.textDim,
),
),
],
),
),
),
),
);
},
);
overlayState?.insert(errorEntry!);
notifyListeners();
}
void dismissError() {
if (errorEntry != null) {
errorEntry?.remove();
errorEntry = null;
notifyListeners();
}
}
/// The standard `catch` handler — every ViewModel routes failures here so the
/// same class of problem always reads the same way to the user. Closes the
/// loading overlay first, then maps the failure onto a [PeachError].
void handleError(
Object? error,
Function() actions,
Function() closeAction,
String buttonName,
) {
closeLoading();
if (error is SSHAuthAbortError || error is SSHAuthFailError) {
showError(
actions,
closeAction,
PeachError(
code: 4010.01,
message: "The server rejected these credentials.",
helper:
"Check the username and password on this server profile. If "
"the account uses a key or 2FA, SSH password auth may be "
"disabled on the host.",
title: "Authentication failed",
severity: Severity.critical.name,
),
buttonName,
);
} else if (error is SocketException || error is SSHStateError) {
showError(
actions,
closeAction,
PeachError(
code: 5000.01,
message: "Peach couldn't reach the server.",
helper:
"Confirm the host and port, that the machine is powered on, "
"and that you're on the network or VPN that can see it.",
title: "Connection failed",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is TimeoutException) {
showError(
actions,
closeAction,
PeachError(
code: 5000.02,
message: "The server took too long to respond.",
helper:
"The connection is slow or the command is still running. "
"Retry, or check the server's load.",
title: "Timed out",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is SSHError) {
showError(
actions,
closeAction,
PeachError(
code: 5100.00,
message: "The SSH session failed while running this operation.",
helper: error.toString(),
title: "SSH error",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is FileSystemException) {
showError(
actions,
closeAction,
PeachError(
code: 6100.01,
message: "A local file couldn't be read or written.",
helper:
"Check the artifact path still exists and that Peach has "
"permission to read it.",
title: "File error",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is Exception) {
showError(
actions,
closeAction,
PeachError(
code: 6000.01,
message: "An error occurred while processing your request.",
helper: error.toString(),
title: "Peach Error",
severity: Severity.message.name,
),
buttonName,
);
} else {
showError(
actions,
closeAction,
PeachError(
code: 8700.02,
message: "An error occurred while processing your request.",
helper:
"Relaunch Peach and try again. If the problem persists, "
"check the server logs for what happened.",
title: "Peach Error",
severity: Severity.message.name,
),
buttonName,
);
}
}
// ── In-app notification sheet ──────────────────────────────────────────────
/// A modal bottom sheet for confirmations and outcomes. [positiveAction]
/// must call `Navigator.pop(context)` first to dismiss the sheet before it
/// navigates anywhere.
Future<void> showApplicationNotification(
NotificationType type,
String title,
String description,
bool enableDrag,
bool barrierDismiss,
VoidCallback? closeAction, {
String? action,
VoidCallback? positiveAction,
}) async {
final jp = context.jp;
Color actionColor;
IconData actionIconData;
switch (type) {
case NotificationType.success:
actionIconData = Icons.check_circle_rounded;
actionColor = colorPositive;
break;
case NotificationType.info:
actionIconData = Icons.info_rounded;
actionColor = colorAccent;
break;
case NotificationType.warning:
actionIconData = Icons.warning_amber_rounded;
actionColor = colorWarmYellow;
break;
case NotificationType.error:
actionIconData = Icons.error_rounded;
actionColor = colorNegative;
break;
}
final bool hasAction = action != null && positiveAction != null;
await showModalBottomSheet(
context: context,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
backgroundColor: Colors.transparent,
elevation: 0,
enableDrag: enableDrag,
isDismissible: barrierDismiss,
isScrollControlled: true,
builder: (BuildContext context) {
return Container(
decoration: BoxDecoration(
color: jp.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
border: Border.all(color: jp.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: jp.border,
borderRadius: BorderRadius.circular(999),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: actionColor.withValues(alpha: 0.2),
),
),
child: Icon(
actionIconData,
size: 28,
color: actionColor,
),
),
IconButton(
onPressed: () {
Navigator.pop(context);
closeAction?.call();
},
icon: Icon(
Icons.close_rounded,
size: 18,
color: jp.textDim,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: actionColor.withValues(alpha: 0.2),
),
),
child: text(
type.name.toUpperCase(),
10,
TextType.Bold,
color: actionColor,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
text(title, 20, TextType.Bold, color: jp.text),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: actionColor.withValues(alpha: 0.15),
),
),
child: text(
description,
13,
TextType.Regular,
color: jp.textDim,
height: 1.6,
),
),
const SizedBox(height: 24),
if (hasAction) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: positiveAction,
style: FilledButton.styleFrom(
backgroundColor: actionColor,
),
icon: Icon(actionIconData, size: 16),
label: text(
action,
14,
TextType.Bold,
color: colorWhite,
),
),
),
const SizedBox(height: 12),
],
Center(
child: TextButton(
onPressed: () {
Navigator.pop(context);
closeAction?.call();
},
child: text(
hasAction ? "Cancel" : "Got it, dismiss",
12,
TextType.Regular,
color: jp.textDim,
),
),
),
],
),
),
],
),
);
},
);
}
void _hideKeyboard() {
SystemChannels.textInput.invokeMethod('TextInput.hide');
}
@override
void dispose() {
closeLoading();
closeNetwork();
dismissError();
super.dispose();
}
}