723 lines
24 KiB
Dart
723 lines
24 KiB
Dart
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();
|
|
}
|
|
}
|