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