import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../about/external/data/GroundedError.dart'; import '../../about/external/data/Severity.dart'; import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/TextType.dart'; import '../../about/internal/file/FileStorage.dart'; import '../../comms/Comms.dart'; import '../../configs/Navigator.dart'; import '../../designs/Component.dart'; import '../../designs/buttons/Buttons.dart'; import '../../designs/text/Text.dart'; import '../../informatics/AppDataManager.dart'; import '../../informatics/DataManager.dart'; import '../../memory/InternalMemory.dart'; import '../../utils/Colors.dart'; import '../system/sessionexpired/SessionExpired.dart'; import '../system/updateme/UpdateMe.dart'; /// Everything cross-cutting lives here: the single data gateway, the loading, /// network and error overlays, and the error decision tree that lets the /// backend steer the client. 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(), Comms(InternalMemory()), FileStorage()); } DataManager getDataManager() { return dataManager; } // ── Loading ─────────────────────────────────────────────────────────────── void showLoading(String loadingText) async { if (loadingEntry == null) { _hideKeyboard(); loadingEntry = OverlayEntry(builder: (context) { return Scaffold( backgroundColor: colorPrimaryDark, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Align( alignment: Alignment.centerLeft, child: text( "GROUNDED", 10, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0, ), ), Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 46, height: 46, child: CircularProgressIndicator( strokeWidth: 2.5, backgroundColor: colorWhite.withValues(alpha: 0.12), color: colorWhite, ), ), const SizedBox(height: 32), text( loadingText, 30, TextType.Light, color: colorWhite, align: TextAlign.center, height: 1.15, ), ], ), text( "Keep your connection active", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.45), ), ], ), ), ), ); }); overlayState?.insert(loadingEntry!); notifyListeners(); } } closeLoading() { if (loadingEntry != null) { loadingEntry?.remove(); loadingEntry = null; notifyListeners(); } } // ── Network ─────────────────────────────────────────────────────────────── void noNetwork(Function() actions) async { if (networkEntry == null) { _hideKeyboard(); networkEntry = OverlayEntry(builder: (context) { return Scaffold( backgroundColor: colorPrimaryLight, body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 64, height: 64, decoration: BoxDecoration( color: colorPrimaryDark, borderRadius: BorderRadius.circular(18), ), child: Icon(CupertinoIcons.wifi_slash, size: 26, color: colorWhite), ), const SizedBox(height: 28), text("CONNECTION", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2), const SizedBox(height: 10), text("You are offline.", 34, TextType.Light, color: colorPrimaryDark, height: 1.15), const SizedBox(height: 12), text( "Reconnect and we will pick up where you left off. Nothing has been lost.", 14, TextType.Regular, color: colorGrey2, height: 1.5, ), const SizedBox(height: 32), SizedBox( width: double.infinity, child: roundedCornerButton( "Try again", actions, icon: CupertinoIcons.refresh, ), ), ], ), ), ), ), ); }); overlayState?.insert(networkEntry!); notifyListeners(); } } closeNetwork() { if (networkEntry != null) { networkEntry?.remove(); networkEntry = null; notifyListeners(); } } /// Guard every network call with this. Returns false and raises the offline /// overlay wired to [actions] when there is nothing to talk to. Future hasNetwork(Function() actions) async { closeLoading(); List resultList = await (Connectivity().checkConnectivity()); ConnectivityResult connectivityResult = ConnectivityResult.none; if (resultList.isNotEmpty) { connectivityResult = resultList.first; } if (connectivityResult == ConnectivityResult.mobile || connectivityResult == ConnectivityResult.wifi || connectivityResult == ConnectivityResult.ethernet) { closeNetwork(); return true; } noNetwork(actions); return false; } // ── Errors ──────────────────────────────────────────────────────────────── /// The standard catch handler. Session expiry and the server-directed /// redirects live here, so the backend can steer the client from any screen. handleError(Object? error, Function() actions, Function() closeAction, String buttonName) { closeLoading(); if (error is DioException) { DioException dioError = error; if (error.type == DioExceptionType.connectionTimeout) { showError( actions, closeAction, GroundedError( code: 5000.01, message: "An error occurred while processing your request.", helper: "Kindly ensure that you have a stable internet connection.", title: "Grounded Error", severity: Severity.message.name), buttonName); } else if (error.type == DioExceptionType.receiveTimeout) { showError( actions, closeAction, GroundedError( code: 5000.02, message: "An error occurred while processing your request.", helper: "Kindly ensure that you have a stable internet connection.", title: "Grounded Error", severity: Severity.message.name), buttonName); } else if (dioError.response?.statusCode == 401) { sessionExpired(); } else if (dioError.response?.statusCode == 403) { showError( actions, closeAction, GroundedError( code: 5100.00, message: "A connection error occurred while processing your request. Usually a result of your network security blocking the request.", helper: "Try using your mobile data or switching to a different network.", title: "Grounded Error", severity: Severity.message.name), buttonName); } else if (dioError.response?.statusCode == 413) { showError(actions, closeAction, getGroundedError(dioError.response?.data), buttonName); } else if (_isBusinessStatus(dioError.response?.statusCode)) { _handleBusinessError(dioError, actions, closeAction, buttonName); } else { showError( actions, closeAction, GroundedError( code: 5500.02, message: "An error occurred while processing your request.", helper: "Kindly relaunch the application and try again. If the problem persists, contact us.", title: "Grounded Error", severity: Severity.message.name), buttonName); } } else if (error is Exception) { showError( actions, closeAction, GroundedError( code: 6000.01, message: "An error occurred while processing your request.", helper: "Kindly relaunch the application and try again. If the problem persists, contact us.", title: "Grounded Error", severity: Severity.message.name), buttonName); } else if (error is int) { showError( actions, closeAction, GroundedError( code: 6000.02, message: "An error occurred while processing your request.", helper: "Kindly relaunch or reinstall the application and retry below.", title: "Grounded Error", severity: Severity.message.name), buttonName); } else { showError( actions, closeAction, GroundedError( code: 8700.02, message: "An error occurred while processing your request.", helper: "Kindly relaunch the application and try again. If the problem persists, contact us.", title: "Grounded Error", severity: Severity.message.name), buttonName); } } /// Statuses that carry an actionable error body the user should see. 500 is /// included for services not yet migrated to the per-status scheme. bool _isBusinessStatus(int? status) { return status == 400 || status == 404 || status == 409 || status == 422 || status == 429 || status == 500; } /// Decodes the body and routes the app-flow control codes before falling /// back to showing the error. void _handleBusinessError(DioException dioError, Function() actions, Function() closeAction, String buttonName) { GroundedError error = getGroundedError(dioError.response?.data); if (error.code == 5000.901) { updateMe(); } else { showError(actions, closeAction, error, buttonName); } } void showError(Function() actions, Function() closeActions, GroundedError error, String buttonText) async { if (errorEntry == null) { _hideKeyboard(); errorEntry = OverlayEntry(builder: (context) { return Scaffold( backgroundColor: colorPrimaryLight, body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 48, 24, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("SYSTEM NOTICE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2), const SizedBox(height: 12), text(error.title, 34, TextType.Light, color: colorPrimaryDark, height: 1.15), const SizedBox(height: 24), card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 44, height: 44, alignment: Alignment.center, decoration: BoxDecoration( color: colorStandingGroundedBg, borderRadius: BorderRadius.circular(12), ), child: Icon( CupertinoIcons.exclamationmark_triangle_fill, size: 20, color: colorStandingGrounded, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("CODE", 9, TextType.Bold, color: colorGrey2, letterSpacing: 1.0), const SizedBox(height: 4), text(error.code.toString(), 17, TextType.Medium, color: colorPrimaryDark), ], ), ), ], ), hairline( margin: const EdgeInsets.symmetric(vertical: 20)), text("WHAT HAPPENED", 9, TextType.Bold, color: colorGrey2, letterSpacing: 1.0), const SizedBox(height: 6), text(error.message, 14, TextType.Regular, color: colorPrimaryDark, height: 1.5), const SizedBox(height: 20), text("WHAT TO DO", 9, TextType.Bold, color: colorGrey2, letterSpacing: 1.0), const SizedBox(height: 6), text(error.helper, 14, TextType.Regular, color: colorPrimaryDark, height: 1.5), ], ), ), const SizedBox(height: 28), SizedBox( width: double.infinity, child: roundedCornerButton( buttonText, actions, icon: CupertinoIcons.arrow_clockwise, ), ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: outlinedActionButton("Close", () { dismissError(); closeActions(); }), ), ], ), ), ), ); }); overlayState?.insert(errorEntry!); notifyListeners(); } } dismissError() { if (errorEntry != null) { errorEntry?.remove(); errorEntry = null; notifyListeners(); } } GroundedError getGroundedError(data) { GroundedError error; try { error = GroundedError.fromJson(data); } catch (e) { error = GroundedError( code: 900, message: "An error occurred while processing your request.", helper: "Kindly ensure that your internet connection is working.", title: "Grounded Error", severity: Severity.message.name); } return error; } // ── In-app notification ─────────────────────────────────────────────────── showApplicationNotification( NotificationType type, String title, String description, bool enableDrag, bool barrierDismiss, VoidCallback? closeAction, { String? action, VoidCallback? positiveAction, }) async { Color actionColor; Color actionBgColor; IconData actionIconData; switch (type) { case NotificationType.success: actionIconData = Icons.check_circle_rounded; actionColor = colorPositive; actionBgColor = colorStandingGoodBg; break; case NotificationType.info: actionIconData = Icons.info_rounded; actionColor = colorPrimaryDark; actionBgColor = colorMuted; break; case NotificationType.warning: actionIconData = Icons.warning_amber_rounded; actionColor = colorStandingWarned; actionBgColor = colorStandingWarnedBg; break; case NotificationType.error: actionIconData = Icons.error_rounded; actionColor = colorStandingGrounded; actionBgColor = colorStandingGroundedBg; 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: colorSheetBackground, borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), ), ), 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: colorGrey.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(999), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 52, height: 52, alignment: Alignment.center, decoration: BoxDecoration( color: actionBgColor, borderRadius: BorderRadius.circular(15), ), child: Icon(actionIconData, size: 24, color: actionColor), ), iconButton( Icon(Icons.close_rounded, size: 17, color: colorGrey2), () { Navigator.pop(context); closeAction?.call(); }, bordered: true, ), ], ), const SizedBox(height: 20), text(type.name.toUpperCase(), 9, TextType.Bold, color: actionColor, letterSpacing: 1.2), const SizedBox(height: 10), text(title, 26, TextType.Light, color: colorPrimaryDark, height: 1.2), const SizedBox(height: 12), text(description, 14, TextType.Regular, color: colorGrey2, height: 1.55), const SizedBox(height: 28), if (hasAction) ...[ SizedBox( width: double.infinity, child: roundedCornerButton( action, positiveAction, background: actionColor, icon: actionIconData, ), ), const SizedBox(height: 10), ], Center( child: textButton( hasAction ? "Cancel" : "Dismiss", () { Navigator.pop(context); closeAction?.call(); }, color: colorGrey2, textSize: 13, ), ), ], ), ), ], ), ); }, ); } // ── Server-directed navigation ──────────────────────────────────────────── void sessionExpired() { GroundedNavigation().navigateToPage( NavigatorType.makeNewMain, const SessionExpired(), context); } void updateMe() { GroundedNavigation() .navigateToPage(NavigatorType.makeNewMain, const UpdateMe(), context); } _hideKeyboard() { SystemChannels.textInput.invokeMethod('TextInput.hide'); } @override void dispose() { closeLoading(); closeNetwork(); dismissError(); super.dispose(); } }