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,129 @@
import '../../internal/application/CommitmentClass.dart';
import '../../internal/application/CommitmentStatus.dart';
import '../../internal/application/CommitmentType.dart';
import '../../internal/application/EnergyCost.dart';
import '../../internal/application/ProofType.dart';
/// Something the user said they would do. A due *window*, not a due date —
/// "Mon 06:00-08:00" beats "Monday", because a window can actually close.
class Commitment {
String? id;
CommitmentType type;
CommitmentClass commitmentClass;
String title;
String category;
DateTime? dueStart;
DateTime? dueEnd;
int estMinutes;
EnergyCost energy;
ProofType proofType;
/// Minimum foreground minutes when proofType is Timer.
int proofTimerMinutes;
String rrule;
String? parentId;
CommitmentStatus status;
int deferralCount;
Commitment({
this.id,
this.type = CommitmentType.TASK,
this.commitmentClass = CommitmentClass.Standard,
this.title = "",
this.category = "",
this.dueStart,
this.dueEnd,
this.estMinutes = 0,
this.energy = EnergyCost.Medium,
this.proofType = ProofType.Honour,
this.proofTimerMinutes = 0,
this.rrule = "",
this.parentId,
this.status = CommitmentStatus.Open,
this.deferralCount = 0,
});
factory Commitment.fromJson(Map<String, dynamic> json) {
return Commitment(
id: json['id'],
type: getCommitmentType(json['type']),
commitmentClass: getCommitmentClass(json['commitmentClass']),
title: json['title'] ?? "",
category: json['category'] ?? "",
dueStart: DateTime.tryParse(json['dueStart'] ?? ""),
dueEnd: DateTime.tryParse(json['dueEnd'] ?? ""),
estMinutes: json['estMinutes'] ?? 0,
energy: getEnergyCost(json['energy']),
proofType: getProofType(json['proofType']),
proofTimerMinutes: json['proofTimerMinutes'] ?? 0,
rrule: json['rrule'] ?? "",
parentId: json['parentId'],
status: getCommitmentStatus(json['status']),
deferralCount: json['deferralCount'] ?? 0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['type'] = type.name;
data['commitmentClass'] = commitmentClass.name;
data['title'] = title;
data['category'] = category;
data['dueStart'] = dueStart?.toIso8601String();
data['dueEnd'] = dueEnd?.toIso8601String();
data['estMinutes'] = estMinutes;
data['energy'] = energy.name;
data['proofType'] = proofType.name;
data['proofTimerMinutes'] = proofTimerMinutes;
data['rrule'] = rrule;
data['parentId'] = parentId;
data['status'] = status.name;
data['deferralCount'] = deferralCount;
return data;
}
/// The window has closed. A commitment goes Overdue at close — it never
/// silently rolls over to today.
bool get windowClosed {
if (dueEnd == null) {
return false;
}
return DateTime.now().isAfter(dueEnd!);
}
/// Whole days past the close of the window; 0 while still open.
int get daysOverdue {
if (dueEnd == null || !windowClosed) {
return 0;
}
return DateTime.now().difference(dueEnd!).inDays;
}
/// Whether completing right now would count as a late complete rather than a
/// clean one. Late is recorded distinctly and never reduces debt to zero.
bool get wouldBeLate {
return windowClosed;
}
/// Non-negotiables are never deferrable, at any count.
bool deferrableUnder(int maxDeferrals) {
if (commitmentClass == CommitmentClass.NonNegotiable) {
return false;
}
return deferralCount < maxDeferrals;
}
}

View File

@@ -0,0 +1,53 @@
import '../../internal/application/EventType.dart';
/// Append-only history. This log — not the status field — is the source of
/// truth, and it is what makes excuse analysis and honest history possible.
class CommitmentEvent {
String? id;
String commitmentId;
EventType event;
DateTime? at;
String excuseText;
String? excuseClusterId;
String? proofRef;
CommitmentEvent({
this.id,
this.commitmentId = "",
this.event = EventType.CREATED,
this.at,
this.excuseText = "",
this.excuseClusterId,
this.proofRef,
});
factory CommitmentEvent.fromJson(Map<String, dynamic> json) {
return CommitmentEvent(
id: json['id'],
commitmentId: json['commitmentId'] ?? "",
event: getEventType(json['event']),
at: DateTime.tryParse(json['at'] ?? ""),
excuseText: json['excuseText'] ?? "",
excuseClusterId: json['excuseClusterId'],
proofRef: json['proofRef'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['commitmentId'] = commitmentId;
data['event'] = event.name;
data['at'] = at?.toIso8601String();
data['excuseText'] = excuseText;
data['excuseClusterId'] = excuseClusterId;
data['proofRef'] = proofRef;
return data;
}
}

View File

@@ -0,0 +1,46 @@
/// One line of the debt ledger. [decayedValue] is what the entry is worth
/// today, after recency decay has been applied.
class DebtEntry {
String? id;
double delta;
String reason;
String? commitmentId;
DateTime? at;
double decayedValue;
DebtEntry({
this.id,
this.delta = 0,
this.reason = "",
this.commitmentId,
this.at,
this.decayedValue = 0,
});
factory DebtEntry.fromJson(Map<String, dynamic> json) {
return DebtEntry(
id: json['id'],
delta: (json['delta'] ?? 0).toDouble(),
reason: json['reason'] ?? "",
commitmentId: json['commitmentId'],
at: DateTime.tryParse(json['at'] ?? ""),
decayedValue: (json['decayedValue'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['delta'] = delta;
data['reason'] = reason;
data['commitmentId'] = commitmentId;
data['at'] = at?.toIso8601String();
data['decayedValue'] = decayedValue;
return data;
}
}

View File

@@ -0,0 +1,72 @@
/// A recurring excuse plus the pattern the app confronts you with, e.g.
/// "Too tired has appeared 14 times this month, 11 of them on gym days,
/// 9 of them after 7pm. Consider moving gym to morning."
class ExcuseCluster {
String? id;
String label;
int occurrences;
/// Weekday histogram (1 = Monday) — where this excuse concentrates.
Map<int, int> byWeekday;
/// Hour-of-day histogram — when it concentrates.
Map<int, int> byHour;
/// The category this excuse most often attaches to.
String dominantCategory;
/// The confrontation copy rendered to the user.
String insight;
ExcuseCluster({
this.id,
this.label = "",
this.occurrences = 0,
Map<int, int>? byWeekday,
Map<int, int>? byHour,
this.dominantCategory = "",
this.insight = "",
}) : byWeekday = byWeekday ?? <int, int>{},
byHour = byHour ?? <int, int>{};
factory ExcuseCluster.fromJson(Map<String, dynamic> json) {
final Map<int, int> weekdays = <int, int>{};
if (json['byWeekday'] != null) {
(json['byWeekday'] as Map<String, dynamic>).forEach((key, value) {
weekdays[int.tryParse(key) ?? 1] = value ?? 0;
});
}
final Map<int, int> hours = <int, int>{};
if (json['byHour'] != null) {
(json['byHour'] as Map<String, dynamic>).forEach((key, value) {
hours[int.tryParse(key) ?? 0] = value ?? 0;
});
}
return ExcuseCluster(
id: json['id'],
label: json['label'] ?? "",
occurrences: json['occurrences'] ?? 0,
byWeekday: weekdays,
byHour: hours,
dominantCategory: json['dominantCategory'] ?? "",
insight: json['insight'] ?? "",
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['label'] = label;
data['occurrences'] = occurrences;
data['byWeekday'] =
byWeekday.map((key, value) => MapEntry(key.toString(), value));
data['byHour'] = byHour.map((key, value) => MapEntry(key.toString(), value));
data['dominantCategory'] = dominantCategory;
data['insight'] = insight;
return data;
}
}

View File

@@ -0,0 +1,91 @@
import '../../internal/application/ProgressionRule.dart';
class ExercisePrescription {
String? id;
String sessionTemplateId;
String exerciseId;
String exerciseName;
/// Primary muscle group, for weekly volume tracking.
String muscleGroup;
int sets;
int targetReps;
/// For timed holds and intervals; 0 when the work is rep-based.
int targetTimeSeconds;
int restSeconds;
/// Eccentric-pause-concentric-pause, e.g. "3010".
String tempo;
ProgressionRule progressionRule;
/// Ground contacts per set, for the plyometric weekly ceiling.
int contactsPerSet;
/// Whether this is a hard exercise for integrity scoring. Derived from
/// historical RPE rather than set statically.
bool hard;
ExercisePrescription({
this.id,
this.sessionTemplateId = "",
this.exerciseId = "",
this.exerciseName = "",
this.muscleGroup = "",
this.sets = 0,
this.targetReps = 0,
this.targetTimeSeconds = 0,
this.restSeconds = 0,
this.tempo = "",
this.progressionRule = ProgressionRule.Reps,
this.contactsPerSet = 0,
this.hard = false,
});
factory ExercisePrescription.fromJson(Map<String, dynamic> json) {
return ExercisePrescription(
id: json['id'],
sessionTemplateId: json['sessionTemplateId'] ?? "",
exerciseId: json['exerciseId'] ?? "",
exerciseName: json['exerciseName'] ?? "",
muscleGroup: json['muscleGroup'] ?? "",
sets: json['sets'] ?? 0,
targetReps: json['targetReps'] ?? 0,
targetTimeSeconds: json['targetTimeSeconds'] ?? 0,
restSeconds: json['restSeconds'] ?? 0,
tempo: json['tempo'] ?? "",
progressionRule: getProgressionRule(json['progressionRule']),
contactsPerSet: json['contactsPerSet'] ?? 0,
hard: json['hard'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['sessionTemplateId'] = sessionTemplateId;
data['exerciseId'] = exerciseId;
data['exerciseName'] = exerciseName;
data['muscleGroup'] = muscleGroup;
data['sets'] = sets;
data['targetReps'] = targetReps;
data['targetTimeSeconds'] = targetTimeSeconds;
data['restSeconds'] = restSeconds;
data['tempo'] = tempo;
data['progressionRule'] = progressionRule.name;
data['contactsPerSet'] = contactsPerSet;
data['hard'] = hard;
return data;
}
int get plannedContacts {
return contactsPerSet * sets;
}
}

View File

@@ -0,0 +1,110 @@
import '../../internal/application/CommitmentClass.dart';
/// The container a set of commitments belongs to — "Workout", "Thesis",
/// "Get the flat sorted". A goal owns its tasks through
/// [Commitment.parentId]; it never carries debt itself, because debt belongs
/// to the specific thing you said you would do, not the ambition behind it.
class Goal {
String? id;
String title;
String description;
String category;
/// The default class inherited by tasks created inside this goal.
CommitmentClass defaultClass;
DateTime? startDate;
/// Optional deadline for the goal as a whole.
DateTime? targetDate;
/// Colour accent, stored as a hex string so the goal reads consistently
/// wherever it appears.
String colourHex;
bool archived;
// ── Derived, supplied by the server ─────────────────────────────────────
int totalTasks;
int completedTasks;
int overdueTasks;
/// Debt accrued across every task under this goal.
double debtContribution;
Goal({
this.id,
this.title = "",
this.description = "",
this.category = "",
this.defaultClass = CommitmentClass.Standard,
this.startDate,
this.targetDate,
this.colourHex = "",
this.archived = false,
this.totalTasks = 0,
this.completedTasks = 0,
this.overdueTasks = 0,
this.debtContribution = 0,
});
factory Goal.fromJson(Map<String, dynamic> json) {
return Goal(
id: json['id'],
title: json['title'] ?? "",
description: json['description'] ?? "",
category: json['category'] ?? "",
defaultClass: getCommitmentClass(json['defaultClass']),
startDate: DateTime.tryParse(json['startDate'] ?? ""),
targetDate: DateTime.tryParse(json['targetDate'] ?? ""),
colourHex: json['colourHex'] ?? "",
archived: json['archived'] ?? false,
totalTasks: json['totalTasks'] ?? 0,
completedTasks: json['completedTasks'] ?? 0,
overdueTasks: json['overdueTasks'] ?? 0,
debtContribution: (json['debtContribution'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['description'] = description;
data['category'] = category;
data['defaultClass'] = defaultClass.name;
data['startDate'] = startDate?.toIso8601String();
data['targetDate'] = targetDate?.toIso8601String();
data['colourHex'] = colourHex;
data['archived'] = archived;
return data;
}
/// 0..1 across the goal's tasks.
double get progress {
if (totalTasks == 0) {
return 0;
}
return completedTasks / totalTasks;
}
/// A goal is in trouble when a meaningful share of its tasks are past their
/// windows, not merely because one slipped.
bool get slipping {
if (totalTasks == 0) {
return false;
}
return overdueTasks / totalTasks >= 0.34;
}
int get remainingTasks {
final int remaining = totalTasks - completedTasks;
return remaining > 0 ? remaining : 0;
}
}

View File

@@ -0,0 +1,45 @@
import 'Severity.dart';
class GroundedError {
double code;
String message;
String helper;
String title;
String severity;
GroundedError(
{required this.code,
required this.message,
required this.helper,
required this.title,
required this.severity});
factory GroundedError.fromJson(Map<String, dynamic> json) {
return GroundedError(
code: json['code'],
message: json['message'],
helper: json['helper'],
title: json['title'],
severity: json['severity'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['code'] = code;
data['message'] = message;
data['helper'] = helper;
data['title'] = title;
data['severity'] = severity;
return data;
}
Severity getSeverityEnum(String severityString) {
for (Severity severity in Severity.values) {
if (severityString == severity.name) {
return severity;
}
}
return Severity.error;
}
}

View File

@@ -0,0 +1,73 @@
/// Frequency-based rather than instance-based: a single miss costs nothing,
/// falling below the target in the rolling window is what accrues debt.
class Habit {
String? id;
String title;
String category;
/// Target completions per rolling window, e.g. 5.
int targetPerWindow;
/// Rolling window length in days, e.g. 7.
int windowDays;
/// Completions inside the current window.
int completionsInWindow;
/// Marked keystone once the data says its completion predicts day quality —
/// the app works this out after ~60 days rather than taking your word.
bool keystone;
/// 0..1 correlation with overall day quality; -1 until there is enough data.
double predictiveStrength;
Habit({
this.id,
this.title = "",
this.category = "",
this.targetPerWindow = 0,
this.windowDays = 7,
this.completionsInWindow = 0,
this.keystone = false,
this.predictiveStrength = -1,
});
factory Habit.fromJson(Map<String, dynamic> json) {
return Habit(
id: json['id'],
title: json['title'] ?? "",
category: json['category'] ?? "",
targetPerWindow: json['targetPerWindow'] ?? 0,
windowDays: json['windowDays'] ?? 7,
completionsInWindow: json['completionsInWindow'] ?? 0,
keystone: json['keystone'] ?? false,
predictiveStrength: (json['predictiveStrength'] ?? -1).toDouble(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['category'] = category;
data['targetPerWindow'] = targetPerWindow;
data['windowDays'] = windowDays;
data['completionsInWindow'] = completionsInWindow;
data['keystone'] = keystone;
data['predictiveStrength'] = predictiveStrength;
return data;
}
/// Behind target for the window — the only condition under which a habit
/// accrues debt.
bool get behindTarget {
return completionsInWindow < targetPerWindow;
}
int get shortfall {
final int gap = targetPerWindow - completionsInWindow;
return gap > 0 ? gap : 0;
}
}

View File

@@ -0,0 +1,118 @@
/// A task actively being run. This is what the full-screen runner and the
/// ongoing notification are both driven from.
class LiveSession {
String commitmentId;
String title;
String goalTitle;
/// Wall-clock start of the run.
DateTime startedAt;
/// Seconds of *foreground* work accumulated. Backgrounding the app stops
/// this accruing — that is the whole point of Timer proof.
int accumulatedSeconds;
/// When the current active stretch began; null while paused.
DateTime? resumedAt;
/// Seconds required before the run counts as proof.
int requiredSeconds;
/// How many times the user left the app mid-run. Surfaced afterwards rather
/// than hidden, because leaving repeatedly is the behaviour worth seeing.
int backgroundedCount;
LiveSession({
this.commitmentId = "",
this.title = "",
this.goalTitle = "",
required this.startedAt,
this.accumulatedSeconds = 0,
this.resumedAt,
this.requiredSeconds = 0,
this.backgroundedCount = 0,
});
factory LiveSession.fromJson(Map<String, dynamic> json) {
return LiveSession(
commitmentId: json['commitmentId'] ?? "",
title: json['title'] ?? "",
goalTitle: json['goalTitle'] ?? "",
startedAt: DateTime.tryParse(json['startedAt'] ?? "") ?? DateTime.now(),
accumulatedSeconds: json['accumulatedSeconds'] ?? 0,
resumedAt: DateTime.tryParse(json['resumedAt'] ?? ""),
requiredSeconds: json['requiredSeconds'] ?? 0,
backgroundedCount: json['backgroundedCount'] ?? 0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
data['title'] = title;
data['goalTitle'] = goalTitle;
data['startedAt'] = startedAt.toIso8601String();
data['accumulatedSeconds'] = accumulatedSeconds;
data['resumedAt'] = resumedAt?.toIso8601String();
data['requiredSeconds'] = requiredSeconds;
data['backgroundedCount'] = backgroundedCount;
return data;
}
bool get running {
return resumedAt != null;
}
/// Foreground seconds as of now, including the stretch in progress. This is
/// derived from wall-clock rather than counted by the ticker, so the value
/// stays correct across a screen-off period where timers are throttled.
int elapsedSeconds({DateTime? now}) {
if (resumedAt == null) {
return accumulatedSeconds;
}
final DateTime moment = now ?? DateTime.now();
return accumulatedSeconds + moment.difference(resumedAt!).inSeconds;
}
int remainingSeconds({DateTime? now}) {
final int remaining = requiredSeconds - elapsedSeconds(now: now);
return remaining > 0 ? remaining : 0;
}
/// 0..1 toward the requirement.
double progress({DateTime? now}) {
if (requiredSeconds <= 0) {
return 0;
}
final double value = elapsedSeconds(now: now) / requiredSeconds;
return value > 1 ? 1 : value;
}
/// The requirement has been met — completion is now allowed.
bool satisfied({DateTime? now}) {
if (requiredSeconds <= 0) {
return true;
}
return elapsedSeconds(now: now) >= requiredSeconds;
}
/// Pause and bank the stretch that just ended.
void pause({DateTime? now}) {
if (resumedAt == null) {
return;
}
final DateTime moment = now ?? DateTime.now();
accumulatedSeconds =
accumulatedSeconds + moment.difference(resumedAt!).inSeconds;
resumedAt = null;
}
void resume({DateTime? now}) {
if (resumedAt != null) {
return;
}
resumedAt = now ?? DateTime.now();
}
}

View File

@@ -0,0 +1,66 @@
/// A training program. Deload weeks are scheduled and enforced — training
/// through a deload logs as non-compliance, same as skipping.
class Program {
String? id;
String name;
int weeks;
int sessionsPerWeek;
/// 1-based week indices that are deloads.
List<int> deloadWeeks;
/// Weekly ceiling on plyometric ground contacts. Plyo is the one modality
/// where the app stops you rather than pushes you.
int weeklyContactCeiling;
/// Mandatory hours between high-intensity lower-body sessions.
int lowerBodyRecoveryHours;
bool active;
Program({
this.id,
this.name = "",
this.weeks = 0,
this.sessionsPerWeek = 0,
List<int>? deloadWeeks,
this.weeklyContactCeiling = 0,
this.lowerBodyRecoveryHours = 48,
this.active = false,
}) : deloadWeeks = deloadWeeks ?? <int>[];
factory Program.fromJson(Map<String, dynamic> json) {
return Program(
id: json['id'],
name: json['name'] ?? "",
weeks: json['weeks'] ?? 0,
sessionsPerWeek: json['sessionsPerWeek'] ?? 0,
deloadWeeks: json['deloadWeeks'] == null
? <int>[]
: (json['deloadWeeks'] as List).map((item) => item as int).toList(),
weeklyContactCeiling: json['weeklyContactCeiling'] ?? 0,
lowerBodyRecoveryHours: json['lowerBodyRecoveryHours'] ?? 48,
active: json['active'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['weeks'] = weeks;
data['sessionsPerWeek'] = sessionsPerWeek;
data['deloadWeeks'] = deloadWeeks;
data['weeklyContactCeiling'] = weeklyContactCeiling;
data['lowerBodyRecoveryHours'] = lowerBodyRecoveryHours;
data['active'] = active;
return data;
}
bool isDeloadWeek(int week) {
return deloadWeeks.contains(week);
}
}

View File

@@ -0,0 +1,192 @@
import 'ExcuseCluster.dart';
/// The weekly parent-teacher conference. One assigned action for next week —
/// not five.
class ReportCard {
String? id;
DateTime? periodStart;
DateTime? periodEnd;
/// Completion rate 0..1, keyed by commitment class name.
Map<String, double> completionByClass;
/// Completion rate 0..1, keyed by category.
Map<String, double> completionByCategory;
/// Completion rate 0..1, keyed by weekday (1 = Monday).
Map<int, double> completionByWeekday;
/// The recurring window where things go to die, as an hour of the day.
int worstHour;
/// Debt score sampled per day across the period.
List<double> debtTrend;
/// The commitments dodged most, highest first.
List<DeferralCount> deferralLeaderboard;
List<ExcuseCluster> excuseTaxonomy;
/// Estimation multiplier per category, surfaced here rather than hidden.
Map<String, double> estimationAccuracy;
/// Training adherence 0..1 across the period.
double trainingAdherence;
double programIntegrity;
/// The single assigned action for next week.
String assignedAction;
/// Cosmetic but effective.
String grade;
/// Rationed, specific praise — empty when nothing was genuinely earned.
String praise;
ReportCard({
this.id,
this.periodStart,
this.periodEnd,
Map<String, double>? completionByClass,
Map<String, double>? completionByCategory,
Map<int, double>? completionByWeekday,
this.worstHour = -1,
List<double>? debtTrend,
List<DeferralCount>? deferralLeaderboard,
List<ExcuseCluster>? excuseTaxonomy,
Map<String, double>? estimationAccuracy,
this.trainingAdherence = 0,
this.programIntegrity = 0,
this.assignedAction = "",
this.grade = "",
this.praise = "",
}) : completionByClass = completionByClass ?? <String, double>{},
completionByCategory = completionByCategory ?? <String, double>{},
completionByWeekday = completionByWeekday ?? <int, double>{},
debtTrend = debtTrend ?? <double>[],
deferralLeaderboard = deferralLeaderboard ?? <DeferralCount>[],
excuseTaxonomy = excuseTaxonomy ?? <ExcuseCluster>[],
estimationAccuracy = estimationAccuracy ?? <String, double>{};
factory ReportCard.fromJson(Map<String, dynamic> json) {
final Map<String, double> byClass = <String, double>{};
if (json['completionByClass'] != null) {
(json['completionByClass'] as Map<String, dynamic>).forEach((key, value) {
byClass[key] = (value ?? 0).toDouble();
});
}
final Map<String, double> byCategory = <String, double>{};
if (json['completionByCategory'] != null) {
(json['completionByCategory'] as Map<String, dynamic>)
.forEach((key, value) {
byCategory[key] = (value ?? 0).toDouble();
});
}
final Map<int, double> byWeekday = <int, double>{};
if (json['completionByWeekday'] != null) {
(json['completionByWeekday'] as Map<String, dynamic>)
.forEach((key, value) {
byWeekday[int.tryParse(key) ?? 1] = (value ?? 0).toDouble();
});
}
final Map<String, double> estimation = <String, double>{};
if (json['estimationAccuracy'] != null) {
(json['estimationAccuracy'] as Map<String, dynamic>)
.forEach((key, value) {
estimation[key] = (value ?? 1).toDouble();
});
}
return ReportCard(
id: json['id'],
periodStart: DateTime.tryParse(json['periodStart'] ?? ""),
periodEnd: DateTime.tryParse(json['periodEnd'] ?? ""),
completionByClass: byClass,
completionByCategory: byCategory,
completionByWeekday: byWeekday,
worstHour: json['worstHour'] ?? -1,
debtTrend: json['debtTrend'] == null
? <double>[]
: (json['debtTrend'] as List)
.map((item) => (item ?? 0).toDouble() as double)
.toList(),
deferralLeaderboard: json['deferralLeaderboard'] == null
? <DeferralCount>[]
: (json['deferralLeaderboard'] as List)
.map((item) => DeferralCount.fromJson(item))
.toList(),
excuseTaxonomy: json['excuseTaxonomy'] == null
? <ExcuseCluster>[]
: (json['excuseTaxonomy'] as List)
.map((item) => ExcuseCluster.fromJson(item))
.toList(),
estimationAccuracy: estimation,
trainingAdherence: (json['trainingAdherence'] ?? 0).toDouble(),
programIntegrity: (json['programIntegrity'] ?? 0).toDouble(),
assignedAction: json['assignedAction'] ?? "",
grade: json['grade'] ?? "",
praise: json['praise'] ?? "",
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['periodStart'] = periodStart?.toIso8601String();
data['periodEnd'] = periodEnd?.toIso8601String();
data['completionByClass'] = completionByClass;
data['completionByCategory'] = completionByCategory;
data['completionByWeekday'] =
completionByWeekday.map((key, value) => MapEntry(key.toString(), value));
data['worstHour'] = worstHour;
data['debtTrend'] = debtTrend;
data['deferralLeaderboard'] =
deferralLeaderboard.map((item) => item.toJson()).toList();
data['excuseTaxonomy'] =
excuseTaxonomy.map((item) => item.toJson()).toList();
data['estimationAccuracy'] = estimationAccuracy;
data['trainingAdherence'] = trainingAdherence;
data['programIntegrity'] = programIntegrity;
data['assignedAction'] = assignedAction;
data['grade'] = grade;
data['praise'] = praise;
return data;
}
}
/// One row of the deferral leaderboard — the tasks dodged most.
class DeferralCount {
String commitmentId;
String title;
int count;
DeferralCount({
this.commitmentId = "",
this.title = "",
this.count = 0,
});
factory DeferralCount.fromJson(Map<String, dynamic> json) {
return DeferralCount(
commitmentId: json['commitmentId'] ?? "",
title: json['title'] ?? "",
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
data['title'] = title;
data['count'] = count;
return data;
}
}

View File

@@ -0,0 +1 @@
enum ResponseState { Success, Failure, Pending }

View File

@@ -0,0 +1,97 @@
/// An ordered sequence where the chain, not the step, is the unit of
/// completion. Breaking mid-way logs partial.
class RoutineChain {
String? id;
String title;
List<RoutineStep> steps;
/// Index of the step reached when the chain last broke; -1 when clean.
int lastBreakIndex;
RoutineChain({
this.id,
this.title = "",
List<RoutineStep>? steps,
this.lastBreakIndex = -1,
}) : steps = steps ?? <RoutineStep>[];
factory RoutineChain.fromJson(Map<String, dynamic> json) {
return RoutineChain(
id: json['id'],
title: json['title'] ?? "",
steps: json['steps'] == null
? <RoutineStep>[]
: (json['steps'] as List)
.map((item) => RoutineStep.fromJson(item))
.toList(),
lastBreakIndex: json['lastBreakIndex'] ?? -1,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['steps'] = steps.map((item) => item.toJson()).toList();
data['lastBreakIndex'] = lastBreakIndex;
return data;
}
int get completedSteps {
return steps.where((step) => step.completed).length;
}
/// 0..1 — a partially run chain is recorded as partial, not as done.
double get partialCompletion {
if (steps.isEmpty) {
return 0;
}
return completedSteps / steps.length;
}
bool get complete {
return steps.isNotEmpty && completedSteps == steps.length;
}
}
class RoutineStep {
String? id;
String title;
int order;
int timerSeconds;
bool completed;
RoutineStep({
this.id,
this.title = "",
this.order = 0,
this.timerSeconds = 0,
this.completed = false,
});
factory RoutineStep.fromJson(Map<String, dynamic> json) {
return RoutineStep(
id: json['id'],
title: json['title'] ?? "",
order: json['order'] ?? 0,
timerSeconds: json['timerSeconds'] ?? 0,
completed: json['completed'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['order'] = order;
data['timerSeconds'] = timerSeconds;
data['completed'] = completed;
return data;
}
}

View File

@@ -0,0 +1,105 @@
import 'SetLog.dart';
class SessionLog {
String? id;
String templateId;
String templateName;
DateTime? startedAt;
DateTime? endedAt;
/// Session RPE 1..10.
double sessionRpe;
/// Readiness check-in at session start.
int sleepScore;
int sorenessScore;
int motivationScore;
/// Did you do the session, or a watered-down version of it?
double integrityScore;
bool duringDeload;
List<SetLog> sets;
SessionLog({
this.id,
this.templateId = "",
this.templateName = "",
this.startedAt,
this.endedAt,
this.sessionRpe = 0,
this.sleepScore = 0,
this.sorenessScore = 0,
this.motivationScore = 0,
this.integrityScore = 0,
this.duringDeload = false,
List<SetLog>? sets,
}) : sets = sets ?? <SetLog>[];
factory SessionLog.fromJson(Map<String, dynamic> json) {
return SessionLog(
id: json['id'],
templateId: json['templateId'] ?? "",
templateName: json['templateName'] ?? "",
startedAt: DateTime.tryParse(json['startedAt'] ?? ""),
endedAt: DateTime.tryParse(json['endedAt'] ?? ""),
sessionRpe: (json['sessionRpe'] ?? 0).toDouble(),
sleepScore: json['sleepScore'] ?? 0,
sorenessScore: json['sorenessScore'] ?? 0,
motivationScore: json['motivationScore'] ?? 0,
integrityScore: (json['integrityScore'] ?? 0).toDouble(),
duringDeload: json['duringDeload'] ?? false,
sets: json['sets'] == null
? <SetLog>[]
: (json['sets'] as List).map((item) => SetLog.fromJson(item)).toList(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['templateId'] = templateId;
data['templateName'] = templateName;
data['startedAt'] = startedAt?.toIso8601String();
data['endedAt'] = endedAt?.toIso8601String();
data['sessionRpe'] = sessionRpe;
data['sleepScore'] = sleepScore;
data['sorenessScore'] = sorenessScore;
data['motivationScore'] = motivationScore;
data['integrityScore'] = integrityScore;
data['duringDeload'] = duringDeload;
data['sets'] = sets.map((item) => item.toJson()).toList();
return data;
}
int get durationMinutes {
if (startedAt == null || endedAt == null) {
return 0;
}
return endedAt!.difference(startedAt!).inMinutes;
}
/// Weekly tonnage contribution for loaded work.
double get tonnage {
double total = 0;
for (SetLog entry in sets) {
total = total + (entry.loadKg * entry.reps);
}
return total;
}
int get contacts {
int total = 0;
for (SetLog entry in sets) {
total = total + entry.contacts;
}
return total;
}
}

View File

@@ -0,0 +1,53 @@
import 'ExercisePrescription.dart';
class SessionTemplate {
String? id;
String programId;
/// 0-based day within the training week.
int dayIndex;
String name;
/// True when this session loads the lower body hard enough to require the
/// program recovery gap before the next one.
bool highIntensityLowerBody;
List<ExercisePrescription> prescriptions;
SessionTemplate({
this.id,
this.programId = "",
this.dayIndex = 0,
this.name = "",
this.highIntensityLowerBody = false,
List<ExercisePrescription>? prescriptions,
}) : prescriptions = prescriptions ?? <ExercisePrescription>[];
factory SessionTemplate.fromJson(Map<String, dynamic> json) {
return SessionTemplate(
id: json['id'],
programId: json['programId'] ?? "",
dayIndex: json['dayIndex'] ?? 0,
name: json['name'] ?? "",
highIntensityLowerBody: json['highIntensityLowerBody'] ?? false,
prescriptions: json['prescriptions'] == null
? <ExercisePrescription>[]
: (json['prescriptions'] as List)
.map((item) => ExercisePrescription.fromJson(item))
.toList(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['programId'] = programId;
data['dayIndex'] = dayIndex;
data['name'] = name;
data['highIntensityLowerBody'] = highIntensityLowerBody;
data['prescriptions'] = prescriptions.map((item) => item.toJson()).toList();
return data;
}
}

View File

@@ -0,0 +1,76 @@
class SetLog {
String? id;
String sessionLogId;
String exerciseId;
String exerciseName;
String muscleGroup;
int setNo;
int reps;
double loadKg;
int timeSeconds;
double rpe;
/// False when the set was improvised rather than prescribed — this is what
/// separates doing the session from doing something adjacent to it.
bool isPrescribed;
int contacts;
SetLog({
this.id,
this.sessionLogId = "",
this.exerciseId = "",
this.exerciseName = "",
this.muscleGroup = "",
this.setNo = 0,
this.reps = 0,
this.loadKg = 0,
this.timeSeconds = 0,
this.rpe = 0,
this.isPrescribed = true,
this.contacts = 0,
});
factory SetLog.fromJson(Map<String, dynamic> json) {
return SetLog(
id: json['id'],
sessionLogId: json['sessionLogId'] ?? "",
exerciseId: json['exerciseId'] ?? "",
exerciseName: json['exerciseName'] ?? "",
muscleGroup: json['muscleGroup'] ?? "",
setNo: json['setNo'] ?? 0,
reps: json['reps'] ?? 0,
loadKg: (json['loadKg'] ?? 0).toDouble(),
timeSeconds: json['timeSeconds'] ?? 0,
rpe: (json['rpe'] ?? 0).toDouble(),
isPrescribed: json['isPrescribed'] ?? true,
contacts: json['contacts'] ?? 0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['sessionLogId'] = sessionLogId;
data['exerciseId'] = exerciseId;
data['exerciseName'] = exerciseName;
data['muscleGroup'] = muscleGroup;
data['setNo'] = setNo;
data['reps'] = reps;
data['loadKg'] = loadKg;
data['timeSeconds'] = timeSeconds;
data['rpe'] = rpe;
data['isPrescribed'] = isPrescribed;
data['contacts'] = contacts;
return data;
}
}

View File

@@ -0,0 +1 @@
enum Severity { warning, alert, message, error }

View File

@@ -0,0 +1,41 @@
import '../../internal/application/Standing.dart';
class StandingChange {
String? id;
Standing from;
Standing to;
DateTime? at;
String trigger;
StandingChange({
this.id,
this.from = Standing.Good,
this.to = Standing.Good,
this.at,
this.trigger = "",
});
factory StandingChange.fromJson(Map<String, dynamic> json) {
return StandingChange(
id: json['id'],
from: getStanding(json['from']),
to: getStanding(json['to']),
at: DateTime.tryParse(json['at'] ?? ""),
trigger: json['trigger'] ?? "",
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['from'] = from.name;
data['to'] = to.name;
data['at'] = at?.toIso8601String();
data['trigger'] = trigger;
return data;
}
}

View File

@@ -0,0 +1,40 @@
import 'ResponseState.dart';
class SystemResponse {
String key;
String value;
String description;
ResponseState state;
SystemResponse(this.key, this.value, this.description, this.state);
factory SystemResponse.fromJsonMap(Map<String, dynamic> json) {
return SystemResponse(
json['key'] ?? "",
json['value'] ?? "",
json['description'] ?? "",
_state(json['state']),
);
}
static ResponseState _state(String? name) {
for (ResponseState value in ResponseState.values) {
if (value.name == name) {
return value;
}
}
return ResponseState.Success;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['key'] = key;
data['value'] = value;
data['description'] = description;
data['state'] = state.name;
return data;
}
}

View File

@@ -0,0 +1,21 @@
import 'PageAndSort.dart';
class CommitmentsRequest {
PageAndSort? query;
/// ISO day the plan is being requested for; empty means today.
String day;
/// Filter by status name; empty means all.
String status;
CommitmentsRequest({this.query, this.day = "", this.status = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['query'] = query?.toJson();
data['day'] = day;
data['status'] = status;
return data;
}
}

View File

@@ -0,0 +1,14 @@
import 'PageAndSort.dart';
/// Every list endpoint takes this shape. Never inline flat sort/page fields.
class HistoryRequest {
PageAndSort? query;
HistoryRequest({this.query});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['query'] = query?.toJson();
return data;
}
}

View File

@@ -0,0 +1,17 @@
import 'Pageable.dart';
import 'Sort.dart';
class PageAndSort {
Sort? sort;
Pageable? page;
PageAndSort({this.sort, this.page});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['sort'] = sort?.toJson();
data['page'] = page?.toJson();
return data;
}
}

View File

@@ -0,0 +1,20 @@
class Pageable {
int offset;
int pageNumber;
int pageSize;
int paged;
Pageable(this.offset, this.pageNumber, this.pageSize, this.paged);
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['offset'] = offset;
data['pageNumber'] = pageNumber;
data['pageSize'] = pageSize;
data['paged'] = paged;
return data;
}
}

View File

@@ -0,0 +1,14 @@
class Sort {
String direction;
String field;
Sort(this.direction, this.field);
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['direction'] = direction;
data['field'] = field;
return data;
}
}

View File

@@ -0,0 +1,47 @@
import '../../CommitmentEvent.dart';
class CommitmentEventPage {
int number;
int size;
int totalElements;
int totalPages;
int numberOfElements;
bool first;
bool last;
List<CommitmentEvent> content;
CommitmentEventPage({
this.number = 0,
this.size = 0,
this.totalElements = 0,
this.totalPages = 0,
this.numberOfElements = 0,
this.first = true,
this.last = true,
List<CommitmentEvent>? content,
}) : content = content ?? <CommitmentEvent>[];
factory CommitmentEventPage.fromJson(Map<String, dynamic> json) {
return CommitmentEventPage(
number: json['number'] ?? 0,
size: json['size'] ?? 0,
totalElements: json['totalElements'] ?? 0,
totalPages: json['totalPages'] ?? 0,
numberOfElements: json['numberOfElements'] ?? 0,
first: json['first'] ?? true,
last: json['last'] ?? true,
content: json['content'] == null
? <CommitmentEvent>[]
: (json['content'] as List)
.map((item) => CommitmentEvent.fromJson(item))
.toList(),
);
}
}

View File

@@ -0,0 +1,47 @@
import '../../Commitment.dart';
class CommitmentPage {
int number;
int size;
int totalElements;
int totalPages;
int numberOfElements;
bool first;
bool last;
List<Commitment> content;
CommitmentPage({
this.number = 0,
this.size = 0,
this.totalElements = 0,
this.totalPages = 0,
this.numberOfElements = 0,
this.first = true,
this.last = true,
List<Commitment>? content,
}) : content = content ?? <Commitment>[];
factory CommitmentPage.fromJson(Map<String, dynamic> json) {
return CommitmentPage(
number: json['number'] ?? 0,
size: json['size'] ?? 0,
totalElements: json['totalElements'] ?? 0,
totalPages: json['totalPages'] ?? 0,
numberOfElements: json['numberOfElements'] ?? 0,
first: json['first'] ?? true,
last: json['last'] ?? true,
content: json['content'] == null
? <Commitment>[]
: (json['content'] as List)
.map((item) => Commitment.fromJson(item))
.toList(),
);
}
}

View File

@@ -0,0 +1,47 @@
import '../../SessionLog.dart';
class SessionLogPage {
int number;
int size;
int totalElements;
int totalPages;
int numberOfElements;
bool first;
bool last;
List<SessionLog> content;
SessionLogPage({
this.number = 0,
this.size = 0,
this.totalElements = 0,
this.totalPages = 0,
this.numberOfElements = 0,
this.first = true,
this.last = true,
List<SessionLog>? content,
}) : content = content ?? <SessionLog>[];
factory SessionLogPage.fromJson(Map<String, dynamic> json) {
return SessionLogPage(
number: json['number'] ?? 0,
size: json['size'] ?? 0,
totalElements: json['totalElements'] ?? 0,
totalPages: json['totalPages'] ?? 0,
numberOfElements: json['numberOfElements'] ?? 0,
first: json['first'] ?? true,
last: json['last'] ?? true,
content: json['content'] == null
? <SessionLog>[]
: (json['content'] as List)
.map((item) => SessionLog.fromJson(item))
.toList(),
);
}
}