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,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";
}
}

View 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()}';
}

View 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:0008: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";
}

View 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);
}
}

View 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";
}
}

View 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;
}

View 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";

View 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);
}
}

View 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();
}

View 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;
}
}
}

View 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;
}

View 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.";
}
}

View 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;
}
}