Initial commit: Grounded Flutter frontend

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
This commit is contained in:
alvocool
2026-07-27 09:11:17 +03:00
commit 16bff634b5
315 changed files with 19132 additions and 0 deletions

View File

@@ -0,0 +1,652 @@
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();
}
}