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