Files
GroundedHelper/frontend/lib/Grounded/see/parent/ParentViewModel.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

665 lines
24 KiB
Dart

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 '../../mock/MockComms.dart'; // MOCK: remove with lib/Grounded/mock/
import '../../mock/MockMemory.dart'; // MOCK: remove with lib/Grounded/mock/
import '../../mock/MockSwitch.dart'; // MOCK: remove with lib/Grounded/mock/
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 =
// MOCK: restore to the single real line below when removing mocks.
useMockData
? AppDataManager(MockMemory(), MockComms(), FileStorage())
: 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(14),
),
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<bool> hasNetwork(Function() actions) async {
closeLoading();
// MOCK: mock mode never needs a connection.
if (useMockData) {
return true;
}
List<ConnectivityResult> 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(10),
),
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(16),
topRight: Radius.circular(16),
),
),
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(14),
),
),
),
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();
}
}