A to-do app that doesn't believe you — an enforcement layer rather than a
neutral ledger.
Architecture ported from Autoreceptives/Frontend/Receptive: stacked MVVM with
the mandatory 4-file screen pattern, one ParentViewModel owning the loading /
network / error overlays and the handleError decision tree, one AppDataManager
gateway, dio comms carrying the three identity headers, secure storage with
random-suffixed keys, and a single-chokepoint Navigator. Package root and Dart
package name are both Grounded; org is nya.
The enforcement engine, one unit per formula in utils/:
- DebtEngine w(class) x severity(d) x decay(t), sublinear severity so old
misses cannot swamp the score; abandonment 2x with 30-day
decay immunity; late complete retains 30%
- StandingEngine Good -> Warned -> Grounded -> Lockdown, derived not set;
Grounded replaces home with the overdue queue
- CapacityEngine blocks over-scheduling against p50 of historically completed
minutes, with a learned per-category estimation multiplier
- IntegrityEngine session integrity, weekly volume, plyometric contact ceiling
and enforced recovery gaps
- ExcuseAnalyser on-device excuse clustering plus the confrontation copy
- GuardrailEngine distress detection and rationed amnesty
- ToneEngine all enforcement copy, so the tone cap lives in one place
CommitmentEvent is append-only and is the source of truth rather than the
status field, which is what makes honest history and excuse analysis possible.
Goals contain commitments via parentId, and a task can be run from a
full-screen runner that derives elapsed time from wall-clock so screen-off
cannot lose time. Backgrounding pauses the clock and is counted. The runner is
mirrored into an ongoing notification, with alarm-class full-screen intents
reserved for non-negotiables.
Design language, fonts, icon and native splash are in place; Mason bricks are
retargeted to this project and verified end-to-end.
flutter analyze lib/ reports no errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mfu2gQLSFN21YRBcU2NrTt
653 lines
23 KiB
Dart
653 lines
23 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 '../../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<bool> hasNetwork(Function() actions) async {
|
|
closeLoading();
|
|
|
|
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(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();
|
|
}
|
|
}
|