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:
134
frontend/lib/Grounded/utils/CapacityEngine.dart
Normal file
134
frontend/lib/Grounded/utils/CapacityEngine.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import '../about/external/data/Commitment.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
|
||||
/// The result of a capacity check at plan time.
|
||||
class CapacityVerdict {
|
||||
/// Minutes the plan actually implies, after the estimation multipliers.
|
||||
double projectedMinutes;
|
||||
|
||||
/// Minutes history says get done on this weekday, at p50.
|
||||
double historicalMinutes;
|
||||
|
||||
/// The ceiling — historical minutes times the headroom factor.
|
||||
double allowedMinutes;
|
||||
|
||||
bool blocked;
|
||||
|
||||
/// Minutes that must come out of the plan before it will be accepted.
|
||||
double excessMinutes;
|
||||
|
||||
String message;
|
||||
|
||||
CapacityVerdict({
|
||||
this.projectedMinutes = 0,
|
||||
this.historicalMinutes = 0,
|
||||
this.allowedMinutes = 0,
|
||||
this.blocked = false,
|
||||
this.excessMinutes = 0,
|
||||
this.message = "",
|
||||
});
|
||||
}
|
||||
|
||||
/// Chronic overdue is usually an overcommitment problem misdiagnosed as a
|
||||
/// laziness problem. This is the check that catches it.
|
||||
///
|
||||
/// ```
|
||||
/// projected_load = Sum (est_minutes x your_multiplier[category])
|
||||
/// if projected_load > 0.85 x p50(historical_completed_minutes[weekday]):
|
||||
/// block, and force removal
|
||||
/// ```
|
||||
class CapacityEngine {
|
||||
/// Plan against 85% of what you historically get done, not 100%.
|
||||
static const double headroom = 0.85;
|
||||
|
||||
/// The plan as the app believes it, not as you estimated it. Your estimates
|
||||
/// are wrong and the multiplier is applied silently.
|
||||
static double projectedLoad(
|
||||
List<Commitment> plan,
|
||||
CapacityProfile profile,
|
||||
) {
|
||||
double total = 0;
|
||||
for (Commitment commitment in plan) {
|
||||
total =
|
||||
total + (commitment.estMinutes * profile.multiplierFor(commitment.category));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
static CapacityVerdict check(
|
||||
List<Commitment> plan,
|
||||
CapacityProfile profile,
|
||||
int weekday,
|
||||
) {
|
||||
final double projected = projectedLoad(plan, profile);
|
||||
final double historical = profile.capacityFor(weekday);
|
||||
final double allowed = historical * headroom;
|
||||
|
||||
// No history yet — the app has not earned the right to block anything.
|
||||
if (historical <= 0) {
|
||||
return CapacityVerdict(
|
||||
projectedMinutes: projected,
|
||||
historicalMinutes: 0,
|
||||
allowedMinutes: 0,
|
||||
blocked: false,
|
||||
message: "Not enough history yet to judge this plan.",
|
||||
);
|
||||
}
|
||||
|
||||
if (projected <= allowed) {
|
||||
return CapacityVerdict(
|
||||
projectedMinutes: projected,
|
||||
historicalMinutes: historical,
|
||||
allowedMinutes: allowed,
|
||||
blocked: false,
|
||||
message: "This plan fits what you actually get done.",
|
||||
);
|
||||
}
|
||||
|
||||
final double excess = projected - allowed;
|
||||
|
||||
return CapacityVerdict(
|
||||
projectedMinutes: projected,
|
||||
historicalMinutes: historical,
|
||||
allowedMinutes: allowed,
|
||||
blocked: true,
|
||||
excessMinutes: excess,
|
||||
message:
|
||||
"You have allocated ${_hours(projected)} of tasks into a day where you historically complete ${_hours(historical)}. Cut ${_hours(excess)}.",
|
||||
);
|
||||
}
|
||||
|
||||
/// The learned multiplier for a category, from what you said against what it
|
||||
/// took. Surfaced in the weekly review rather than hidden.
|
||||
static double learnMultiplier(
|
||||
List<double> estimatedMinutes,
|
||||
List<double> actualMinutes,
|
||||
) {
|
||||
if (estimatedMinutes.isEmpty ||
|
||||
estimatedMinutes.length != actualMinutes.length) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
double estimated = 0;
|
||||
double actual = 0;
|
||||
|
||||
for (int index = 0; index < estimatedMinutes.length; index++) {
|
||||
estimated = estimated + estimatedMinutes[index];
|
||||
actual = actual + actualMinutes[index];
|
||||
}
|
||||
|
||||
if (estimated <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return actual / estimated;
|
||||
}
|
||||
|
||||
static String _hours(double minutes) {
|
||||
if (minutes < 60) {
|
||||
return "${minutes.round()}min";
|
||||
}
|
||||
final double hours = minutes / 60;
|
||||
return "${hours.toStringAsFixed(1)}h";
|
||||
}
|
||||
}
|
||||
115
frontend/lib/Grounded/utils/Colors.dart
Normal file
115
frontend/lib/Grounded/utils/Colors.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
MaterialColor createMaterialColor(Color color) {
|
||||
List strengths = <double>[.05];
|
||||
final swatch = <int, Color>{};
|
||||
final int r = (color.r * 255.0).round() & 0xff,
|
||||
g = (color.g * 255.0).round() & 0xff,
|
||||
b = (color.b * 255.0).round() & 0xff;
|
||||
|
||||
for (int i = 1; i < 10; i++) {
|
||||
strengths.add(0.1 * i);
|
||||
}
|
||||
for (var strength in strengths) {
|
||||
final double ds = 0.5 - strength;
|
||||
swatch[(strength * 1000).round()] = Color.fromRGBO(
|
||||
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
|
||||
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
|
||||
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
return MaterialColor(color.toARGB32(), swatch);
|
||||
}
|
||||
|
||||
// ── Main scheme ───────────────────────────────────────────────────────────────
|
||||
// Grounded reads as an institution, not a toy: near-black ink, one hard
|
||||
// accent, and a warning palette that carries the enforcement tiers.
|
||||
var colorWhite = const Color(0xFFFFFFFF);
|
||||
var colorPrimary = const Color(0xFF2F6F4E);
|
||||
var colorPrimaryDark = const Color(0xFF141414);
|
||||
var colorPrimaryDark2 = const Color(0xFF232323);
|
||||
var colorPrimaryLight = const Color(0xFFEDEEEA);
|
||||
var colorPrimaryLight2 = const Color(0xFFF4F5F2);
|
||||
|
||||
var colorAccent = const Color(0xFFB5442F);
|
||||
var colorMilkWhite = const Color(0xFFD9D8CE);
|
||||
var colorMuted = const Color(0xFFE6EAE6);
|
||||
var colorSecondary = const Color(0xFFF1F4F1);
|
||||
|
||||
var colorPositive = const Color(0xFF2F6F4E);
|
||||
var colorNegative = const Color(0xFFB3261E);
|
||||
var colorGrey = const Color(0xFFA8A8A8);
|
||||
var colorGrey2 = const Color(0xFF6E6E6E);
|
||||
var colorGrey3 = const Color(0xFF8A8A8A);
|
||||
var colorTinted = const Color(0xFFE0A88F);
|
||||
var colorWarmYellow = const Color(0xFFD9A404);
|
||||
var colorReddish = const Color(0xFFC0392B);
|
||||
var colorDarkBlue = const Color(0xFF10161F);
|
||||
|
||||
// ── Semantic ──────────────────────────────────────────────────────────────────
|
||||
const Color colorSuccess = Color(0xFF2E7D4F);
|
||||
const Color colorWarning = Color(0xFFC98A04);
|
||||
const Color colorDestructive = Color(0xFFA32C1C);
|
||||
const Color colorBlack = Color(0xFF000000);
|
||||
const Color colorBorder = Color(0x14000000);
|
||||
const Color colorDivider = Color(0x12000000);
|
||||
|
||||
// ── Standing tiers ────────────────────────────────────────────────────────────
|
||||
// Each standing has one colour used consistently everywhere it appears, so the
|
||||
// tier is legible at a glance without reading the label.
|
||||
const Color colorStandingGood = Color(0xFF2F6F4E);
|
||||
const Color colorStandingGoodBg = Color(0xFFE4EFE8);
|
||||
const Color colorStandingWarned = Color(0xFFC98A04);
|
||||
const Color colorStandingWarnedBg = Color(0xFFFAF0D8);
|
||||
const Color colorStandingGrounded = Color(0xFFB5442F);
|
||||
const Color colorStandingGroundedBg = Color(0xFFF7E3DE);
|
||||
const Color colorStandingLockdown = Color(0xFF7A1F14);
|
||||
const Color colorStandingLockdownBg = Color(0xFFEFD6D2);
|
||||
|
||||
// ── Commitment classes ────────────────────────────────────────────────────────
|
||||
const Color colorClassNonNegotiable = Color(0xFF7A1F14);
|
||||
const Color colorClassNonNegotiableBg = Color(0xFFF3DFDB);
|
||||
const Color colorClassStandard = Color(0xFF2C4A63);
|
||||
const Color colorClassStandardBg = Color(0xFFE0E8EF);
|
||||
const Color colorClassElective = Color(0xFF6E6E6E);
|
||||
const Color colorClassElectiveBg = Color(0xFFEDEDED);
|
||||
|
||||
// ── Debt / charting ───────────────────────────────────────────────────────────
|
||||
const Color colorDebtLine = Color(0xFFB5442F);
|
||||
const Color colorDebtFill = Color(0x1AB5442F);
|
||||
const Color colorChartGrid = Color(0x0F000000);
|
||||
const Color colorChartAxis = Color(0xFF8A8A8A);
|
||||
|
||||
// ── Surfaces ──────────────────────────────────────────────────────────────────
|
||||
const Color colorCard = Color(0xFFFFFFFF);
|
||||
const Color colorSheetBackground = Color(0xFFF6F5F2);
|
||||
const Color colorInset = Color(0xFFFAFAF8);
|
||||
|
||||
// ── Dark surfaces ─────────────────────────────────────────────────────────────
|
||||
const Color colorDarkBg = Color(0xFF080808);
|
||||
const Color colorDarkCard = Color(0xFF141414);
|
||||
const Color colorDarkSurface = Color(0xFF1C1C1C);
|
||||
const Color colorDarkBorder = Color(0xFF262626);
|
||||
|
||||
Color getColorFromHex(String hexColor) {
|
||||
if (hexColor.isEmpty || hexColor.length <= 6) {
|
||||
hexColor = "#ffffff";
|
||||
}
|
||||
|
||||
hexColor = hexColor.toUpperCase().replaceAll("#", "");
|
||||
|
||||
if (hexColor.length == 6) {
|
||||
hexColor = "FF$hexColor";
|
||||
}
|
||||
|
||||
try {
|
||||
return Color(int.parse(hexColor, radix: 16));
|
||||
} catch (e) {
|
||||
return colorWhite;
|
||||
}
|
||||
}
|
||||
|
||||
String colorToHex(Color color) {
|
||||
return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
}
|
||||
188
frontend/lib/Grounded/utils/CommonUtils.dart
Normal file
188
frontend/lib/Grounded/utils/CommonUtils.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../about/external/data/Commitment.dart';
|
||||
import '../about/internal/application/CommitmentClass.dart';
|
||||
import '../about/internal/application/Standing.dart';
|
||||
import 'Colors.dart';
|
||||
|
||||
/// Formatting and small shared derivations. Anything a screen would otherwise
|
||||
/// inline twice belongs here.
|
||||
|
||||
/// "Mon 06:00–08:00" — the window, which is the whole point.
|
||||
String formatWindow(Commitment commitment) {
|
||||
if (commitment.dueStart == null || commitment.dueEnd == null) {
|
||||
return "No window set";
|
||||
}
|
||||
|
||||
final DateFormat day = DateFormat('EEE');
|
||||
final DateFormat time = DateFormat('HH:mm');
|
||||
|
||||
final String startDay = day.format(commitment.dueStart!);
|
||||
final String startTime = time.format(commitment.dueStart!);
|
||||
final String endTime = time.format(commitment.dueEnd!);
|
||||
|
||||
final bool sameDay = commitment.dueStart!.day == commitment.dueEnd!.day &&
|
||||
commitment.dueStart!.month == commitment.dueEnd!.month;
|
||||
|
||||
if (sameDay) {
|
||||
return "$startDay $startTime–$endTime";
|
||||
}
|
||||
|
||||
return "$startDay $startTime – ${day.format(commitment.dueEnd!)} $endTime";
|
||||
}
|
||||
|
||||
String formatDate(DateTime? value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return DateFormat('d MMM yyyy').format(value);
|
||||
}
|
||||
|
||||
String formatDateTime(DateTime? value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return DateFormat('d MMM, HH:mm').format(value);
|
||||
}
|
||||
|
||||
/// "3h 20m" from raw minutes.
|
||||
String formatMinutes(num minutes) {
|
||||
final int total = minutes.round();
|
||||
if (total < 60) {
|
||||
return "${total}m";
|
||||
}
|
||||
final int hours = total ~/ 60;
|
||||
final int remainder = total % 60;
|
||||
if (remainder == 0) {
|
||||
return "${hours}h";
|
||||
}
|
||||
return "${hours}h ${remainder}m";
|
||||
}
|
||||
|
||||
/// "12:04" from raw seconds — for the proof timer and rest clocks.
|
||||
String formatClock(int seconds) {
|
||||
final int safe = seconds < 0 ? 0 : seconds;
|
||||
final int minutes = safe ~/ 60;
|
||||
final int remainder = safe % 60;
|
||||
return "${minutes.toString().padLeft(2, '0')}:${remainder.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
/// Debt is shown to one decimal — precise enough to move visibly when you
|
||||
/// clear something, coarse enough not to look like a lie.
|
||||
String formatDebt(double debt) {
|
||||
return debt.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
/// "2 days overdue", "Due in 40m", "Window closes in 3h".
|
||||
String overdueLabel(Commitment commitment) {
|
||||
if (commitment.dueEnd == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final Duration difference = DateTime.now().difference(commitment.dueEnd!);
|
||||
|
||||
if (difference.isNegative) {
|
||||
final Duration remaining = difference.abs();
|
||||
if (remaining.inHours < 1) {
|
||||
return "Closes in ${remaining.inMinutes}m";
|
||||
}
|
||||
if (remaining.inDays < 1) {
|
||||
return "Closes in ${remaining.inHours}h";
|
||||
}
|
||||
return "Closes in ${remaining.inDays}d";
|
||||
}
|
||||
|
||||
if (difference.inHours < 1) {
|
||||
return "${difference.inMinutes}m overdue";
|
||||
}
|
||||
if (difference.inDays < 1) {
|
||||
return "${difference.inHours}h overdue";
|
||||
}
|
||||
if (difference.inDays == 1) {
|
||||
return "1 day overdue";
|
||||
}
|
||||
return "${difference.inDays} days overdue";
|
||||
}
|
||||
|
||||
Color standingColor(Standing standing) {
|
||||
switch (standing) {
|
||||
case Standing.Good:
|
||||
return colorStandingGood;
|
||||
case Standing.Warned:
|
||||
return colorStandingWarned;
|
||||
case Standing.Grounded:
|
||||
return colorStandingGrounded;
|
||||
case Standing.Lockdown:
|
||||
return colorStandingLockdown;
|
||||
}
|
||||
}
|
||||
|
||||
Color standingBackground(Standing standing) {
|
||||
switch (standing) {
|
||||
case Standing.Good:
|
||||
return colorStandingGoodBg;
|
||||
case Standing.Warned:
|
||||
return colorStandingWarnedBg;
|
||||
case Standing.Grounded:
|
||||
return colorStandingGroundedBg;
|
||||
case Standing.Lockdown:
|
||||
return colorStandingLockdownBg;
|
||||
}
|
||||
}
|
||||
|
||||
Color classColor(CommitmentClass value) {
|
||||
switch (value) {
|
||||
case CommitmentClass.NonNegotiable:
|
||||
return colorClassNonNegotiable;
|
||||
case CommitmentClass.Standard:
|
||||
return colorClassStandard;
|
||||
case CommitmentClass.Elective:
|
||||
return colorClassElective;
|
||||
}
|
||||
}
|
||||
|
||||
Color classBackground(CommitmentClass value) {
|
||||
switch (value) {
|
||||
case CommitmentClass.NonNegotiable:
|
||||
return colorClassNonNegotiableBg;
|
||||
case CommitmentClass.Standard:
|
||||
return colorClassStandardBg;
|
||||
case CommitmentClass.Elective:
|
||||
return colorClassElectiveBg;
|
||||
}
|
||||
}
|
||||
|
||||
/// The weekday name for a 1..7 index.
|
||||
String weekdayName(int weekday) {
|
||||
const List<String> names = <String>[
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
];
|
||||
if (weekday < 1 || weekday > 7) {
|
||||
return "";
|
||||
}
|
||||
return names[weekday - 1];
|
||||
}
|
||||
|
||||
/// "7pm" for an hour-of-day index.
|
||||
String hourLabel(int hour) {
|
||||
if (hour < 0) {
|
||||
return "";
|
||||
}
|
||||
if (hour == 0) {
|
||||
return "midnight";
|
||||
}
|
||||
if (hour < 12) {
|
||||
return "${hour}am";
|
||||
}
|
||||
if (hour == 12) {
|
||||
return "noon";
|
||||
}
|
||||
return "${hour - 12}pm";
|
||||
}
|
||||
159
frontend/lib/Grounded/utils/DebtEngine.dart
Normal file
159
frontend/lib/Grounded/utils/DebtEngine.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:math';
|
||||
|
||||
import '../about/external/data/Commitment.dart';
|
||||
import '../about/external/data/Habit.dart';
|
||||
import '../about/internal/application/CommitmentClass.dart';
|
||||
import '../about/internal/application/CommitmentStatus.dart';
|
||||
|
||||
/// The single number the whole system runs on.
|
||||
///
|
||||
/// ```
|
||||
/// debt = Sum over open/missed commitments:
|
||||
/// w(class) x severity(days_overdue) x recency_decay(t)
|
||||
///
|
||||
/// w(class): non-negotiable 5.0 | standard 2.0 | elective 0.0
|
||||
/// severity(d): 1 + log2(1 + d)
|
||||
/// recency_decay: 0.5 ^ (days_since / half_life)
|
||||
/// abandonment: one-time +2x w(class), no decay for 30 days
|
||||
/// late_complete: debt reduced to 30% of accrued, not 0
|
||||
/// ```
|
||||
///
|
||||
/// Sublinear severity is the load-bearing choice: with linear growth a single
|
||||
/// ancient task swamps the score and the number stops meaning anything.
|
||||
class DebtEngine {
|
||||
/// Half-life of the recency decay, in days.
|
||||
static const double halfLifeDays = 14;
|
||||
|
||||
/// Multiplier applied once when a commitment is abandoned.
|
||||
static const double abandonmentMultiplier = 2.0;
|
||||
|
||||
/// Days an abandonment resists decay.
|
||||
static const int abandonmentProtectionDays = 30;
|
||||
|
||||
/// What a late completion leaves behind. Never zero — otherwise you learn
|
||||
/// that everything is negotiable.
|
||||
static const double lateCompleteRetention = 0.30;
|
||||
|
||||
/// Debt weight per habit shortfall unit.
|
||||
static const double habitShortfallWeight = 1.0;
|
||||
|
||||
/// `severity(d) = 1 + log2(1 + d)`.
|
||||
static double severity(int daysOverdue) {
|
||||
final int days = daysOverdue < 0 ? 0 : daysOverdue;
|
||||
return 1 + (log(1 + days) / ln2);
|
||||
}
|
||||
|
||||
/// `recency_decay(t) = 0.5 ^ (days_since / half_life)`.
|
||||
static double recencyDecay(int daysSince) {
|
||||
final int days = daysSince < 0 ? 0 : daysSince;
|
||||
return pow(0.5, days / halfLifeDays).toDouble();
|
||||
}
|
||||
|
||||
/// Debt contributed by one commitment, as of [now].
|
||||
static double commitmentDebt(Commitment commitment, {DateTime? now}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
|
||||
final double weight = classWeight(commitment.commitmentClass);
|
||||
|
||||
// Electives never accrue debt, whatever happens to them.
|
||||
if (weight == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Cleanly completed and archived items are settled.
|
||||
if (commitment.status == CommitmentStatus.Completed ||
|
||||
commitment.status == CommitmentStatus.Archived) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (commitment.dueEnd == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final int daysSince = moment.difference(commitment.dueEnd!).inDays;
|
||||
|
||||
// The window is still open — nothing is owed yet.
|
||||
if (daysSince < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (commitment.status == CommitmentStatus.Abandoned) {
|
||||
// Abandonment is the most expensive outcome and resists decay for a
|
||||
// month, so it cannot be waited out.
|
||||
final double base = weight * abandonmentMultiplier * severity(daysSince);
|
||||
if (daysSince <= abandonmentProtectionDays) {
|
||||
return base;
|
||||
}
|
||||
return base * recencyDecay(daysSince - abandonmentProtectionDays);
|
||||
}
|
||||
|
||||
final double accrued =
|
||||
weight * severity(daysSince) * recencyDecay(daysSince);
|
||||
|
||||
if (commitment.status == CommitmentStatus.LateCompleted) {
|
||||
return accrued * lateCompleteRetention;
|
||||
}
|
||||
|
||||
return accrued;
|
||||
}
|
||||
|
||||
/// Debt contributed by a habit. A single miss costs nothing — only falling
|
||||
/// below the frequency target in the rolling window does.
|
||||
static double habitDebt(Habit habit) {
|
||||
if (!habit.behindTarget) {
|
||||
return 0;
|
||||
}
|
||||
return habit.shortfall * habitShortfallWeight;
|
||||
}
|
||||
|
||||
/// The whole score, as of [now].
|
||||
static double totalDebt(
|
||||
List<Commitment> commitments, {
|
||||
List<Habit> habits = const <Habit>[],
|
||||
DateTime? now,
|
||||
}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
|
||||
double total = 0;
|
||||
|
||||
for (Commitment commitment in commitments) {
|
||||
total = total + commitmentDebt(commitment, now: moment);
|
||||
}
|
||||
|
||||
for (Habit habit in habits) {
|
||||
total = total + habitDebt(habit);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Open items past their window — the count that [Thresholds.maxOpenOverdue]
|
||||
/// caps.
|
||||
static int openOverdueCount(List<Commitment> commitments) {
|
||||
return commitments
|
||||
.where((commitment) =>
|
||||
commitment.status == CommitmentStatus.Overdue ||
|
||||
(commitment.status == CommitmentStatus.Open &&
|
||||
commitment.windowClosed))
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Missed non-negotiables — three of these force Grounded regardless of the
|
||||
/// numeric score.
|
||||
static int missedNonNegotiables(List<Commitment> commitments) {
|
||||
return commitments
|
||||
.where((commitment) =>
|
||||
commitment.commitmentClass == CommitmentClass.NonNegotiable &&
|
||||
(commitment.status == CommitmentStatus.Overdue ||
|
||||
commitment.status == CommitmentStatus.Abandoned ||
|
||||
(commitment.status == CommitmentStatus.Open &&
|
||||
commitment.windowClosed)))
|
||||
.length;
|
||||
}
|
||||
|
||||
/// What clearing this item would remove from the score — used to show the
|
||||
/// user the actual price of each row in the overdue queue.
|
||||
static double reliefFromClearing(Commitment commitment, {DateTime? now}) {
|
||||
return commitmentDebt(commitment, now: now);
|
||||
}
|
||||
}
|
||||
211
frontend/lib/Grounded/utils/ExcuseAnalyser.dart
Normal file
211
frontend/lib/Grounded/utils/ExcuseAnalyser.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
import '../about/external/data/CommitmentEvent.dart';
|
||||
import '../about/external/data/ExcuseCluster.dart';
|
||||
import '../about/internal/application/EventType.dart';
|
||||
|
||||
/// Clusters excuses over time and turns the pattern into a confrontation:
|
||||
/// "Too tired has appeared 14 times this month, 11 of them on gym days,
|
||||
/// 9 of them after 7pm. Consider moving gym to morning."
|
||||
///
|
||||
/// The on-device pass is a cheap keyword bucketing so the confrontation works
|
||||
/// offline; the server refines clusters and overwrites [ExcuseCluster.insight].
|
||||
class ExcuseAnalyser {
|
||||
/// Excuse families the local pass recognises. Order matters — the first
|
||||
/// family whose keyword appears wins.
|
||||
static const Map<String, List<String>> families = <String, List<String>>{
|
||||
"Too tired": <String>["tired", "exhausted", "knackered", "no energy", "sleepy"],
|
||||
"No time": <String>["no time", "busy", "ran out of time", "swamped"],
|
||||
"Not feeling it": <String>["not feeling", "no motivation", "cant be", "cannot be"],
|
||||
"Unwell": <String>["sick", "ill", "headache", "pain", "sore", "injured"],
|
||||
"Interrupted": <String>["interrupted", "came up", "emergency", "had to"],
|
||||
"Forgot": <String>["forgot", "slipped my mind", "missed it"],
|
||||
"Weather": <String>["rain", "cold", "hot", "weather"],
|
||||
"Travel": <String>["travel", "away", "trip", "commute", "traffic"],
|
||||
};
|
||||
|
||||
static const String unclustered = "Other";
|
||||
|
||||
/// The family this excuse belongs to.
|
||||
static String classify(String excuse) {
|
||||
final String text = excuse.toLowerCase();
|
||||
|
||||
for (MapEntry<String, List<String>> family in families.entries) {
|
||||
for (String keyword in family.value) {
|
||||
if (text.contains(keyword)) {
|
||||
return family.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unclustered;
|
||||
}
|
||||
|
||||
/// Build the taxonomy from the event log. Only deferrals and misses carry
|
||||
/// excuses worth clustering.
|
||||
static List<ExcuseCluster> cluster(List<CommitmentEvent> events) {
|
||||
final Map<String, ExcuseCluster> clusters = <String, ExcuseCluster>{};
|
||||
final Map<String, Map<String, int>> categoryCounts =
|
||||
<String, Map<String, int>>{};
|
||||
|
||||
for (CommitmentEvent event in events) {
|
||||
if (event.event != EventType.DEFERRED &&
|
||||
event.event != EventType.MISSED &&
|
||||
event.event != EventType.ABANDONED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.excuseText.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String label = classify(event.excuseText);
|
||||
|
||||
final ExcuseCluster cluster =
|
||||
clusters.putIfAbsent(label, () => ExcuseCluster(label: label));
|
||||
|
||||
cluster.occurrences = cluster.occurrences + 1;
|
||||
|
||||
if (event.at != null) {
|
||||
final int weekday = event.at!.weekday;
|
||||
final int hour = event.at!.hour;
|
||||
cluster.byWeekday[weekday] = (cluster.byWeekday[weekday] ?? 0) + 1;
|
||||
cluster.byHour[hour] = (cluster.byHour[hour] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (event.excuseClusterId != null &&
|
||||
event.excuseClusterId!.isNotEmpty) {
|
||||
final Map<String, int> counts =
|
||||
categoryCounts.putIfAbsent(label, () => <String, int>{});
|
||||
counts[event.excuseClusterId!] =
|
||||
(counts[event.excuseClusterId!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
final List<ExcuseCluster> result = clusters.values.toList();
|
||||
|
||||
for (ExcuseCluster cluster in result) {
|
||||
cluster.dominantCategory = _dominant(categoryCounts[cluster.label]);
|
||||
cluster.insight = describe(cluster);
|
||||
}
|
||||
|
||||
result.sort((a, b) => b.occurrences.compareTo(a.occurrences));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The confrontation copy. Only the concentrations that are actually
|
||||
/// meaningful get mentioned — a flat distribution says nothing.
|
||||
static String describe(ExcuseCluster cluster) {
|
||||
if (cluster.occurrences < 3) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
|
||||
buffer.write(
|
||||
"'${cluster.label}' has appeared ${cluster.occurrences} times");
|
||||
|
||||
final MapEntry<int, int>? weekday = _peak(cluster.byWeekday);
|
||||
if (weekday != null && weekday.value >= (cluster.occurrences * 0.4)) {
|
||||
buffer.write(", ${weekday.value} of them on ${_weekdayName(weekday.key)}s");
|
||||
}
|
||||
|
||||
final MapEntry<int, int>? hour = _peak(cluster.byHour);
|
||||
if (hour != null && hour.value >= (cluster.occurrences * 0.35)) {
|
||||
buffer.write(", ${hour.value} of them after ${_hourLabel(hour.key)}");
|
||||
}
|
||||
|
||||
buffer.write(".");
|
||||
|
||||
final String suggestion = _suggest(cluster, weekday, hour);
|
||||
if (suggestion.isNotEmpty) {
|
||||
buffer.write(" $suggestion");
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
static String _suggest(
|
||||
ExcuseCluster cluster,
|
||||
MapEntry<int, int>? weekday,
|
||||
MapEntry<int, int>? hour,
|
||||
) {
|
||||
if (hour == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (hour.key >= 18 && cluster.label == "Too tired") {
|
||||
return "Consider moving this to the morning.";
|
||||
}
|
||||
|
||||
if (hour.key >= 18) {
|
||||
return "Evenings are not working for this. Try scheduling it earlier.";
|
||||
}
|
||||
|
||||
if (weekday != null) {
|
||||
return "${_weekdayName(weekday.key)} is where this keeps failing.";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
static MapEntry<int, int>? _peak(Map<int, int> histogram) {
|
||||
if (histogram.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MapEntry<int, int>? peak;
|
||||
for (MapEntry<int, int> entry in histogram.entries) {
|
||||
if (peak == null || entry.value > peak.value) {
|
||||
peak = entry;
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
static String _dominant(Map<String, int>? counts) {
|
||||
if (counts == null || counts.isEmpty) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String label = "";
|
||||
int best = 0;
|
||||
|
||||
for (MapEntry<String, int> entry in counts.entries) {
|
||||
if (entry.value > best) {
|
||||
best = entry.value;
|
||||
label = entry.key;
|
||||
}
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
static String _weekdayName(int weekday) {
|
||||
const List<String> names = <String>[
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
];
|
||||
if (weekday < 1 || weekday > 7) {
|
||||
return "";
|
||||
}
|
||||
return names[weekday - 1];
|
||||
}
|
||||
|
||||
static String _hourLabel(int hour) {
|
||||
if (hour == 0) {
|
||||
return "midnight";
|
||||
}
|
||||
if (hour < 12) {
|
||||
return "${hour}am";
|
||||
}
|
||||
if (hour == 12) {
|
||||
return "noon";
|
||||
}
|
||||
return "${hour - 12}pm";
|
||||
}
|
||||
}
|
||||
44
frontend/lib/Grounded/utils/GuardrailEngine.dart
Normal file
44
frontend/lib/Grounded/utils/GuardrailEngine.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
/// The counterweights. An app built on guilt has an obvious failure mode: the
|
||||
/// people who need it most delete it during their worst week. These are not
|
||||
/// nice-to-haves — they are the retention strategy.
|
||||
class GuardrailEngine {
|
||||
/// Debt increase over the window that counts as a spike.
|
||||
static const double debtSpikeDelta = 15;
|
||||
|
||||
/// App opens per week below which engagement counts as dropped.
|
||||
static const int engagementFloor = 3;
|
||||
|
||||
/// Readiness score below which inputs count as degraded.
|
||||
static const int readinessFloor = 4;
|
||||
|
||||
/// Distress is the conjunction, not any single signal: debt spiking *and*
|
||||
/// engagement dropping *and* readiness degrading. Strictness must never be
|
||||
/// the response to someone who is actually struggling.
|
||||
static bool detectDistress({
|
||||
required double debtDelta,
|
||||
required int appOpensThisWeek,
|
||||
required int meanReadiness,
|
||||
}) {
|
||||
final bool debtSpiking = debtDelta >= debtSpikeDelta;
|
||||
final bool disengaging = appOpensThisWeek <= engagementFloor;
|
||||
final bool degrading = meanReadiness > 0 && meanReadiness <= readinessFloor;
|
||||
|
||||
return debtSpiking && disengaging && degrading;
|
||||
}
|
||||
|
||||
/// How many amnesty tokens remain this month. Rationed so they feel
|
||||
/// valuable, but they exist so a bad flu does not destroy three months of
|
||||
/// progress.
|
||||
static int tokensRemaining(int granted, int spent) {
|
||||
final int remaining = granted - spent;
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
static bool canSpendAmnesty(int granted, int spent) {
|
||||
return tokensRemaining(granted, spent) > 0;
|
||||
}
|
||||
|
||||
/// When distressed, the plan is cut to this many non-negotiables and nothing
|
||||
/// else is asked for.
|
||||
static const int distressPlanSize = 3;
|
||||
}
|
||||
12
frontend/lib/Grounded/utils/Images.dart
Normal file
12
frontend/lib/Grounded/utils/Images.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
const String imagePath = "assets/images";
|
||||
|
||||
const String iconPath = "assets/icons";
|
||||
|
||||
/// The anchor mark, matching the native splash so the handover is invisible.
|
||||
const String splashMark = "$iconPath/splash.png";
|
||||
|
||||
const String logoMark = "$iconPath/icon.png";
|
||||
|
||||
const String loadingBg = "$imagePath/loading.jpg";
|
||||
|
||||
const String emptyQueue = "$imagePath/empty_queue.png";
|
||||
191
frontend/lib/Grounded/utils/IntegrityEngine.dart
Normal file
191
frontend/lib/Grounded/utils/IntegrityEngine.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import '../about/external/data/ExercisePrescription.dart';
|
||||
import '../about/external/data/SessionLog.dart';
|
||||
import '../about/external/data/SetLog.dart';
|
||||
|
||||
/// Did you do the session, or a watered-down version of it?
|
||||
///
|
||||
/// ```
|
||||
/// integrity = 0.5 x (prescribed_sets_done / prescribed_sets)
|
||||
/// + 0.3 x (1 - mean |actual_reps - target| / target)
|
||||
/// + 0.2 x (hard_exercises_done / hard_exercises_prescribed)
|
||||
/// ```
|
||||
///
|
||||
/// Skipping the hard exercise while doing the easy ones is the most common
|
||||
/// form of self-deception in training, and the third term is what detects it.
|
||||
class IntegrityEngine {
|
||||
static const double setsWeight = 0.5;
|
||||
static const double repsWeight = 0.3;
|
||||
static const double hardWeight = 0.2;
|
||||
|
||||
static double score(
|
||||
SessionLog session,
|
||||
List<ExercisePrescription> prescribed,
|
||||
) {
|
||||
if (prescribed.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (setsWeight * _setCompletion(session, prescribed)) +
|
||||
(repsWeight * _repAccuracy(session, prescribed)) +
|
||||
(hardWeight * _hardCompletion(session, prescribed));
|
||||
}
|
||||
|
||||
/// Prescribed sets actually done, capped at 1 so overshooting one lift does
|
||||
/// not paper over skipping another.
|
||||
static double _setCompletion(
|
||||
SessionLog session,
|
||||
List<ExercisePrescription> prescribed,
|
||||
) {
|
||||
int prescribedSets = 0;
|
||||
int doneSets = 0;
|
||||
|
||||
for (ExercisePrescription item in prescribed) {
|
||||
prescribedSets = prescribedSets + item.sets;
|
||||
|
||||
final int done = session.sets
|
||||
.where((entry) =>
|
||||
entry.exerciseId == item.exerciseId && entry.isPrescribed)
|
||||
.length;
|
||||
|
||||
doneSets = doneSets + (done > item.sets ? item.sets : done);
|
||||
}
|
||||
|
||||
if (prescribedSets == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return doneSets / prescribedSets;
|
||||
}
|
||||
|
||||
/// How close the reps landed to target, averaged across prescribed sets.
|
||||
static double _repAccuracy(
|
||||
SessionLog session,
|
||||
List<ExercisePrescription> prescribed,
|
||||
) {
|
||||
double totalError = 0;
|
||||
int counted = 0;
|
||||
|
||||
for (ExercisePrescription item in prescribed) {
|
||||
if (item.targetReps <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final List<SetLog> done = session.sets
|
||||
.where((entry) =>
|
||||
entry.exerciseId == item.exerciseId && entry.isPrescribed)
|
||||
.toList();
|
||||
|
||||
for (SetLog entry in done) {
|
||||
final double error =
|
||||
(entry.reps - item.targetReps).abs() / item.targetReps;
|
||||
totalError = totalError + (error > 1 ? 1 : error);
|
||||
counted = counted + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (counted == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1 - (totalError / counted);
|
||||
}
|
||||
|
||||
/// The term that catches cherry-picking. "Hard" comes from your historical
|
||||
/// RPE on that exercise, not a static label.
|
||||
static double _hardCompletion(
|
||||
SessionLog session,
|
||||
List<ExercisePrescription> prescribed,
|
||||
) {
|
||||
final List<ExercisePrescription> hard =
|
||||
prescribed.where((item) => item.hard).toList();
|
||||
|
||||
if (hard.isEmpty) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int done = 0;
|
||||
|
||||
for (ExercisePrescription item in hard) {
|
||||
final bool touched = session.sets.any((entry) =>
|
||||
entry.exerciseId == item.exerciseId && entry.isPrescribed);
|
||||
if (touched) {
|
||||
done = done + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return done / hard.length;
|
||||
}
|
||||
|
||||
/// Whether an exercise counts as hard for this user, derived from historical
|
||||
/// RPE rather than asserted up front.
|
||||
static bool isHard(List<double> historicalRpe, {double cutoff = 8.0}) {
|
||||
if (historicalRpe.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
double total = 0;
|
||||
for (double value in historicalRpe) {
|
||||
total = total + value;
|
||||
}
|
||||
|
||||
return (total / historicalRpe.length) >= cutoff;
|
||||
}
|
||||
|
||||
/// Weekly volume per muscle group, for under-target warnings — "you went to
|
||||
/// the gym" is not a metric.
|
||||
static Map<String, int> weeklySetsByMuscleGroup(List<SessionLog> sessions) {
|
||||
final Map<String, int> volume = <String, int>{};
|
||||
|
||||
for (SessionLog session in sessions) {
|
||||
for (SetLog entry in session.sets) {
|
||||
if (entry.muscleGroup.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
volume[entry.muscleGroup] = (volume[entry.muscleGroup] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return volume;
|
||||
}
|
||||
|
||||
/// Total plyometric ground contacts across the week.
|
||||
static int weeklyContacts(List<SessionLog> sessions) {
|
||||
int total = 0;
|
||||
for (SessionLog session in sessions) {
|
||||
total = total + session.contacts;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Plyo is the one modality where the app stops you rather than pushes you.
|
||||
/// CNS and connective tissue do not recover on a motivation schedule.
|
||||
static bool contactCeilingBreached(List<SessionLog> sessions, int ceiling) {
|
||||
if (ceiling <= 0) {
|
||||
return false;
|
||||
}
|
||||
return weeklyContacts(sessions) >= ceiling;
|
||||
}
|
||||
|
||||
/// Whether enough recovery has passed since the last high-intensity lower
|
||||
/// body session.
|
||||
static bool recoveredEnough(
|
||||
DateTime? lastHighIntensityLowerBody,
|
||||
int requiredHours, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
if (lastHighIntensityLowerBody == null) {
|
||||
return true;
|
||||
}
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
return moment.difference(lastHighIntensityLowerBody).inHours >=
|
||||
requiredHours;
|
||||
}
|
||||
|
||||
/// Training through a deload logs as non-compliance, the same as skipping.
|
||||
static bool violatesDeload(SessionLog session, bool isDeloadWeek) {
|
||||
if (!isDeloadWeek) {
|
||||
return false;
|
||||
}
|
||||
return session.sets.any((entry) => entry.rpe >= 8);
|
||||
}
|
||||
}
|
||||
116
frontend/lib/Grounded/utils/ObjectConvertors.dart
Normal file
116
frontend/lib/Grounded/utils/ObjectConvertors.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import '../about/external/data/Commitment.dart';
|
||||
import '../about/external/data/CommitmentEvent.dart';
|
||||
import '../about/external/data/DebtEntry.dart';
|
||||
import '../about/external/data/ExcuseCluster.dart';
|
||||
import '../about/external/data/ExercisePrescription.dart';
|
||||
import '../about/external/data/Goal.dart';
|
||||
import '../about/external/data/Habit.dart';
|
||||
import '../about/external/data/Program.dart';
|
||||
import '../about/external/data/RoutineChain.dart';
|
||||
import '../about/external/data/SessionLog.dart';
|
||||
import '../about/external/data/SessionTemplate.dart';
|
||||
import '../about/external/data/SetLog.dart';
|
||||
import '../about/external/data/StandingChange.dart';
|
||||
|
||||
/// List parsing lives here. ViewModels never inline
|
||||
/// `.map((e) => T.fromJson(e)).toList()` — add a convertor if one is missing.
|
||||
|
||||
List<Commitment> getCommitmentList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <Commitment>[];
|
||||
}
|
||||
return (data as List).map((item) => Commitment.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<Goal> getGoalList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <Goal>[];
|
||||
}
|
||||
return (data as List).map((item) => Goal.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<CommitmentEvent> getCommitmentEventList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <CommitmentEvent>[];
|
||||
}
|
||||
return (data as List).map((item) => CommitmentEvent.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<DebtEntry> getDebtEntryList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <DebtEntry>[];
|
||||
}
|
||||
return (data as List).map((item) => DebtEntry.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<StandingChange> getStandingChangeList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <StandingChange>[];
|
||||
}
|
||||
return (data as List).map((item) => StandingChange.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<ExcuseCluster> getExcuseClusterList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <ExcuseCluster>[];
|
||||
}
|
||||
return (data as List).map((item) => ExcuseCluster.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<Habit> getHabitList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <Habit>[];
|
||||
}
|
||||
return (data as List).map((item) => Habit.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<RoutineChain> getRoutineChainList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <RoutineChain>[];
|
||||
}
|
||||
return (data as List).map((item) => RoutineChain.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<Program> getProgramList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <Program>[];
|
||||
}
|
||||
return (data as List).map((item) => Program.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<SessionTemplate> getSessionTemplateList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <SessionTemplate>[];
|
||||
}
|
||||
return (data as List).map((item) => SessionTemplate.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<ExercisePrescription> getPrescriptionList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <ExercisePrescription>[];
|
||||
}
|
||||
return (data as List)
|
||||
.map((item) => ExercisePrescription.fromJson(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SessionLog> getSessionLogList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <SessionLog>[];
|
||||
}
|
||||
return (data as List).map((item) => SessionLog.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<SetLog> getSetLogList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <SetLog>[];
|
||||
}
|
||||
return (data as List).map((item) => SetLog.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<String> getStringList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <String>[];
|
||||
}
|
||||
return (data as List).map((item) => item.toString()).toList();
|
||||
}
|
||||
91
frontend/lib/Grounded/utils/StandingEngine.dart
Normal file
91
frontend/lib/Grounded/utils/StandingEngine.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import '../about/external/data/Commitment.dart';
|
||||
import '../about/internal/application/Standing.dart';
|
||||
import 'DebtEngine.dart';
|
||||
import 'Thresholds.dart';
|
||||
|
||||
/// Standing is computed continuously and drives real consequences. It is
|
||||
/// derived, never set — there is no way to talk your way up a tier.
|
||||
class StandingEngine {
|
||||
/// The standing implied by the current debt and the missed-non-negotiable
|
||||
/// count. Sick mode never escalates: strictness must never be the response
|
||||
/// to someone who is actually struggling.
|
||||
static Standing evaluate(
|
||||
double debtScore, {
|
||||
int missedNonNegotiables = 0,
|
||||
bool sickMode = false,
|
||||
bool distressed = false,
|
||||
}) {
|
||||
if (sickMode || distressed) {
|
||||
return Standing.Good;
|
||||
}
|
||||
|
||||
if (debtScore >= Thresholds.lockdownThreshold) {
|
||||
return Standing.Lockdown;
|
||||
}
|
||||
|
||||
if (debtScore >= Thresholds.groundedThreshold ||
|
||||
missedNonNegotiables >= Thresholds.nonNegotiableMissesForGrounded) {
|
||||
return Standing.Grounded;
|
||||
}
|
||||
|
||||
if (debtScore >= Thresholds.warnedThreshold) {
|
||||
return Standing.Warned;
|
||||
}
|
||||
|
||||
return Standing.Good;
|
||||
}
|
||||
|
||||
/// Evaluate straight from the commitment list.
|
||||
static Standing evaluateFor(
|
||||
List<Commitment> commitments, {
|
||||
bool sickMode = false,
|
||||
bool distressed = false,
|
||||
DateTime? now,
|
||||
}) {
|
||||
return evaluate(
|
||||
DebtEngine.totalDebt(commitments, now: now),
|
||||
missedNonNegotiables: DebtEngine.missedNonNegotiables(commitments),
|
||||
sickMode: sickMode,
|
||||
distressed: distressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a new commitment of this class may be created right now.
|
||||
static bool permitsNewCommitment(Standing standing, {bool elective = false}) {
|
||||
if (elective) {
|
||||
return canAddElectives(standing);
|
||||
}
|
||||
return canAddCommitments(standing);
|
||||
}
|
||||
|
||||
/// Grounded replaces the home screen with the overdue queue — you do not get
|
||||
/// to look at your nice plans, only at your mess.
|
||||
static bool showsOverdueQueueAsHome(Standing standing) {
|
||||
return standing == Standing.Grounded || standing == Standing.Lockdown;
|
||||
}
|
||||
|
||||
/// Lockdown puts a blocking interstitial in front of the app that must be
|
||||
/// cleared one item at a time.
|
||||
static bool blocksAppOnOpen(Standing standing) {
|
||||
return standing == Standing.Lockdown;
|
||||
}
|
||||
|
||||
/// Whether the accountability partner is auto-notified at this tier.
|
||||
static bool notifiesPartner(Standing standing) {
|
||||
return standing == Standing.Grounded || standing == Standing.Lockdown;
|
||||
}
|
||||
|
||||
/// Debt still to shed before dropping a tier. Zero when already at Good.
|
||||
static double debtToNextTierDown(double debtScore, Standing standing) {
|
||||
switch (standing) {
|
||||
case Standing.Lockdown:
|
||||
return debtScore - Thresholds.lockdownThreshold;
|
||||
case Standing.Grounded:
|
||||
return debtScore - Thresholds.groundedThreshold;
|
||||
case Standing.Warned:
|
||||
return debtScore - Thresholds.warnedThreshold;
|
||||
case Standing.Good:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
frontend/lib/Grounded/utils/Thresholds.dart
Normal file
39
frontend/lib/Grounded/utils/Thresholds.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
/// Configurable limits with sane defaults. These are the dials the user is
|
||||
/// allowed to turn — everything else about enforcement is fixed.
|
||||
class Thresholds {
|
||||
/// Hard cap on unresolved overdue items.
|
||||
static const int maxOpenOverdue = 5;
|
||||
|
||||
/// After this, a task cannot be deferred again — only completed or
|
||||
/// explicitly abandoned.
|
||||
static const int maxDeferralsPerTask = 2;
|
||||
|
||||
/// Minimum excuse length. Free text, no template buttons; the friction is
|
||||
/// the point.
|
||||
static const int minExcuseLength = 15;
|
||||
|
||||
/// Standing thresholds on the debt score.
|
||||
static const double warnedThreshold = 12;
|
||||
static const double groundedThreshold = 30;
|
||||
static const double lockdownThreshold = 60;
|
||||
|
||||
/// Missed non-negotiables that force Grounded regardless of score.
|
||||
static const int nonNegotiableMissesForGrounded = 3;
|
||||
|
||||
/// Amnesty tokens granted per month.
|
||||
static const int amnestyTokensPerMonth = 3;
|
||||
|
||||
/// Capacity check headroom — plan against 85% of what you historically get
|
||||
/// done, not 100%.
|
||||
static const double capacityHeadroom = 0.85;
|
||||
|
||||
/// Days after which an untouched elective auto-archives.
|
||||
static const int electiveArchiveDays = 21;
|
||||
|
||||
/// Snooze tax: each snooze shortens the next interval instead of extending
|
||||
/// it, and the third becomes a full-screen alarm.
|
||||
static const List<int> snoozeLadderMinutes = <int>[10, 5, 2];
|
||||
|
||||
/// Minutes a push can go unacknowledged before SMS fallback fires.
|
||||
static const int smsFallbackMinutes = 20;
|
||||
}
|
||||
117
frontend/lib/Grounded/utils/ToneEngine.dart
Normal file
117
frontend/lib/Grounded/utils/ToneEngine.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import '../about/internal/application/EscalationTier.dart';
|
||||
import '../about/internal/application/Standing.dart';
|
||||
import '../about/internal/application/ToneLevel.dart';
|
||||
|
||||
/// All user-facing enforcement copy comes from here, so the hard cap is
|
||||
/// enforceable in one place: language may criticise behaviour, never the
|
||||
/// person. Nothing that mocks the user's worth ships.
|
||||
class ToneEngine {
|
||||
/// The escalation ladder. Disappointment is deliberately placed above anger
|
||||
/// because it works better as a lever.
|
||||
static EscalationTier tierFor(int unacknowledgedCount) {
|
||||
if (unacknowledgedCount <= 0) {
|
||||
return EscalationTier.Reminder;
|
||||
}
|
||||
if (unacknowledgedCount == 1) {
|
||||
return EscalationTier.Nudge;
|
||||
}
|
||||
if (unacknowledgedCount == 2) {
|
||||
return EscalationTier.Nag;
|
||||
}
|
||||
if (unacknowledgedCount == 3) {
|
||||
return EscalationTier.Disappointed;
|
||||
}
|
||||
return EscalationTier.Cold;
|
||||
}
|
||||
|
||||
/// Notification body for a commitment at a given tier and tone.
|
||||
static String nudge(
|
||||
EscalationTier tier,
|
||||
ToneLevel tone,
|
||||
String title,
|
||||
) {
|
||||
switch (tier) {
|
||||
case EscalationTier.Reminder:
|
||||
return "$title is due now.";
|
||||
case EscalationTier.Nudge:
|
||||
return "$title is still sitting there.";
|
||||
case EscalationTier.Nag:
|
||||
return tone == ToneLevel.Firm
|
||||
? "$title is overdue. It needs a decision."
|
||||
: "Third time: $title. Do it or abandon it.";
|
||||
case EscalationTier.Disappointed:
|
||||
return "You said you would do $title. You have not.";
|
||||
case EscalationTier.Cold:
|
||||
return "$title. No more reminders about this one.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The header copy on app open, which shifts with standing.
|
||||
static String standingHeadline(Standing standing, ToneLevel tone) {
|
||||
switch (standing) {
|
||||
case Standing.Good:
|
||||
return "You are in good standing.";
|
||||
case Standing.Warned:
|
||||
return "You are slipping.";
|
||||
case Standing.Grounded:
|
||||
return tone == ToneLevel.Firm
|
||||
? "You are grounded until this is cleared."
|
||||
: "Grounded. Nothing new until the queue is empty.";
|
||||
case Standing.Lockdown:
|
||||
return "Lockdown. One item at a time.";
|
||||
}
|
||||
}
|
||||
|
||||
static String standingBody(Standing standing, ToneLevel tone) {
|
||||
switch (standing) {
|
||||
case Standing.Good:
|
||||
return "Keep the plan honest and it stays this way.";
|
||||
case Standing.Warned:
|
||||
return "New electives are blocked. Clear some debt before adding more.";
|
||||
case Standing.Grounded:
|
||||
return "Your plans are hidden. This is what is actually outstanding.";
|
||||
case Standing.Lockdown:
|
||||
return "Complete or abandon each item below. There is no third option.";
|
||||
}
|
||||
}
|
||||
|
||||
/// Praise is rationed but real. A parent who only criticises gets tuned out —
|
||||
/// this returns empty unless something specific was genuinely earned.
|
||||
static String praise(String specificAchievement) {
|
||||
if (specificAchievement.isEmpty) {
|
||||
return "";
|
||||
}
|
||||
return specificAchievement;
|
||||
}
|
||||
|
||||
/// The register the app switches to when distress is detected. The strict
|
||||
/// persona drops entirely — this is the difference between a product people
|
||||
/// keep and one they resent.
|
||||
static String distressHeadline() {
|
||||
return "Let us cut this back.";
|
||||
}
|
||||
|
||||
static String distressBody() {
|
||||
return "Something has clearly been hard lately. Pick three things that "
|
||||
"genuinely matter this week and let the rest go. Nothing here is "
|
||||
"counting against you right now.";
|
||||
}
|
||||
|
||||
/// Copy shown when a deferral is refused because the cap is spent.
|
||||
static String deferralRefused(int maxDeferrals) {
|
||||
return "This has been deferred $maxDeferrals times. It can now only be "
|
||||
"completed or abandoned.";
|
||||
}
|
||||
|
||||
/// Copy shown when a non-negotiable deferral is attempted.
|
||||
static String nonNegotiableRefused() {
|
||||
return "Non-negotiables are not deferrable. That is what makes them "
|
||||
"non-negotiable.";
|
||||
}
|
||||
|
||||
/// Copy shown when the excuse is too short.
|
||||
static String excuseTooShort(int minimum) {
|
||||
return "Write at least $minimum characters. If it is not worth explaining, "
|
||||
"it is not worth deferring.";
|
||||
}
|
||||
}
|
||||
80
frontend/lib/Grounded/utils/Validators.dart
Normal file
80
frontend/lib/Grounded/utils/Validators.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'Thresholds.dart';
|
||||
|
||||
/// Field-level validation. Returns null when valid, so it drops straight into
|
||||
/// a TextFormField validator.
|
||||
class Validators {
|
||||
static String? required(String? value, String field) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "$field is required.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? title(String? value) {
|
||||
final String? empty = required(value, "Title");
|
||||
if (empty != null) {
|
||||
return empty;
|
||||
}
|
||||
if (value!.trim().length < 3) {
|
||||
return "Give it a name you will recognise later.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The excuse gate. Free text only, minimum length, no template buttons —
|
||||
/// the friction is the point.
|
||||
static String? excuse(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "An excuse is required to defer.";
|
||||
}
|
||||
if (value.trim().length < Thresholds.minExcuseLength) {
|
||||
return "Write at least ${Thresholds.minExcuseLength} characters.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? estimateMinutes(String? value) {
|
||||
final String? empty = required(value, "Estimate");
|
||||
if (empty != null) {
|
||||
return empty;
|
||||
}
|
||||
final int? minutes = int.tryParse(value!.trim());
|
||||
if (minutes == null || minutes <= 0) {
|
||||
return "Enter the minutes you think it will take.";
|
||||
}
|
||||
if (minutes > 720) {
|
||||
return "Nothing on a daily plan takes more than 12 hours. Split it.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A window, not a date. The end must actually close after the start.
|
||||
static String? window(DateTime? start, DateTime? end) {
|
||||
if (start == null || end == null) {
|
||||
return "Set a due window, not just a day.";
|
||||
}
|
||||
if (!end.isAfter(start)) {
|
||||
return "The window has to close after it opens.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? username(String? value) {
|
||||
final String? empty = required(value, "Username");
|
||||
if (empty != null) {
|
||||
return empty;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? password(String? value) {
|
||||
final String? empty = required(value, "Password");
|
||||
if (empty != null) {
|
||||
return empty;
|
||||
}
|
||||
if (value!.length < 8) {
|
||||
return "Passwords are at least 8 characters.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user