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 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 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(); } }