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

View File

@@ -0,0 +1,16 @@
/// Abandoning costs the most debt of all, so it is always explicit and always
/// carries a reason.
class AbandonRequest {
String commitmentId;
String reason;
AbandonRequest({this.commitmentId = "", this.reason = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
data['reason'] = reason;
return data;
}
}

View File

@@ -0,0 +1,13 @@
/// Spend one of the rationed monthly tokens to wipe an item debt, no questions
/// asked.
class AmnestyRequest {
String commitmentId;
AmnestyRequest({this.commitmentId = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
return data;
}
}

View File

@@ -0,0 +1,58 @@
/// Create or update a commitment.
class CommitmentRequest {
String? id;
String type;
String commitmentClass;
String title;
String category;
String dueStart;
String dueEnd;
int estMinutes;
String energy;
String proofType;
int proofTimerMinutes;
String rrule;
CommitmentRequest({
this.id,
this.type = "TASK",
this.commitmentClass = "Standard",
this.title = "",
this.category = "",
this.dueStart = "",
this.dueEnd = "",
this.estMinutes = 0,
this.energy = "Medium",
this.proofType = "Honour",
this.proofTimerMinutes = 0,
this.rrule = "",
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['type'] = type;
data['commitmentClass'] = commitmentClass;
data['title'] = title;
data['category'] = category;
data['dueStart'] = dueStart;
data['dueEnd'] = dueEnd;
data['estMinutes'] = estMinutes;
data['energy'] = energy;
data['proofType'] = proofType;
data['proofTimerMinutes'] = proofTimerMinutes;
data['rrule'] = rrule;
return data;
}
}

View File

@@ -0,0 +1,38 @@
/// Completion always carries its proof. The server decides Complete vs Late
/// Complete from the window, never the client.
class CompletionRequest {
String commitmentId;
String proofType;
/// Reference to the uploaded artefact — photo id, timer session id, geofence
/// dwell id or witness confirmation id.
String proofRef;
/// Foreground seconds actually run, for Timer proof.
int timerSeconds;
double latitude;
double longitude;
CompletionRequest({
this.commitmentId = "",
this.proofType = "Honour",
this.proofRef = "",
this.timerSeconds = 0,
this.latitude = 0,
this.longitude = 0,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
data['proofType'] = proofType;
data['proofRef'] = proofRef;
data['timerSeconds'] = timerSeconds;
data['latitude'] = latitude;
data['longitude'] = longitude;
return data;
}
}

View File

@@ -0,0 +1,27 @@
/// Deferral always carries an excuse. Free text, minimum length enforced, no
/// template buttons — the friction is the point.
class DeferralRequest {
String commitmentId;
String excuseText;
String newDueStart;
String newDueEnd;
DeferralRequest({
this.commitmentId = "",
this.excuseText = "",
this.newDueStart = "",
this.newDueEnd = "",
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['commitmentId'] = commitmentId;
data['excuseText'] = excuseText;
data['newDueStart'] = newDueStart;
data['newDueEnd'] = newDueEnd;
return data;
}
}

View File

@@ -0,0 +1,25 @@
class DeviceRequest {
String identifier;
String model;
String platform;
String version;
DeviceRequest({
this.identifier = "",
this.model = "",
this.platform = "",
this.version = "",
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['identifier'] = identifier;
data['model'] = model;
data['platform'] = platform;
data['version'] = version;
return data;
}
}

View File

@@ -0,0 +1,41 @@
class GoalRequest {
String? id;
String title;
String description;
String category;
String defaultClass;
String startDate;
String targetDate;
String colourHex;
GoalRequest({
this.id,
this.title = "",
this.description = "",
this.category = "",
this.defaultClass = "Standard",
this.startDate = "",
this.targetDate = "",
this.colourHex = "",
});
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;
data['startDate'] = startDate;
data['targetDate'] = targetDate;
data['colourHex'] = colourHex;
return data;
}
}

View File

@@ -0,0 +1,11 @@
class IdRequest {
String id;
IdRequest({this.id = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
return data;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
class SessionLogRequest {
String? id;
String templateId;
String startedAt;
String endedAt;
double sessionRpe;
int sleepScore;
int sorenessScore;
int motivationScore;
List<Map<String, dynamic>> sets;
SessionLogRequest({
this.id,
this.templateId = "",
this.startedAt = "",
this.endedAt = "",
this.sessionRpe = 0,
this.sleepScore = 0,
this.sorenessScore = 0,
this.motivationScore = 0,
List<Map<String, dynamic>>? sets,
}) : sets = sets ?? <Map<String, dynamic>>[];
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['templateId'] = templateId;
data['startedAt'] = startedAt;
data['endedAt'] = endedAt;
data['sessionRpe'] = sessionRpe;
data['sleepScore'] = sleepScore;
data['sorenessScore'] = sorenessScore;
data['motivationScore'] = motivationScore;
data['sets'] = sets;
return data;
}
}

View File

@@ -0,0 +1,18 @@
/// Pauses debt accrual entirely. Requires a reason and is logged in history.
class SickModeRequest {
bool enabled;
String reason;
String until;
SickModeRequest({this.enabled = false, this.reason = "", this.until = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['enabled'] = enabled;
data['reason'] = reason;
data['until'] = until;
return data;
}
}

View File

@@ -0,0 +1,11 @@
class ToneRequest {
String tone;
ToneRequest({this.tone = "Strict"});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['tone'] = tone;
return data;
}
}

View File

@@ -0,0 +1,57 @@
/// What the user can realistically absorb, learned from history rather than
/// asserted. Drives the capacity block at plan time.
class CapacityProfile {
/// p50 of historically completed minutes, keyed by weekday (1 = Monday).
Map<int, double> completedMinutesByWeekday;
/// Per-category estimation multiplier — you say 30min, you take 70min -> 2.3.
Map<String, double> estimationMultipliers;
CapacityProfile({
Map<int, double>? completedMinutesByWeekday,
Map<String, double>? estimationMultipliers,
}) : completedMinutesByWeekday = completedMinutesByWeekday ?? <int, double>{},
estimationMultipliers = estimationMultipliers ?? <String, double>{};
factory CapacityProfile.fromJson(Map<String, dynamic> json) {
final Map<int, double> minutes = <int, double>{};
if (json['completedMinutesByWeekday'] != null) {
(json['completedMinutesByWeekday'] as Map<String, dynamic>)
.forEach((key, value) {
minutes[int.tryParse(key) ?? 1] = (value ?? 0).toDouble();
});
}
final Map<String, double> multipliers = <String, double>{};
if (json['estimationMultipliers'] != null) {
(json['estimationMultipliers'] as Map<String, dynamic>)
.forEach((key, value) {
multipliers[key] = (value ?? 1).toDouble();
});
}
return CapacityProfile(
completedMinutesByWeekday: minutes,
estimationMultipliers: multipliers,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['completedMinutesByWeekday'] = completedMinutesByWeekday
.map((key, value) => MapEntry(key.toString(), value));
data['estimationMultipliers'] = estimationMultipliers;
return data;
}
/// The learned multiplier for a category, defaulting to honest 1.0 until
/// there is enough history to say otherwise.
double multiplierFor(String category) {
return estimationMultipliers[category] ?? 1.0;
}
/// The p50 of what actually gets done on this weekday.
double capacityFor(int weekday) {
return completedMinutesByWeekday[weekday] ?? 0;
}
}

View File

@@ -0,0 +1,44 @@
/// The disciplinary weight class of a commitment. Central, not cosmetic — the
/// class decides debt weight, deferability and escalation harshness.
enum CommitmentClass {
/// Never auto-deferrable, heaviest debt weight, hardest escalation.
NonNegotiable,
/// Normal weight, limited deferrals.
Standard,
/// Nice-to-have, no debt on miss, auto-archives.
Elective,
}
/// Debt weight w(class) from the debt formula.
double classWeight(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return 5.0;
case CommitmentClass.Standard:
return 2.0;
case CommitmentClass.Elective:
return 0.0;
}
}
String classLabel(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return "Non-negotiable";
case CommitmentClass.Standard:
return "Standard";
case CommitmentClass.Elective:
return "Elective";
}
}
CommitmentClass getCommitmentClass(String? name) {
for (CommitmentClass value in CommitmentClass.values) {
if (value.name == name) {
return value;
}
}
return CommitmentClass.Standard;
}

View File

@@ -0,0 +1,18 @@
enum CommitmentStatus {
Open,
Completed,
LateCompleted,
Overdue,
Deferred,
Abandoned,
Archived,
}
CommitmentStatus getCommitmentStatus(String? name) {
for (CommitmentStatus value in CommitmentStatus.values) {
if (value.name == name) {
return value;
}
}
return CommitmentStatus.Open;
}

View File

@@ -0,0 +1,10 @@
enum CommitmentType { TASK, SESSION, HABIT }
CommitmentType getCommitmentType(String? name) {
for (CommitmentType value in CommitmentType.values) {
if (value.name == name) {
return value;
}
}
return CommitmentType.TASK;
}

View File

@@ -0,0 +1,7 @@
class DialogData {
String title;
String description;
DialogData(this.title, this.description);
}

View File

@@ -0,0 +1,10 @@
enum EnergyCost { Low, Medium, High }
EnergyCost getEnergyCost(String? name) {
for (EnergyCost value in EnergyCost.values) {
if (value.name == name) {
return value;
}
}
return EnergyCost.Medium;
}

View File

@@ -0,0 +1,11 @@
/// Notification escalation ladder. Disappointment outperforms anger.
enum EscalationTier { Reminder, Nudge, Nag, Disappointed, Cold }
EscalationTier getEscalationTier(String? name) {
for (EscalationTier value in EscalationTier.values) {
if (value.name == name) {
return value;
}
}
return EscalationTier.Reminder;
}

View File

@@ -0,0 +1,20 @@
/// Append-only commitment event log. The status field is never the source of
/// truth — this log is.
enum EventType {
CREATED,
COMPLETED,
LATE,
DEFERRED,
MISSED,
ABANDONED,
AMNESTY,
}
EventType getEventType(String? name) {
for (EventType value in EventType.values) {
if (value.name == name) {
return value;
}
}
return EventType.CREATED;
}

View File

@@ -0,0 +1,27 @@
/// The device + session identity carried on every request. `name` and `id` are
/// stored pre-encrypted and become the `what` / `whom` headers.
class MeDescription {
String id;
String name;
String token;
MeDescription({required this.id, required this.name, required this.token});
factory MeDescription.fromJson(Map<String, dynamic> json) {
return MeDescription(
id: json['id'] ?? "",
name: json['name'] ?? "",
token: json['token'] ?? "",
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['token'] = token;
return data;
}
}

View File

@@ -0,0 +1 @@
enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain }

View File

@@ -0,0 +1 @@
enum NotificationType { info, success, error, warning }

View File

@@ -0,0 +1,7 @@
class Pair {
String key;
dynamic value;
Pair(this.key, this.value);
}

View File

@@ -0,0 +1,42 @@
/// Progression rules that actually apply to bodyweight training, where adding
/// plates is not the lever.
enum ProgressionRule {
/// 3x8 -> 3x12, then a harder variation.
Reps,
/// Incline push-up -> flat -> decline -> archer -> one-arm.
Leverage,
/// Slower tempo, longer holds, same reps.
TimeUnderTension,
/// Same work, less rest.
Density,
/// Added external load.
Load,
}
String progressionLabel(ProgressionRule value) {
switch (value) {
case ProgressionRule.Reps:
return "Rep progression";
case ProgressionRule.Leverage:
return "Leverage progression";
case ProgressionRule.TimeUnderTension:
return "Time under tension";
case ProgressionRule.Density:
return "Density";
case ProgressionRule.Load:
return "Load";
}
}
ProgressionRule getProgressionRule(String? name) {
for (ProgressionRule value in ProgressionRule.values) {
if (value.name == name) {
return value;
}
}
return ProgressionRule.Reps;
}

View File

@@ -0,0 +1,46 @@
/// The anti-cheat layer. Chosen per commitment at creation time.
enum ProofType {
/// Plain checkbox — for trivia only.
Honour,
/// Camera-only, no gallery import, timestamp + optional GPS embedded.
Photo,
/// Foreground session of >= X minutes; backgrounding pauses it.
Timer,
/// Geofence dwell via passive location.
Location,
/// Health platform confirms a workout occurred in the window.
Health,
/// An accountability partner taps to confirm.
Witness,
}
String proofLabel(ProofType value) {
switch (value) {
case ProofType.Honour:
return "Honour";
case ProofType.Photo:
return "Photo";
case ProofType.Timer:
return "Timer";
case ProofType.Location:
return "Location";
case ProofType.Health:
return "Health";
case ProofType.Witness:
return "Witness";
}
}
ProofType getProofType(String? name) {
for (ProofType value in ProofType.values) {
if (value.name == name) {
return value;
}
}
return ProofType.Honour;
}

View File

@@ -0,0 +1,35 @@
/// The current disciplinary state. Derived from debt, drives what the app
/// permits: Good -> Warned -> Grounded -> Lockdown.
enum Standing { Good, Warned, Grounded, Lockdown }
String standingLabel(Standing value) {
switch (value) {
case Standing.Good:
return "Good standing";
case Standing.Warned:
return "Warned";
case Standing.Grounded:
return "Grounded";
case Standing.Lockdown:
return "Lockdown";
}
}
/// Whether the user is allowed to create new commitments in this standing.
bool canAddCommitments(Standing value) {
return value == Standing.Good || value == Standing.Warned;
}
/// Whether elective commitments are permitted in this standing.
bool canAddElectives(Standing value) {
return value == Standing.Good;
}
Standing getStanding(String? name) {
for (Standing value in Standing.values) {
if (value.name == name) {
return value;
}
}
return Standing.Good;
}

View File

@@ -0,0 +1,6 @@
enum TextType {
Bold,
Light,
Regular,
Medium,
}

View File

@@ -0,0 +1,34 @@
class Token {
String accessToken;
String refreshToken;
String tokenType;
int expiresIn;
String scope;
Token(this.accessToken, this.refreshToken, this.tokenType, this.expiresIn,
this.scope);
factory Token.fromJsonMap(Map<String, dynamic> json) {
return Token(
json['access_token'] ?? "",
json['refresh_token'] ?? "",
json['token_type'] ?? "",
json['expires_in'] ?? 0,
json['scope'] ?? "",
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['access_token'] = accessToken;
data['refresh_token'] = refreshToken;
data['token_type'] = tokenType;
data['expires_in'] = expiresIn;
data['scope'] = scope;
return data;
}
}

View File

@@ -0,0 +1,23 @@
/// The tone slider. Hard-capped: no copy ever attacks the user's worth, only
/// their behaviour.
enum ToneLevel { Firm, Strict, DrillSergeant }
String toneLabel(ToneLevel value) {
switch (value) {
case ToneLevel.Firm:
return "Firm";
case ToneLevel.Strict:
return "Strict";
case ToneLevel.DrillSergeant:
return "Drill Sergeant";
}
}
ToneLevel getToneLevel(String? name) {
for (ToneLevel value in ToneLevel.values) {
if (value.name == name) {
return value;
}
}
return ToneLevel.Strict;
}

View File

@@ -0,0 +1,57 @@
import 'Standing.dart';
import 'ToneLevel.dart';
class UserDetails {
String pic;
String name;
String timezone;
ToneLevel tone;
Standing standing;
double debtScore;
int amnestyTokens;
bool sickMode;
UserDetails({
required this.pic,
required this.name,
this.timezone = "Africa/Nairobi",
this.tone = ToneLevel.Strict,
this.standing = Standing.Good,
this.debtScore = 0,
this.amnestyTokens = 0,
this.sickMode = false,
});
factory UserDetails.fromJson(Map<String, dynamic> json) {
return UserDetails(
pic: json['pic'] ?? "",
name: json['name'] ?? "",
timezone: json['timezone'] ?? "Africa/Nairobi",
tone: getToneLevel(json['tone']),
standing: getStanding(json['standing']),
debtScore: (json['debtScore'] ?? 0).toDouble(),
amnestyTokens: json['amnestyTokens'] ?? 0,
sickMode: json['sickMode'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['pic'] = pic;
data['name'] = name;
data['timezone'] = timezone;
data['tone'] = tone.name;
data['standing'] = standing.name;
data['debtScore'] = debtScore;
data['amnestyTokens'] = amnestyTokens;
data['sickMode'] = sickMode;
return data;
}
}

View File

@@ -0,0 +1,13 @@
import 'dart:typed_data';
abstract class ConnectFileStorage {
/// Persists proof bytes locally and returns the reference the completion
/// request carries.
Future<String> saveProof(String name, Uint8List bytes);
Future<Uint8List?> readProof(String reference);
Future<bool> deleteProof(String reference);
Future<String> proofDirectory();
}

View File

@@ -0,0 +1,53 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'ConnectFileStorage.dart';
class FileStorage implements ConnectFileStorage {
static const String proofFolder = "proof";
@override
Future<String> proofDirectory() async {
final Directory base = await getApplicationDocumentsDirectory();
final Directory folder = Directory("${base.path}/$proofFolder");
if (!await folder.exists()) {
await folder.create(recursive: true);
}
return folder.path;
}
@override
Future<String> saveProof(String name, Uint8List bytes) async {
final String folder = await proofDirectory();
final File file = File("$folder/$name");
await file.writeAsBytes(bytes);
return file.path;
}
@override
Future<Uint8List?> readProof(String reference) async {
final File file = File(reference);
if (!await file.exists()) {
return null;
}
return await file.readAsBytes();
}
@override
Future<bool> deleteProof(String reference) async {
final File file = File(reference);
if (!await file.exists()) {
return false;
}
await file.delete();
return true;
}
}

View File

@@ -0,0 +1,508 @@
import 'package:dio/dio.dart';
import '../about/external/data/pages/request/CommitmentsRequest.dart';
import '../about/external/data/pages/request/HistoryRequest.dart';
import '../about/external/initial/AbandonRequest.dart';
import '../about/external/initial/AmnestyRequest.dart';
import '../about/external/initial/CommitmentRequest.dart';
import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart';
import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/Pair.dart';
import '../configs/Env.dart';
import '../memory/ConnectInternalMemory.dart';
import 'CommsDirections.dart';
import 'ConnectComms.dart';
class Comms implements ConnectComms {
Dio dio = Dio();
ConnectInternalMemory helper;
Comms(this.helper);
/// Builds the identity headers and resolves the URL. Headers are reset on
/// every call so a stale Authorization can never leak into an auth-flow
/// request.
Future<Pair> getRequestHeaders(String url, String urlData) async {
dio.options.headers = <String, dynamic>{};
MeDescription data = await helper.getMyDescription();
if (data.token.isNotEmpty && url != deviceToken) {
dio.options.headers["Authorization"] = "Bearer ${data.token}";
}
if (url == logoutRequest) {
dio.options.headers.remove("Authorization");
}
dio.options.contentType = Headers.jsonContentType;
dio.options.responseType = ResponseType.json;
// The backend identity contract: what = encrypted access code,
// whom = encrypted device id, version = the app build.
dio.options.headers["what"] = data.name;
dio.options.headers["whom"] = data.id;
dio.options.headers["version"] = localisedAppVersion;
return Pair("${_routeFor(url)}$url$urlData", dio.options.headers);
}
/// Auth-flow paths go to Prospect, training paths to Training, everything
/// else to the Discipline service.
String _routeFor(String url) {
if (url == deviceReg ||
url == deviceToken ||
url == loginUser ||
url == logoutRequest ||
url == aboutMe ||
url == accountRecovery ||
url.startsWith('InAugurate/')) {
return prospectRoute;
}
if (url.startsWith('Program/') ||
url.startsWith('Session/') ||
url.startsWith('Metrics/')) {
return trainingRoute;
}
return disciplineRoute;
}
// ── Auth ──────────────────────────────────────────────────────────────────
@override
Future<Response> registerDevice(DeviceRequest request) async {
Pair navigation = await getRequestHeaders(deviceReg, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> registerDeviceToken(String token) async {
Pair navigation = await getRequestHeaders(deviceToken, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: <String, dynamic>{"token": token});
}
@override
Future<Response> login(LoginData request) async {
Pair navigation = await getRequestHeaders(loginUser, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logout() async {
Pair navigation = await getRequestHeaders(logoutRequest, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> me() async {
Pair navigation = await getRequestHeaders(aboutMe, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
// ── Goals ─────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGoals(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myGoals, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveGoalEntry(GoalRequest request) async {
Pair navigation = await getRequestHeaders(saveGoal, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getGoalTasks(IdRequest request) async {
Pair navigation = await getRequestHeaders(goalTasks, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> archiveGoalEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(archiveGoal, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Commitments ───────────────────────────────────────────────────────────
@override
Future<Response> getTodayPlan(CommitmentsRequest request) async {
Pair navigation = await getRequestHeaders(todayPlan, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getMyCommitments(CommitmentsRequest request) async {
Pair navigation = await getRequestHeaders(myCommitments, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getOverdueQueue(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(overdueQueue, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveCommitmentEntry(CommitmentRequest request) async {
Pair navigation = await getRequestHeaders(saveCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> updateCommitmentEntry(CommitmentRequest request) async {
Pair navigation = await getRequestHeaders(updateCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> deleteCommitmentEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(deleteCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> completeCommitmentEntry(CompletionRequest request) async {
Pair navigation = await getRequestHeaders(completeCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> deferCommitmentEntry(DeferralRequest request) async {
Pair navigation = await getRequestHeaders(deferCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> abandonCommitmentEntry(AbandonRequest request) async {
Pair navigation = await getRequestHeaders(abandonCommitment, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getCommitmentHistory(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(commitmentHistory, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getCommitmentEvents(IdRequest request) async {
Pair navigation = await getRequestHeaders(commitmentEvents, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Capacity ──────────────────────────────────────────────────────────────
@override
Future<Response> checkCapacity(CommitmentsRequest request) async {
Pair navigation = await getRequestHeaders(capacityCheck, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getCapacityProfileEntry() async {
Pair navigation = await getRequestHeaders(capacityProfilePath, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
// ── Debt & standing ───────────────────────────────────────────────────────
@override
Future<Response> getDebtSummary() async {
Pair navigation = await getRequestHeaders(debtSummary, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> getDebtLedger(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(debtLedger, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getDebtTrend(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(debtTrendPath, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getStanding() async {
Pair navigation = await getRequestHeaders(standingPath, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> getStandingHistory(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(standingHistory, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Excuses ───────────────────────────────────────────────────────────────
@override
Future<Response> getExcuseClusters(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(excuseClusters, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Proof ─────────────────────────────────────────────────────────────────
@override
Future<Response> uploadPhotoProof(FormData request) async {
Pair navigation = await getRequestHeaders(uploadProofPhoto, "");
dio.options.headers = navigation.value;
dio.options.contentType = Headers.multipartFormDataContentType;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> submitTimerProofEntry(CompletionRequest request) async {
Pair navigation = await getRequestHeaders(submitTimerProof, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> submitLocationProofEntry(CompletionRequest request) async {
Pair navigation = await getRequestHeaders(submitLocationProof, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Guardrails ────────────────────────────────────────────────────────────
@override
Future<Response> spendAmnestyToken(AmnestyRequest request) async {
Pair navigation = await getRequestHeaders(spendAmnesty, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getAmnestyBalance() async {
Pair navigation = await getRequestHeaders(amnestyBalance, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> updateSickMode(SickModeRequest request) async {
Pair navigation = await getRequestHeaders(setSickMode, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> checkDistress() async {
Pair navigation = await getRequestHeaders(distressCheck, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> updateTone(ToneRequest request) async {
Pair navigation = await getRequestHeaders(setTone, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Habits & routines ─────────────────────────────────────────────────────
@override
Future<Response> getMyHabits(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myHabits, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveHabitEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(saveHabit, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logHabitEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(logHabit, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getKeystoneHabits() async {
Pair navigation = await getRequestHeaders(keystoneHabits, "");
dio.options.headers = navigation.value;
return await dio.get(navigation.key);
}
@override
Future<Response> getMyRoutines(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myRoutines, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(logRoutineChain, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Training ──────────────────────────────────────────────────────────────
@override
Future<Response> getMyPrograms(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myPrograms, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveProgramEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(saveProgram, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> activateProgramEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(activateProgram, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getProgramSessions(IdRequest request) async {
Pair navigation = await getRequestHeaders(programSessions, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> startSessionEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(startSession, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveSessionLogEntry(SessionLogRequest request) async {
Pair navigation = await getRequestHeaders(saveSessionLog, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getSessionHistory(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(sessionHistory, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getLastSessionFor(IdRequest request) async {
Pair navigation = await getRequestHeaders(lastSessionFor, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getWeeklyVolume(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(weeklyVolume, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getContactVolume(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(contactVolume, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getPersonalRecords(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(personalRecords, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Report card ───────────────────────────────────────────────────────────
@override
Future<Response> getWeeklyReportCard(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(weeklyReportCard, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getMonthlyReportCard(ReportCardRequest request) async {
Pair navigation = await getRequestHeaders(monthlyReportCard, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
// ── Notifications ─────────────────────────────────────────────────────────
@override
Future<Response> getMyNotifications(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myNotifications, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> acknowledgeNudgeEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(acknowledgeNudge, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
}

View File

@@ -0,0 +1,174 @@
import '../configs/Env.dart';
/// The single switch between local and production.
bool isProd = false;
// ── Service routes ────────────────────────────────────────────────────────────
String prospectRoute = isProd
? "$groundedRouteProd/Prospect/"
: "$groundedRouteLocal:40003/Prospect/";
String disciplineRoute = isProd
? "$groundedRouteProd/Discipline/"
: "$groundedRouteLocal:40005/Discipline/";
String trainingRoute = isProd
? "$groundedRouteProd/Training/"
: "$groundedRouteLocal:40004/Training/";
// ── Auth flow (Prospect) ──────────────────────────────────────────────────────
String deviceReg = "Device/NewDevice";
String deviceToken = 'Device/Note';
String loginUser = 'User/Login';
String logoutRequest = 'User/Logout';
String aboutMe = 'User/Me';
String accountRecovery = "User/RecoverRequest";
String inaugurateProspect = 'InAugurate/Prospect';
String inaugurateTerms = 'InAugurate/Terms';
String registerCredentialsLocation = 'InAugurate/UserAndPass';
// ── Goals ─────────────────────────────────────────────────────────────────────
String myGoals = 'Goal/Mine';
String saveGoal = 'Goal/Save';
String goalTasks = 'Goal/Tasks';
String archiveGoal = 'Goal/Archive';
// ── Commitments ───────────────────────────────────────────────────────────────
String myCommitments = 'Commitment/Mine';
String todayPlan = 'Commitment/Today';
String overdueQueue = 'Commitment/Overdue';
String saveCommitment = 'Commitment/Save';
String updateCommitment = 'Commitment/Update';
String deleteCommitment = 'Commitment/Delete';
String completeCommitment = 'Commitment/Complete';
String deferCommitment = 'Commitment/Defer';
String abandonCommitment = 'Commitment/Abandon';
String commitmentHistory = 'Commitment/History';
String commitmentEvents = 'Commitment/Events';
// ── Capacity ──────────────────────────────────────────────────────────────────
String capacityCheck = 'Capacity/Check';
String capacityProfilePath = 'Capacity/Profile';
// ── Debt & standing ───────────────────────────────────────────────────────────
String debtSummary = 'Debt/Summary';
String debtLedger = 'Debt/Ledger';
String debtTrendPath = 'Debt/Trend';
String standingPath = 'Standing/Current';
String standingHistory = 'Standing/History';
// ── Excuses ───────────────────────────────────────────────────────────────────
String excuseClusters = 'Excuse/Clusters';
String excuseInsights = 'Excuse/Insights';
// ── Proof ─────────────────────────────────────────────────────────────────────
String uploadProofPhoto = 'Proof/PhotoUpload';
String submitTimerProof = 'Proof/Timer';
String submitLocationProof = 'Proof/Location';
String requestWitnessProof = 'Proof/Witness';
// ── Guardrails ────────────────────────────────────────────────────────────────
String spendAmnesty = 'Guardrail/Amnesty';
String amnestyBalance = 'Guardrail/AmnestyBalance';
String setSickMode = 'Guardrail/SickMode';
String distressCheck = 'Guardrail/Distress';
String setTone = 'Guardrail/Tone';
// ── Habits & routines ─────────────────────────────────────────────────────────
String myHabits = 'Habit/Mine';
String saveHabit = 'Habit/Save';
String logHabit = 'Habit/Log';
String keystoneHabits = 'Habit/Keystone';
String myRoutines = 'Routine/Mine';
String saveRoutine = 'Routine/Save';
String logRoutineChain = 'Routine/Log';
// ── Training ──────────────────────────────────────────────────────────────────
String myPrograms = 'Program/Mine';
String saveProgram = 'Program/Save';
String activateProgram = 'Program/Activate';
String programSessions = 'Program/Sessions';
String saveSessionTemplate = 'Program/SaveTemplate';
String startSession = 'Session/Start';
String saveSessionLog = 'Session/Save';
String sessionHistory = 'Session/History';
String lastSessionFor = 'Session/Last';
String weeklyVolume = 'Session/WeeklyVolume';
String contactVolume = 'Session/Contacts';
String personalRecords = 'Session/Records';
String bodyMetrics = 'Metrics/Body';
String saveBodyMetric = 'Metrics/SaveBody';
// ── Report card ───────────────────────────────────────────────────────────────
String weeklyReportCard = 'Report/Weekly';
String monthlyReportCard = 'Report/Monthly';
String reportHistory = 'Report/History';
// ── Notifications ─────────────────────────────────────────────────────────────
String myNotifications = 'Notifications/Mine';
String readNotification = 'Notifications/Read';
String acknowledgeNudge = 'Notifications/Acknowledge';
// ── Partners ──────────────────────────────────────────────────────────────────
String myPartners = 'Partner/Mine';
String invitePartner = 'Partner/Invite';
String partnerPolicy = 'Partner/Policy';

View File

@@ -0,0 +1,145 @@
import 'package:dio/dio.dart';
import '../about/external/data/pages/request/CommitmentsRequest.dart';
import '../about/external/data/pages/request/HistoryRequest.dart';
import '../about/external/initial/AbandonRequest.dart';
import '../about/external/initial/AmnestyRequest.dart';
import '../about/external/initial/CommitmentRequest.dart';
import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart';
abstract class ConnectComms {
// ── Auth ────────────────────────────────────────────────────────────────
Future<Response> registerDevice(DeviceRequest request);
Future<Response> registerDeviceToken(String token);
Future<Response> login(LoginData request);
Future<Response> logout();
Future<Response> me();
// ── Goals ───────────────────────────────────────────────────────────────
Future<Response> getMyGoals(HistoryRequest request);
Future<Response> saveGoalEntry(GoalRequest request);
Future<Response> getGoalTasks(IdRequest request);
Future<Response> archiveGoalEntry(IdRequest request);
// ── Commitments ─────────────────────────────────────────────────────────
Future<Response> getTodayPlan(CommitmentsRequest request);
Future<Response> getMyCommitments(CommitmentsRequest request);
Future<Response> getOverdueQueue(HistoryRequest request);
Future<Response> saveCommitmentEntry(CommitmentRequest request);
Future<Response> updateCommitmentEntry(CommitmentRequest request);
Future<Response> deleteCommitmentEntry(IdRequest request);
Future<Response> completeCommitmentEntry(CompletionRequest request);
Future<Response> deferCommitmentEntry(DeferralRequest request);
Future<Response> abandonCommitmentEntry(AbandonRequest request);
Future<Response> getCommitmentHistory(HistoryRequest request);
Future<Response> getCommitmentEvents(IdRequest request);
// ── Capacity ────────────────────────────────────────────────────────────
Future<Response> checkCapacity(CommitmentsRequest request);
Future<Response> getCapacityProfileEntry();
// ── Debt & standing ─────────────────────────────────────────────────────
Future<Response> getDebtSummary();
Future<Response> getDebtLedger(HistoryRequest request);
Future<Response> getDebtTrend(ReportCardRequest request);
Future<Response> getStanding();
Future<Response> getStandingHistory(HistoryRequest request);
// ── Excuses ─────────────────────────────────────────────────────────────
Future<Response> getExcuseClusters(ReportCardRequest request);
// ── Proof ───────────────────────────────────────────────────────────────
Future<Response> uploadPhotoProof(FormData request);
Future<Response> submitTimerProofEntry(CompletionRequest request);
Future<Response> submitLocationProofEntry(CompletionRequest request);
// ── Guardrails ──────────────────────────────────────────────────────────
Future<Response> spendAmnestyToken(AmnestyRequest request);
Future<Response> getAmnestyBalance();
Future<Response> updateSickMode(SickModeRequest request);
Future<Response> checkDistress();
Future<Response> updateTone(ToneRequest request);
// ── Habits & routines ───────────────────────────────────────────────────
Future<Response> getMyHabits(HistoryRequest request);
Future<Response> saveHabitEntry(Map<String, dynamic> request);
Future<Response> logHabitEntry(IdRequest request);
Future<Response> getKeystoneHabits();
Future<Response> getMyRoutines(HistoryRequest request);
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
// ── Training ────────────────────────────────────────────────────────────
Future<Response> getMyPrograms(HistoryRequest request);
Future<Response> saveProgramEntry(Map<String, dynamic> request);
Future<Response> activateProgramEntry(IdRequest request);
Future<Response> getProgramSessions(IdRequest request);
Future<Response> startSessionEntry(IdRequest request);
Future<Response> saveSessionLogEntry(SessionLogRequest request);
Future<Response> getSessionHistory(HistoryRequest request);
Future<Response> getLastSessionFor(IdRequest request);
Future<Response> getWeeklyVolume(ReportCardRequest request);
Future<Response> getContactVolume(ReportCardRequest request);
Future<Response> getPersonalRecords(HistoryRequest request);
// ── Report card ─────────────────────────────────────────────────────────
Future<Response> getWeeklyReportCard(ReportCardRequest request);
Future<Response> getMonthlyReportCard(ReportCardRequest request);
// ── Notifications ───────────────────────────────────────────────────────
Future<Response> getMyNotifications(HistoryRequest request);
Future<Response> acknowledgeNudgeEntry(IdRequest request);
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
String groundedRouteProd = dotenv.get('GROUNDED_PRODUCTION_PATH', fallback: '');
String groundedRouteLocal = dotenv.get('GROUNDED_LOCAL_PATH', fallback: '');
String localisedAppVersion = dotenv.get('LOCALISED_APP_VERSION', fallback: '');

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:page_transition/page_transition.dart';
import '../about/internal/application/NavigatorType.dart';
/// Every transition in the app funnels through here. Destinations are widget
/// instances, not named routes, and the single choke point is what lets a
/// device-integrity check gate all routing.
class GroundedNavigation {
void navigateToPage(NavigatorType type, dynamic path, BuildContext context) {
_runSec(context, type, path);
}
Future _runSec(BuildContext context, NavigatorType type, dynamic path) async {
switch (type) {
case NavigatorType.openFully:
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (BuildContext context) => path));
break;
case NavigatorType.justOpen:
Navigator.push(
context,
PageTransition(
type: PageTransitionType.size,
alignment: Alignment.center,
child: path));
break;
case NavigatorType.replaceCurrent:
Navigator.pushReplacement(
context,
PageTransition(
type: PageTransitionType.scale,
alignment: Alignment.center,
curve: Curves.ease,
duration: const Duration(microseconds: 9000),
child: path));
break;
case NavigatorType.makeNewMain:
Navigator.pushAndRemoveUntil(
context,
PageTransition(
type: PageTransitionType.fade,
alignment: Alignment.center,
child: path),
ModalRoute.withName('/'));
break;
}
}
/// The reload-on-return channel: the child pops with a result and the parent
/// refreshes on it.
Future<dynamic> navigateToPageWithData(
dynamic path, BuildContext context) async {
return await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => path,
),
);
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../about/external/data/Commitment.dart';
import '../about/external/data/LiveSession.dart';
import '../about/internal/application/CommitmentClass.dart';
import '../about/internal/application/EscalationTier.dart';
import '../about/internal/application/ToneLevel.dart';
import '../utils/CommonUtils.dart';
import '../utils/ToneEngine.dart';
/// Notification channels, the ongoing "task running" notification, and the
/// full-screen alarm used for non-negotiables.
class LocalNotificationEngine {
static final FlutterLocalNotificationsPlugin plugin =
FlutterLocalNotificationsPlugin();
/// Ordinary reminders — respects quiet hours.
static const String reminderChannel = "grounded_reminders";
/// The ongoing notification attached to a running task. Not dismissable, so
/// a task in progress is always one tap away from the lock screen.
static const String sessionChannel = "grounded_session";
/// Alarm-class, full-screen. Reserved for non-negotiables, and the only
/// channel that overrides quiet hours.
static const String alarmChannel = "grounded_alarm";
static const int sessionNotificationId = 9000;
static bool _ready = false;
static Future<void> init() async {
if (_ready) {
return;
}
const AndroidInitializationSettings android =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings apple = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
// Critical alerts need Apple entitlement approval; requesting without it
// is simply ignored rather than failing.
requestCriticalPermission: true,
);
await plugin.initialize(
settings: const InitializationSettings(
android: android, iOS: apple, macOS: apple),
);
if (Platform.isAndroid) {
await _createAndroidChannels();
}
_ready = true;
}
static Future<void> _createAndroidChannels() async {
final AndroidFlutterLocalNotificationsPlugin? android =
plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android == null) {
return;
}
await android.createNotificationChannel(const AndroidNotificationChannel(
reminderChannel,
'Reminders',
description: 'Nudges about commitments that are due.',
importance: Importance.defaultImportance,
));
await android.createNotificationChannel(const AndroidNotificationChannel(
sessionChannel,
'Task in progress',
description: 'The ongoing notification for a task you are running.',
importance: Importance.low,
playSound: false,
enableVibration: false,
));
await android.createNotificationChannel(const AndroidNotificationChannel(
alarmChannel,
'Non-negotiables',
description:
'Full-screen alarms for non-negotiable commitments. These ignore quiet hours.',
importance: Importance.max,
playSound: true,
enableVibration: true,
));
await android.requestNotificationsPermission();
// Exact alarms are what let a window actually close on time rather than
// whenever the OS feels like it.
await android.requestExactAlarmsPermission();
}
/// The ongoing notification for a running task. Shows the live remaining
/// time so it is useful from the lock screen without unlocking.
static Future<void> showSessionNotification(LiveSession session) async {
await init();
final String remaining = session.requiredSeconds > 0
? "${formatClock(session.remainingSeconds())} remaining"
: "${formatClock(session.elapsedSeconds())} elapsed";
final AndroidNotificationDetails android = AndroidNotificationDetails(
sessionChannel,
'Task in progress',
channelDescription: 'The ongoing notification for a running task.',
importance: Importance.low,
priority: Priority.low,
ongoing: true,
autoCancel: false,
onlyAlertOnce: true,
showWhen: true,
usesChronometer: session.requiredSeconds <= 0,
category: AndroidNotificationCategory.workout,
actions: const <AndroidNotificationAction>[
AndroidNotificationAction('pause', 'Pause'),
AndroidNotificationAction('finish', 'Finish'),
],
);
await plugin.show(
id: sessionNotificationId,
title: session.title,
body: session.goalTitle.isEmpty
? remaining
: "${session.goalTitle} · $remaining",
notificationDetails: NotificationDetails(
android: android,
iOS: const DarwinNotificationDetails(presentBanner: false),
),
payload: session.commitmentId,
);
}
static Future<void> cancelSessionNotification() async {
await init();
await plugin.cancel(id: sessionNotificationId);
}
/// A due-window nudge. Non-negotiables go out full-screen and alarm-class so
/// they surface over the lock screen; everything else is an ordinary
/// notification.
static Future<void> showCommitmentNudge(
Commitment commitment,
EscalationTier tier,
ToneLevel tone,
) async {
await init();
final bool nonNegotiable =
commitment.commitmentClass == CommitmentClass.NonNegotiable;
final AndroidNotificationDetails android = AndroidNotificationDetails(
nonNegotiable ? alarmChannel : reminderChannel,
nonNegotiable ? 'Non-negotiables' : 'Reminders',
importance: nonNegotiable ? Importance.max : Importance.defaultImportance,
priority: nonNegotiable ? Priority.max : Priority.defaultPriority,
// The full-screen intent is what turns this into a lock-screen takeover
// rather than a banner that can be swiped past.
fullScreenIntent: nonNegotiable,
category: nonNegotiable
? AndroidNotificationCategory.alarm
: AndroidNotificationCategory.reminder,
actions: <AndroidNotificationAction>[
const AndroidNotificationAction('start', 'Start now'),
if (!nonNegotiable)
const AndroidNotificationAction('snooze', 'Snooze'),
],
);
await plugin.show(
id: commitment.id.hashCode,
title: commitment.title,
body: ToneEngine.nudge(tier, tone, commitment.title),
notificationDetails: NotificationDetails(
android: android,
iOS: DarwinNotificationDetails(
// Critical alerts bypass Do Not Disturb, and are the iOS equivalent
// of the Android full-screen intent. Requires Apple approval.
interruptionLevel: nonNegotiable
? InterruptionLevel.critical
: InterruptionLevel.active,
),
),
payload: commitment.id,
);
}
static Future<void> cancelAll() async {
await init();
await plugin.cancelAll();
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// The font families, wrapped so no screen ever names a family directly.
/// General Sans — see fonts/LICENSE-GeneralSans.txt.
String getTextType(TextType type) {
switch (type) {
case TextType.Bold:
return "GroundedBold";
case TextType.Light:
return "GroundedLight";
case TextType.Regular:
return "GroundedRegular";
case TextType.Medium:
return "GroundedMedium";
}
}
/// A labelled pill — the standing chip, the class chip, the proof chip. One
/// implementation so they stay visually identical everywhere.
Widget pill(
String label,
Color foreground,
Color background, {
IconData? icon,
double textSize = 10,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: textSize + 2, color: foreground),
const SizedBox(width: 5),
],
text(
label.toUpperCase(),
textSize,
TextType.Bold,
color: foreground,
letterSpacing: 0.7,
),
],
),
);
}
/// The standard card surface.
Widget card({
required Widget child,
EdgeInsets padding = const EdgeInsets.all(16),
EdgeInsets margin = EdgeInsets.zero,
Color? background,
Color? borderColor,
double radius = 16,
VoidCallback? onTap,
}) {
final Widget body = Container(
width: double.infinity,
padding: padding,
margin: margin,
decoration: BoxDecoration(
color: background ?? colorCard,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? colorBorder, width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x08000000),
blurRadius: 18,
offset: Offset(0, 4),
),
],
),
child: child,
);
if (onTap == null) {
return body;
}
return GestureDetector(onTap: onTap, child: body);
}
/// Section heading — small all-caps label over a large light title, the
/// house style used on every screen header.
Widget sectionHeader(String label, String title, {Color? titleColor}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label.toUpperCase(), 11, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 8),
text(title, 32, TextType.Light, color: titleColor ?? colorPrimaryDark),
],
);
}
/// A labelled statistic, used across the report card and the debt header.
Widget statTile(
String label,
String value, {
Color? valueColor,
String? caption,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label.toUpperCase(), 10, TextType.Bold,
color: colorGrey2, letterSpacing: 0.8),
const SizedBox(height: 6),
text(value, 26, TextType.Bold, color: valueColor ?? colorPrimaryDark),
if (caption != null) ...[
const SizedBox(height: 2),
text(caption, 11, TextType.Regular, color: colorGrey2),
],
],
);
}
/// A hairline divider at the house opacity.
Widget hairline({EdgeInsets margin = EdgeInsets.zero}) {
return Container(
height: 1,
margin: margin,
color: colorDivider,
);
}
/// Empty state. Deliberately plain — an empty queue is good news and should
/// not be celebrated with confetti.
Widget emptyState(
IconData icon,
String title,
String description, {
Color? accent,
}) {
final Color tone = accent ?? colorGrey2;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(icon, size: 32, color: tone),
),
const SizedBox(height: 20),
text(title, 18, TextType.Bold,
color: colorPrimaryDark, align: TextAlign.center),
const SizedBox(height: 8),
text(description, 13, TextType.Regular,
color: colorGrey2, align: TextAlign.center, height: 1.5),
],
),
),
);
}
/// A progress bar with the house geometry.
Widget meter(
double fraction, {
Color? fill,
Color? track,
double height = 8,
}) {
final double clamped = fraction.isNaN
? 0
: fraction < 0
? 0
: fraction > 1
? 1
: fraction;
return ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: clamped,
minHeight: height,
backgroundColor: track ?? colorMuted,
valueColor: AlwaysStoppedAnimation<Color>(fill ?? colorPrimary),
),
);
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import 'text/Text.dart';
class Responsive extends StatelessWidget {
final Widget mobile;
final Widget tablet;
final Widget desktop;
const Responsive({
super.key,
required this.desktop,
required this.mobile,
required this.tablet,
});
/// mobile < 650
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < 650;
/// tablet >= 650
static bool isTablet(BuildContext context) =>
MediaQuery.sizeOf(context).width >= 650;
/// desktop >= 1100
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= 1100;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (isDesktop(context)) {
return desktop;
} else if (isTablet(context)) {
return tablet;
} else if (isMobile(context)) {
return mobile;
} else {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [text("NOT SUPPORTED", 12, TextType.Bold)],
),
);
}
});
}
}

View File

@@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'Component.dart';
import 'text/Text.dart';
/// The house layout: black chrome at the top, a white sheet rising into it with
/// a large corner radius. Every screen is built from this so the app reads as
/// one object rather than a stack of pages.
class Sheet extends StatelessWidget {
/// Small all-caps label rendered in the black chrome, above the title.
final String eyebrow;
/// The chrome title — small and centred, not the display title.
final String title;
final Widget child;
final VoidCallback? onBack;
/// Optional trailing control in the chrome.
final Widget? action;
/// Chrome colour. Defaults to near-black; standing screens tint it.
final Color? chrome;
/// Rendered inside the chrome beneath the title — the standing strip.
final Widget? banner;
final bool scrollable;
const Sheet({
super.key,
required this.title,
required this.child,
this.eyebrow = "",
this.onBack,
this.action,
this.chrome,
this.banner,
this.scrollable = true,
});
@override
Widget build(BuildContext context) {
final Color chromeColor = chrome ?? colorPrimaryDark;
return Scaffold(
backgroundColor: chromeColor,
body: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
width: 44,
child: onBack == null
? null
: _chromeButton(
Icons.arrow_back_ios_new_rounded, onBack!),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow.isNotEmpty) ...[
text(
eyebrow.toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2,
align: TextAlign.center,
),
const SizedBox(height: 3),
],
text(
title,
15,
TextType.Medium,
color: colorWhite,
align: TextAlign.center,
),
],
),
),
SizedBox(
width: 44,
child: action == null
? null
: Align(
alignment: Alignment.centerRight,
child: action,
),
),
],
),
if (banner != null) ...[
const SizedBox(height: 16),
banner!,
],
],
),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorPrimaryLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
clipBehavior: Clip.antiAlias,
child: scrollable
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40),
child: child,
)
: Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
child: child,
),
),
),
],
),
);
}
Widget _chromeButton(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 15, color: colorWhite),
),
);
}
}
/// A circular control for the black chrome, with an optional unread dot.
Widget chromeAction(
IconData icon,
VoidCallback onTap, {
bool dotted = false,
Color? dotColor,
}) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 17, color: colorWhite),
),
if (dotted)
Positioned(
top: 1,
right: 1,
child: Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: dotColor ?? colorAccent,
shape: BoxShape.circle,
border: Border.all(color: colorPrimaryDark, width: 1.5),
),
),
),
],
),
);
}
/// The label/value pair — a tiny grey all-caps label sitting directly above a
/// value. The core unit of the whole interface.
Widget labelled(
String label,
String value, {
double valueSize = 15,
TextType valueType = TextType.Medium,
Color? valueColor,
Color? labelColor,
CrossAxisAlignment align = CrossAxisAlignment.start,
}) {
return Column(
crossAxisAlignment: align,
mainAxisSize: MainAxisSize.min,
children: [
text(
label.toUpperCase(),
9,
TextType.Bold,
color: labelColor ?? colorGrey2,
letterSpacing: 1.0,
),
const SizedBox(height: 5),
text(value, valueSize, valueType,
color: valueColor ?? colorPrimaryDark),
],
);
}
/// A row of metadata above a display title, as on the reference: small grey
/// pairs separated by generous space.
Widget metaRow(List<Widget> items) {
final List<Widget> spaced = <Widget>[];
for (int index = 0; index < items.length; index++) {
spaced.add(items[index]);
if (index != items.length - 1) {
spaced.add(const SizedBox(width: 28));
}
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: spaced,
);
}
/// The oversized light display title that opens a section.
Widget displayTitle(String value, {Color? color, double size = 34}) {
return text(value, size, TextType.Light,
color: color ?? colorPrimaryDark, height: 1.15);
}
/// A section break: hairline, then a small bold heading with an optional
/// trailing chip.
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
hairline(margin: const EdgeInsets.only(bottom: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
if (caption.isNotEmpty) ...[
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: text(caption, 11, TextType.Regular,
color: colorGrey2),
),
],
],
),
),
if (trailing != null) trailing,
],
),
const SizedBox(height: 16),
],
);
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../Component.dart';
import '../text/Text.dart';
/// The primary action. One per screen — if a screen appears to need two, one
/// of them is secondary.
Widget roundedCornerButton(
String label,
VoidCallback onPressed, {
Color? background,
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double verticalPadding = 16,
}) {
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
final Color fg = foreground ?? colorWhite;
return ElevatedButton(
onPressed: enabled ? onPressed : null,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: bg,
disabledBackgroundColor: colorGrey.withValues(alpha: 0.4),
padding: EdgeInsets.symmetric(vertical: verticalPadding),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
const SizedBox(width: 8),
],
text(label, 14, TextType.Bold, color: fg),
],
),
);
}
/// The secondary action — outlined, never filled, so the hierarchy is never
/// ambiguous.
Widget outlinedActionButton(
String label,
VoidCallback onPressed, {
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
}) {
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;
return OutlinedButton(
onPressed: enabled ? onPressed : null,
style: OutlinedButton.styleFrom(
side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
const SizedBox(width: 8),
],
text(label, 14, TextType.Bold, color: fg),
],
),
);
}
Widget textButton(
String label,
VoidCallback onPressed, {
Color? color,
double textSize = 13,
TextType type = TextType.Regular,
}) {
return TextButton(
onPressed: onPressed,
child: text(label, textSize, type, color: color ?? colorGrey2),
);
}
Widget iconButton(
Widget icon,
VoidCallback onPressed, {
bool bordered = false,
Color? borderColor,
Color? background,
double radius = 10,
double size = 38,
}) {
return GestureDetector(
onTap: onPressed,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background ?? Colors.transparent,
borderRadius: BorderRadius.circular(radius),
border: bordered
? Border.all(color: borderColor ?? colorBorder, width: 1)
: null,
),
child: icon,
),
);
}
/// The destructive action — abandoning, which costs the most debt of all and
/// so is always visually distinct from completing.
Widget destructiveButton(
String label,
VoidCallback onPressed, {
IconData? icon,
bool enabled = true,
}) {
return roundedCornerButton(
label,
onPressed,
background: colorDestructive,
foreground: colorWhite,
icon: icon,
enabled: enabled,
);
}
/// A segmented selector, used for class, energy, proof type and tone.
Widget segmentedSelector<T>({
required List<T> options,
required T selected,
required String Function(T) label,
required void Function(T) onSelected,
Color? activeColor,
}) {
final Color active = activeColor ?? colorPrimaryDark;
return Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((option) {
final bool isSelected = option == selected;
return GestureDetector(
onTap: () => onSelected(option),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? active : colorWhite,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? active : colorBorder,
width: 1,
),
),
child: text(
label(option),
12,
isSelected ? TextType.Bold : TextType.Regular,
color: isSelected ? colorWhite : colorGrey2,
),
),
);
}).toList(),
);
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../Component.dart';
import '../text/Text.dart';
/// The standard text field.
Widget inputField(
String label,
TextEditingController controller, {
String hint = "",
String? Function(String?)? validator,
TextInputType keyboard = TextInputType.text,
bool obscure = false,
int maxLines = 1,
List<TextInputFormatter>? formatters,
IconData? icon,
ValueChanged<String>? onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label.toUpperCase(), 10, TextType.Bold,
color: colorGrey2, letterSpacing: 0.8),
const SizedBox(height: 8),
TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboard,
obscureText: obscure,
maxLines: obscure ? 1 : maxLines,
inputFormatters: formatters,
onChanged: onChanged,
style: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 14,
color: colorPrimaryDark,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 13,
color: colorGrey,
),
prefixIcon: icon == null
? null
: Icon(icon, size: 18, color: colorGrey2),
filled: true,
fillColor: colorWhite,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorBorder, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorNegative, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorNegative, width: 1.4),
),
errorStyle: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 11,
color: colorNegative,
),
),
),
],
);
}
/// The excuse field. Deliberately unadorned — no templates, no quick-picks, a
/// live character count that shows the minimum, because the friction is the
/// feature rather than an obstacle to route around.
Widget excuseField(
TextEditingController controller,
int minimumLength, {
String? Function(String?)? validator,
ValueChanged<String>? onChanged,
}) {
final int length = controller.text.trim().length;
final bool satisfied = length >= minimumLength;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("WHY", 10, TextType.Bold, color: colorGrey2, letterSpacing: 0.8),
text(
satisfied ? "$length characters" : "$length / $minimumLength",
10,
TextType.Bold,
color: satisfied ? colorPositive : colorGrey,
),
],
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
validator: validator,
onChanged: onChanged,
maxLines: 4,
style: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 14,
color: colorPrimaryDark,
height: 1.5,
),
decoration: InputDecoration(
hintText: "In your own words. No shortcuts here.",
hintStyle: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 13,
color: colorGrey,
),
filled: true,
fillColor: colorWhite,
contentPadding: const EdgeInsets.all(14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorBorder, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorNegative, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorNegative, width: 1.4),
),
errorStyle: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 11,
color: colorNegative,
),
),
),
],
);
}
/// A read-only field that loads its options on tap rather than pre-loading
/// them — the ViewModel fetches, then calls back to open the picker.
Widget selectField(
String label,
String value,
VoidCallback onTap, {
String hint = "Select",
IconData icon = Icons.expand_more_rounded,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label.toUpperCase(), 10, TextType.Bold,
color: colorGrey2, letterSpacing: 0.8),
const SizedBox(height: 8),
GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
decoration: BoxDecoration(
color: colorWhite,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorBorder, width: 1),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: text(
value.isEmpty ? hint : value,
14,
value.isEmpty ? TextType.Regular : TextType.Bold,
color: value.isEmpty ? colorGrey : colorPrimaryDark,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(icon, size: 20, color: colorGrey2),
],
),
),
),
],
);
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../Component.dart';
/// The only way display copy is rendered. Raw Text/TextStyle is never used for
/// user-facing copy.
Widget text(
String text,
double textSize,
TextType type, {
Color? color,
TextAlign? align,
int? maxLines,
TextOverflow? overflow,
double? letterSpacing,
double? height,
double? wordSpacing,
TextDecoration? decoration,
FontWeight? weight,
}) {
return Text(
text,
textAlign: align ?? TextAlign.left,
maxLines: maxLines,
overflow: overflow,
style: TextStyle(
decoration: decoration ?? TextDecoration.none,
color: color ?? colorPrimaryDark,
fontSize: textSize,
fontFamily: getTextType(type),
letterSpacing: letterSpacing,
height: height,
wordSpacing: wordSpacing,
fontWeight: weight,
),
);
}

View File

@@ -0,0 +1,362 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../about/external/data/Commitment.dart';
import '../about/external/data/Program.dart';
import '../about/external/data/SystemResponse.dart';
import '../about/external/data/pages/request/CommitmentsRequest.dart';
import '../about/external/data/pages/request/HistoryRequest.dart';
import '../about/external/initial/AbandonRequest.dart';
import '../about/external/initial/AmnestyRequest.dart';
import '../about/external/initial/CommitmentRequest.dart';
import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart';
import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart';
import '../about/internal/file/ConnectFileStorage.dart';
import '../comms/ConnectComms.dart';
import '../memory/ConnectInternalMemory.dart';
import 'DataManager.dart';
/// The single data gateway. Constructed once in [ParentViewModel] and shared by
/// every screen; it only delegates to its three collaborators.
class AppDataManager implements DataManager {
ConnectInternalMemory memory;
ConnectComms comms;
ConnectFileStorage files;
AppDataManager(this.memory, this.comms, this.files);
// ── Memory ────────────────────────────────────────────────────────────────
@override
Future<MeDescription> getMyDescription() => memory.getMyDescription();
@override
Future setMyDescription(MeDescription description) =>
memory.setMyDescription(description);
@override
Future<String> getNotToken() => memory.getNotToken();
@override
Future setNotToken(String token) => memory.setNotToken(token);
@override
Future<SystemResponse> getUserCreationDetails() =>
memory.getUserCreationDetails();
@override
Future setUserCreationDetails(SystemResponse response) =>
memory.setUserCreationDetails(response);
@override
Future<UserDetails> getUserDetails() => memory.getUserDetails();
@override
Future setUserDetails(UserDetails details) => memory.setUserDetails(details);
@override
Future<Token> getTokenEntry() => memory.getTokenEntry();
@override
Future setTokenEntry(Token token) => memory.setTokenEntry(token);
@override
Future<String> getRefreshAt() => memory.getRefreshAt();
@override
Future setRefreshAt(String refresher) => memory.setRefreshAt(refresher);
@override
Future<Commitment> getActiveCommitment() => memory.getActiveCommitment();
@override
Future setActiveCommitment(Commitment commitment) =>
memory.setActiveCommitment(commitment);
@override
Future<Program> getActiveProgram() => memory.getActiveProgram();
@override
Future setActiveProgram(Program program) => memory.setActiveProgram(program);
@override
Future<CapacityProfile> getCapacityProfile() => memory.getCapacityProfile();
@override
Future setCapacityProfile(CapacityProfile profile) =>
memory.setCapacityProfile(profile);
@override
Future<double> getCachedDebtScore() => memory.getCachedDebtScore();
@override
Future setCachedDebtScore(double score) => memory.setCachedDebtScore(score);
@override
Future<int> getEngagementCount() => memory.getEngagementCount();
@override
Future setEngagementCount(int count) => memory.setEngagementCount(count);
@override
Future<int> getAmnestySpent() => memory.getAmnestySpent();
@override
Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent);
@override
Future<bool> showOnboarding() => memory.showOnboarding();
@override
Future setOnboardingOption(bool value) => memory.setOnboardingOption(value);
// ── Files ─────────────────────────────────────────────────────────────────
@override
Future<String> saveProof(String name, Uint8List bytes) =>
files.saveProof(name, bytes);
@override
Future<Uint8List?> readProof(String reference) => files.readProof(reference);
@override
Future<bool> deleteProof(String reference) => files.deleteProof(reference);
@override
Future<String> proofDirectory() => files.proofDirectory();
// ── Comms ─────────────────────────────────────────────────────────────────
@override
Future<Response> registerDevice(DeviceRequest request) =>
comms.registerDevice(request);
@override
Future<Response> registerDeviceToken(String token) =>
comms.registerDeviceToken(token);
@override
Future<Response> login(LoginData request) => comms.login(request);
@override
Future<Response> logout() => comms.logout();
@override
Future<Response> me() => comms.me();
@override
Future<Response> getMyGoals(HistoryRequest request) =>
comms.getMyGoals(request);
@override
Future<Response> saveGoalEntry(GoalRequest request) =>
comms.saveGoalEntry(request);
@override
Future<Response> getGoalTasks(IdRequest request) =>
comms.getGoalTasks(request);
@override
Future<Response> archiveGoalEntry(IdRequest request) =>
comms.archiveGoalEntry(request);
@override
Future<Response> getTodayPlan(CommitmentsRequest request) =>
comms.getTodayPlan(request);
@override
Future<Response> getMyCommitments(CommitmentsRequest request) =>
comms.getMyCommitments(request);
@override
Future<Response> getOverdueQueue(HistoryRequest request) =>
comms.getOverdueQueue(request);
@override
Future<Response> saveCommitmentEntry(CommitmentRequest request) =>
comms.saveCommitmentEntry(request);
@override
Future<Response> updateCommitmentEntry(CommitmentRequest request) =>
comms.updateCommitmentEntry(request);
@override
Future<Response> deleteCommitmentEntry(IdRequest request) =>
comms.deleteCommitmentEntry(request);
@override
Future<Response> completeCommitmentEntry(CompletionRequest request) =>
comms.completeCommitmentEntry(request);
@override
Future<Response> deferCommitmentEntry(DeferralRequest request) =>
comms.deferCommitmentEntry(request);
@override
Future<Response> abandonCommitmentEntry(AbandonRequest request) =>
comms.abandonCommitmentEntry(request);
@override
Future<Response> getCommitmentHistory(HistoryRequest request) =>
comms.getCommitmentHistory(request);
@override
Future<Response> getCommitmentEvents(IdRequest request) =>
comms.getCommitmentEvents(request);
@override
Future<Response> checkCapacity(CommitmentsRequest request) =>
comms.checkCapacity(request);
@override
Future<Response> getCapacityProfileEntry() => comms.getCapacityProfileEntry();
@override
Future<Response> getDebtSummary() => comms.getDebtSummary();
@override
Future<Response> getDebtLedger(HistoryRequest request) =>
comms.getDebtLedger(request);
@override
Future<Response> getDebtTrend(ReportCardRequest request) =>
comms.getDebtTrend(request);
@override
Future<Response> getStanding() => comms.getStanding();
@override
Future<Response> getStandingHistory(HistoryRequest request) =>
comms.getStandingHistory(request);
@override
Future<Response> getExcuseClusters(ReportCardRequest request) =>
comms.getExcuseClusters(request);
@override
Future<Response> uploadPhotoProof(FormData request) =>
comms.uploadPhotoProof(request);
@override
Future<Response> submitTimerProofEntry(CompletionRequest request) =>
comms.submitTimerProofEntry(request);
@override
Future<Response> submitLocationProofEntry(CompletionRequest request) =>
comms.submitLocationProofEntry(request);
@override
Future<Response> spendAmnestyToken(AmnestyRequest request) =>
comms.spendAmnestyToken(request);
@override
Future<Response> getAmnestyBalance() => comms.getAmnestyBalance();
@override
Future<Response> updateSickMode(SickModeRequest request) =>
comms.updateSickMode(request);
@override
Future<Response> checkDistress() => comms.checkDistress();
@override
Future<Response> updateTone(ToneRequest request) => comms.updateTone(request);
@override
Future<Response> getMyHabits(HistoryRequest request) =>
comms.getMyHabits(request);
@override
Future<Response> saveHabitEntry(Map<String, dynamic> request) =>
comms.saveHabitEntry(request);
@override
Future<Response> logHabitEntry(IdRequest request) =>
comms.logHabitEntry(request);
@override
Future<Response> getKeystoneHabits() => comms.getKeystoneHabits();
@override
Future<Response> getMyRoutines(HistoryRequest request) =>
comms.getMyRoutines(request);
@override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) =>
comms.logRoutineChainEntry(request);
@override
Future<Response> getMyPrograms(HistoryRequest request) =>
comms.getMyPrograms(request);
@override
Future<Response> saveProgramEntry(Map<String, dynamic> request) =>
comms.saveProgramEntry(request);
@override
Future<Response> activateProgramEntry(IdRequest request) =>
comms.activateProgramEntry(request);
@override
Future<Response> getProgramSessions(IdRequest request) =>
comms.getProgramSessions(request);
@override
Future<Response> startSessionEntry(IdRequest request) =>
comms.startSessionEntry(request);
@override
Future<Response> saveSessionLogEntry(SessionLogRequest request) =>
comms.saveSessionLogEntry(request);
@override
Future<Response> getSessionHistory(HistoryRequest request) =>
comms.getSessionHistory(request);
@override
Future<Response> getLastSessionFor(IdRequest request) =>
comms.getLastSessionFor(request);
@override
Future<Response> getWeeklyVolume(ReportCardRequest request) =>
comms.getWeeklyVolume(request);
@override
Future<Response> getContactVolume(ReportCardRequest request) =>
comms.getContactVolume(request);
@override
Future<Response> getPersonalRecords(HistoryRequest request) =>
comms.getPersonalRecords(request);
@override
Future<Response> getWeeklyReportCard(ReportCardRequest request) =>
comms.getWeeklyReportCard(request);
@override
Future<Response> getMonthlyReportCard(ReportCardRequest request) =>
comms.getMonthlyReportCard(request);
@override
Future<Response> getMyNotifications(HistoryRequest request) =>
comms.getMyNotifications(request);
@override
Future<Response> acknowledgeNudgeEntry(IdRequest request) =>
comms.acknowledgeNudgeEntry(request);
}

View File

@@ -0,0 +1,6 @@
import '../about/internal/file/ConnectFileStorage.dart';
import '../comms/ConnectComms.dart';
import '../memory/ConnectInternalMemory.dart';
abstract class DataManager
implements ConnectInternalMemory, ConnectComms, ConnectFileStorage {}

View File

@@ -0,0 +1,65 @@
import '../about/external/data/Commitment.dart';
import '../about/external/data/Program.dart';
import '../about/external/data/SystemResponse.dart';
import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart';
abstract class ConnectInternalMemory {
Future<MeDescription> getMyDescription();
Future setMyDescription(MeDescription description);
Future<String> getNotToken();
Future setNotToken(String token);
Future<SystemResponse> getUserCreationDetails();
Future setUserCreationDetails(SystemResponse response);
Future<UserDetails> getUserDetails();
Future setUserDetails(UserDetails details);
Future<Token> getTokenEntry();
Future setTokenEntry(Token token);
Future<String> getRefreshAt();
Future setRefreshAt(String refresher);
Future<Commitment> getActiveCommitment();
Future setActiveCommitment(Commitment commitment);
Future<Program> getActiveProgram();
Future setActiveProgram(Program program);
Future<CapacityProfile> getCapacityProfile();
Future setCapacityProfile(CapacityProfile profile);
/// The last computed debt score, so the app can open with a number rather
/// than a spinner.
Future<double> getCachedDebtScore();
Future setCachedDebtScore(double score);
/// App opens this week, for the distress conjunction.
Future<int> getEngagementCount();
Future setEngagementCount(int count);
/// Amnesty tokens spent this month.
Future<int> getAmnestySpent();
Future setAmnestySpent(int spent);
Future<bool> showOnboarding();
Future setOnboardingOption(bool value);
}

View File

@@ -0,0 +1,270 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../about/external/data/Commitment.dart';
import '../about/external/data/Program.dart';
import '../about/external/data/ResponseState.dart';
import '../about/external/data/SystemResponse.dart';
import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart';
import 'ConnectInternalMemory.dart';
/// Secure local storage. Keys carry a random suffix so they are not guessable
/// from the field name alone.
class InternalMemory implements ConnectInternalMemory {
static const String DESCRIBE_ME = "DESCRIBE_ME_Kq7fRp2XvN4mLd8T";
static const String NOT_TOKEN = "NOT_TOKEN_Zw3hYb9CsK6nQe1V";
static const String USER_DETAILS_NEW = "USER_DETAILS_NEW_Rt5jXm8PfA2wDc7L";
static const String ABOUT_ME = "ABOUT_ME_Hn4vTq7BdS9xGk3M";
static const String TOKEN_DETAILS = "TOKEN_DETAILS_Ly6pWc3NrE8zFj5Q";
static const String REFRESHER_ID = "REFRESHER_ID_Vb2sJd7MtX4qHu9K";
static const String ACTIVE_COMMITMENT =
"ACTIVE_COMMITMENT_Pf9kRn3WgY6tBz2S";
static const String ACTIVE_PROGRAM = "ACTIVE_PROGRAM_Dm5cQx8LvH1rTk4N";
static const String CAPACITY_PROFILE = "CAPACITY_PROFILE_Gs7bZp4JnW9dFy6X";
static const String DEBT_SCORE = "DEBT_SCORE_Ux3mKt6QcR8vNa5H";
static const String ENGAGEMENT_COUNT = "ENGAGEMENT_COUNT_Ct8nDw2FbP5jSq7Z";
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
final groundedStorage = const FlutterSecureStorage();
@override
Future<MeDescription> getMyDescription() async {
String? value = await groundedStorage.read(key: DESCRIBE_ME);
if (value != null) {
if (value != "") {
Map<String, dynamic> json = jsonDecode(value);
return MeDescription.fromJson(json);
}
}
return MeDescription(id: "", name: "", token: "");
}
@override
Future setMyDescription(MeDescription description) async {
String value = jsonEncode(description.toJson());
await groundedStorage.write(key: DESCRIBE_ME, value: value);
}
@override
Future<String> getNotToken() async {
return await groundedStorage.read(key: NOT_TOKEN) ?? "";
}
@override
Future setNotToken(String token) async {
await groundedStorage.write(key: NOT_TOKEN, value: token);
}
@override
Future<SystemResponse> getUserCreationDetails() async {
String? value = await groundedStorage.read(key: USER_DETAILS_NEW);
if (value != null) {
if (value != "") {
Map<String, dynamic> json = jsonDecode(value);
return SystemResponse.fromJsonMap(json);
}
}
return SystemResponse("", "", "", ResponseState.Success);
}
@override
Future setUserCreationDetails(SystemResponse response) async {
String value = jsonEncode(response.toJson());
await groundedStorage.write(key: USER_DETAILS_NEW, value: value);
}
@override
Future<UserDetails> getUserDetails() async {
String? value = await groundedStorage.read(key: ABOUT_ME);
if (value != null) {
if (value != "") {
Map<String, dynamic> json = jsonDecode(value);
return UserDetails.fromJson(json);
}
}
return UserDetails(pic: '', name: '');
}
@override
Future setUserDetails(UserDetails details) async {
String value = jsonEncode(details.toJson());
await groundedStorage.write(key: ABOUT_ME, value: value);
}
@override
Future<Token> getTokenEntry() async {
String? value = await groundedStorage.read(key: TOKEN_DETAILS);
if (value != null) {
if (value != "") {
Map<String, dynamic> json = jsonDecode(value);
return Token.fromJsonMap(json);
}
}
return Token("", "", "", 0, "");
}
@override
Future setTokenEntry(Token token) async {
String value = jsonEncode(token.toJson());
await groundedStorage.write(key: TOKEN_DETAILS, value: value);
}
@override
Future<String> getRefreshAt() async {
return await groundedStorage.read(key: REFRESHER_ID) ?? "";
}
@override
Future setRefreshAt(String refresher) async {
await groundedStorage.write(key: REFRESHER_ID, value: refresher);
}
@override
Future<Commitment> getActiveCommitment() async {
String? value = await groundedStorage.read(key: ACTIVE_COMMITMENT);
if (value != null) {
if (value.isNotEmpty) {
Map<String, dynamic> json = jsonDecode(value);
return Commitment.fromJson(json);
}
}
return Commitment();
}
@override
Future setActiveCommitment(Commitment commitment) async {
String value = jsonEncode(commitment.toJson());
await groundedStorage.write(key: ACTIVE_COMMITMENT, value: value);
}
@override
Future<Program> getActiveProgram() async {
String? value = await groundedStorage.read(key: ACTIVE_PROGRAM);
if (value != null) {
if (value.isNotEmpty) {
Map<String, dynamic> json = jsonDecode(value);
return Program.fromJson(json);
}
}
return Program();
}
@override
Future setActiveProgram(Program program) async {
String value = jsonEncode(program.toJson());
await groundedStorage.write(key: ACTIVE_PROGRAM, value: value);
}
@override
Future<CapacityProfile> getCapacityProfile() async {
String? value = await groundedStorage.read(key: CAPACITY_PROFILE);
if (value != null) {
if (value.isNotEmpty) {
Map<String, dynamic> json = jsonDecode(value);
return CapacityProfile.fromJson(json);
}
}
return CapacityProfile();
}
@override
Future setCapacityProfile(CapacityProfile profile) async {
String value = jsonEncode(profile.toJson());
await groundedStorage.write(key: CAPACITY_PROFILE, value: value);
}
@override
Future<double> getCachedDebtScore() async {
String? value = await groundedStorage.read(key: DEBT_SCORE);
if (value != null) {
return double.tryParse(value) ?? 0;
}
return 0;
}
@override
Future setCachedDebtScore(double score) async {
await groundedStorage.write(key: DEBT_SCORE, value: score.toString());
}
@override
Future<int> getEngagementCount() async {
String? value = await groundedStorage.read(key: ENGAGEMENT_COUNT);
if (value != null) {
return int.tryParse(value) ?? 0;
}
return 0;
}
@override
Future setEngagementCount(int count) async {
await groundedStorage.write(key: ENGAGEMENT_COUNT, value: count.toString());
}
@override
Future<int> getAmnestySpent() async {
String? value = await groundedStorage.read(key: AMNESTY_SPENT);
if (value != null) {
return int.tryParse(value) ?? 0;
}
return 0;
}
@override
Future setAmnestySpent(int spent) async {
await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString());
}
@override
Future<bool> showOnboarding() async {
String? value = await groundedStorage.read(key: SHOW_ONBOARDING);
if (value != null) {
return value.toLowerCase() == 'true';
}
return true;
}
@override
Future setOnboardingOption(bool value) async {
await groundedStorage.write(key: SHOW_ONBOARDING, value: value.toString());
}
}

View File

@@ -0,0 +1,12 @@
import '../../utils/CapacityEngine.dart';
abstract class ConnectNewCommitment {
void onSaved();
/// The plan is over capacity — the save is refused until something is cut.
void onCapacityBlocked(CapacityVerdict verdict);
/// The learned multiplier for this category, so the estimate field can show
/// what the app actually expects the task to take.
void onMultiplierResolved(double multiplier);
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'NewCommitmentState.dart';
class NewCommitment extends StatefulWidget {
const NewCommitment({super.key});
@override
State<NewCommitment> createState() => NewCommitmentState();
}

View File

@@ -0,0 +1,552 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/EnergyCost.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/CapacityEngine.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectNewCommitment.dart';
import 'NewCommitment.dart';
import 'ViewNewCommitment.dart';
class NewCommitmentState extends State<NewCommitment>
implements ConnectNewCommitment {
ViewNewCommitment? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _title = TextEditingController();
final TextEditingController _category = TextEditingController();
final TextEditingController _estimate = TextEditingController();
CommitmentClass _class = CommitmentClass.Standard;
EnergyCost _energy = EnergyCost.Medium;
ProofType _proof = ProofType.Honour;
DateTime? _windowStart;
DateTime? _windowEnd;
double _multiplier = 1.0;
String _windowError = "";
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewNewCommitment>.reactive(
viewModelBuilder: () => ViewNewCommitment(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
// A window, not a date: the default opens now and closes in two hours, so
// the field is never left as a bare day.
final DateTime now = DateTime.now();
setState(() {
_windowStart = now;
_windowEnd = now.add(const Duration(hours: 2));
});
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onCategoryChanged(String value) {
_model?.resolveMultiplier(value.trim());
}
void _onClassSelected(CommitmentClass value) {
setState(() {
_class = value;
// Non-negotiables carry real consequences, so they default to real proof
// rather than the honour checkbox.
if (value == CommitmentClass.NonNegotiable &&
_proof == ProofType.Honour) {
_proof = ProofType.Photo;
}
});
}
void _onEnergySelected(EnergyCost value) {
setState(() {
_energy = value;
});
}
void _onProofSelected(ProofType value) {
setState(() {
_proof = value;
});
}
void _onPickWindowStart() async {
final DateTime? picked = await _pickMoment(_windowStart);
if (picked == null) {
return;
}
setState(() {
_windowStart = picked;
if (_windowEnd == null || !_windowEnd!.isAfter(picked)) {
_windowEnd = picked.add(const Duration(hours: 2));
}
_windowError = "";
});
}
void _onPickWindowEnd() async {
final DateTime? picked = await _pickMoment(_windowEnd);
if (picked == null) {
return;
}
setState(() {
_windowEnd = picked;
_windowError = Validators.window(_windowStart, _windowEnd) ?? "";
});
}
Future<DateTime?> _pickMoment(DateTime? initial) async {
final DateTime base = initial ?? DateTime.now();
final DateTime? day = await showDatePicker(
context: context,
initialDate: base,
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (day == null || !mounted) {
return null;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(base),
);
if (time == null) {
return null;
}
return DateTime(day.year, day.month, day.day, time.hour, time.minute);
}
void _onSave() {
final String? windowIssue = Validators.window(_windowStart, _windowEnd);
if (windowIssue != null) {
setState(() {
_windowError = windowIssue;
});
return;
}
if (_formKey.currentState?.validate() != true) {
return;
}
_model?.save(_buildRequest(), _buildCandidate());
}
CommitmentRequest _buildRequest() {
return CommitmentRequest(
commitmentClass: _class.name,
title: _title.text.trim(),
category: _category.text.trim(),
dueStart: _windowStart?.toIso8601String() ?? "",
dueEnd: _windowEnd?.toIso8601String() ?? "",
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
energy: _energy.name,
proofType: _proof.name,
proofTimerMinutes:
_proof == ProofType.Timer ? int.tryParse(_estimate.text.trim()) ?? 0 : 0,
);
}
Commitment _buildCandidate() {
return Commitment(
commitmentClass: _class,
title: _title.text.trim(),
category: _category.text.trim(),
dueStart: _windowStart,
dueEnd: _windowEnd,
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
energy: _energy,
proofType: _proof,
);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
final int estimate = int.tryParse(_estimate.text.trim()) ?? 0;
final bool multiplierWorthShowing = _multiplier > 1.15 && estimate > 0;
return Sheet(
eyebrow: "New",
title: "Commitment",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("What are you\ncommitting to?"),
const SizedBox(height: 28),
inputField(
"Title",
_title,
hint: "Say it the way you would say it out loud",
validator: Validators.title,
),
const SizedBox(height: 20),
inputField(
"Category",
_category,
hint: "Admin, gym, deep work…",
onChanged: _onCategoryChanged,
),
const SizedBox(height: 28),
sectionBreak("Class", caption: "decides the weight"),
segmentedSelector<CommitmentClass>(
options: CommitmentClass.values,
selected: _class,
label: classLabel,
onSelected: _onClassSelected,
activeColor: classColor(_class),
),
const SizedBox(height: 12),
text(
_classDescription(_class),
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 28),
sectionBreak("Window", caption: "not just a day"),
if (_windowError.isNotEmpty) ...[
text(_windowError, 11, TextType.Regular, color: colorNegative),
const SizedBox(height: 10),
],
Row(
children: [
Expanded(
child: selectField(
"Opens",
_windowStart == null ? "" : formatDateTime(_windowStart),
_onPickWindowStart,
icon: CupertinoIcons.calendar,
),
),
const SizedBox(width: 10),
Expanded(
child: selectField(
"Closes",
_windowEnd == null ? "" : formatDateTime(_windowEnd),
_onPickWindowEnd,
icon: CupertinoIcons.calendar,
),
),
],
),
const SizedBox(height: 10),
text(
"When this window closes, the item goes overdue. It does not roll over to tomorrow.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 28),
sectionBreak("Effort"),
inputField(
"Estimated minutes",
_estimate,
hint: "How long you think it takes",
validator: Validators.estimateMinutes,
keyboard: TextInputType.number,
onChanged: (value) => setState(() {}),
),
if (multiplierWorthShowing) ...[
const SizedBox(height: 12),
card(
background: colorStandingWarnedBg,
borderColor: colorStandingWarned.withValues(alpha: 0.20),
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(CupertinoIcons.info_circle_fill,
size: 15, color: colorStandingWarned),
const SizedBox(width: 10),
Expanded(
child: text(
"On ${_category.text.trim().isEmpty ? "this category" : _category.text.trim()} you historically take ${_multiplier.toStringAsFixed(1)}× your estimate. Your day is planned against ${formatMinutes(estimate * _multiplier)}.",
12,
TextType.Regular,
color: colorPrimaryDark,
height: 1.5,
),
),
],
),
),
],
const SizedBox(height: 20),
text("ENERGY COST", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(height: 10),
segmentedSelector<EnergyCost>(
options: EnergyCost.values,
selected: _energy,
label: (value) => value.name,
onSelected: _onEnergySelected,
),
const SizedBox(height: 28),
sectionBreak("Proof", caption: "the checkbox is the enemy"),
segmentedSelector<ProofType>(
options: ProofType.values,
selected: _proof,
label: proofLabel,
onSelected: _onProofSelected,
),
const SizedBox(height: 12),
text(
_proofDescription(_proof),
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 32),
roundedCornerButton(
"Commit",
_onSave,
icon: CupertinoIcons.checkmark,
),
const SizedBox(height: 10),
Center(
child: text(
"You can change the details later. You cannot change the history.",
11,
TextType.Regular,
color: colorGrey2,
align: TextAlign.center,
),
),
],
),
),
);
}
String _classDescription(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return "Never deferrable, heaviest debt, hardest escalation. Meds, deadlines, rent.";
case CommitmentClass.Standard:
return "Normal weight, two deferrals, then it is complete or abandon.";
case CommitmentClass.Elective:
return "No debt if you miss it. Auto-archives if it sits untouched.";
}
}
String _proofDescription(ProofType value) {
switch (value) {
case ProofType.Honour:
return "A plain checkbox. Fine for trivia, worthless for anything you actually lie to yourself about.";
case ProofType.Photo:
return "Camera only, no gallery. Timestamped, and near-duplicate photos get flagged.";
case ProofType.Timer:
return "A foreground session. Backgrounding the app pauses the clock.";
case ProofType.Location:
return "Geofence dwell. Being near it does not count as being there.";
case ProofType.Health:
return "Your health platform confirms the workout happened inside the window.";
case ProofType.Witness:
return "Someone else confirms it. The hardest one to talk your way around.";
}
}
/// The capacity refusal. It shows the arithmetic rather than just saying no,
/// because the point is to make overcommitment visible.
void _openCapacitySheet(CapacityVerdict verdict) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
builder: (BuildContext sheetContext) {
return Container(
decoration: BoxDecoration(
color: colorSheetBackground,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: colorGrey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(999),
),
),
),
text("OVER CAPACITY", 9, TextType.Bold,
color: colorStandingWarned, letterSpacing: 1.2),
const SizedBox(height: 10),
text("This day is already full.", 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 14),
text(verdict.message, 14, TextType.Regular,
color: colorGrey2, height: 1.55),
const SizedBox(height: 24),
card(
child: Row(
children: [
Expanded(
child: labelled(
"Planned",
formatMinutes(verdict.projectedMinutes),
valueSize: 20,
valueType: TextType.Light,
),
),
Expanded(
child: labelled(
"You do",
formatMinutes(verdict.historicalMinutes),
valueSize: 20,
valueType: TextType.Light,
),
),
Expanded(
child: labelled(
"Cut",
formatMinutes(verdict.excessMinutes),
valueSize: 20,
valueType: TextType.Light,
valueColor: colorStandingGrounded,
),
),
],
),
),
const SizedBox(height: 20),
text(
"Chronic overdue is usually an overcommitment problem wearing a laziness costume. Cutting something now is the cheapest fix available.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 24),
roundedCornerButton(
"Let me cut something",
() => Navigator.pop(sheetContext),
icon: CupertinoIcons.scissors,
),
const SizedBox(height: 8),
Center(
child: textButton(
"Add it anyway",
() {
Navigator.pop(sheetContext);
_model?.saveAnyway(_buildRequest());
},
textSize: 12,
color: colorGrey2,
),
),
],
),
),
);
},
);
}
// ── ConnectNewCommitment ──────────────────────────────────────────────────
@override
void onSaved() {
_model?.showApplicationNotification(
NotificationType.success,
"Committed",
"It is on the record now. The window closes ${formatDateTime(_windowEnd)}.",
true,
true,
() {
Navigator.pop(context, true);
},
);
}
@override
void onCapacityBlocked(CapacityVerdict verdict) {
_openCapacitySheet(verdict);
}
@override
void onMultiplierResolved(double multiplier) {
setState(() {
_multiplier = multiplier;
});
}
@override
void dispose() {
_title.dispose();
_category.dispose();
_estimate.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,93 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CapacityProfile.dart';
import '../../utils/CapacityEngine.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectNewCommitment.dart';
class ViewNewCommitment extends ParentViewModel {
ConnectNewCommitment connection;
ViewNewCommitment(super.context, this.connection);
/// Surfaces the learned multiplier for the category so the estimate field
/// can show what the app actually expects, rather than silently overriding.
void resolveMultiplier(String category) async {
final CapacityProfile profile =
await getDataManager().getCapacityProfile();
connection.onMultiplierResolved(profile.multiplierFor(category));
}
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
/// as laziness, so the plan is checked against what history says actually
/// gets done before anything is accepted.
void save(CommitmentRequest request, Commitment candidate) async {
if (!await hasNetwork(() => save(request, candidate))) return;
showLoading("Checking your day");
try {
final DateTime day = candidate.dueStart ?? DateTime.now();
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
day: day.toIso8601String(),
query: PageAndSort(
sort: Sort('asc', 'dueStart'),
page: Pageable(0, 0, 100, 0),
),
));
final CommitmentPage page = CommitmentPage.fromJson(response.data);
final CapacityProfile profile =
await getDataManager().getCapacityProfile();
final List<Commitment> proposed = <Commitment>[
...page.content,
candidate,
];
final CapacityVerdict verdict =
CapacityEngine.check(proposed, profile, day.weekday);
if (verdict.blocked) {
closeLoading();
connection.onCapacityBlocked(verdict);
return;
}
await getDataManager().saveCommitmentEntry(request);
closeLoading();
connection.onSaved();
} catch (e) {
handleError(
e, () => save(request, candidate), () => dismissError(), "Retry");
}
}
/// Saving past a capacity block, which is only reachable after the user has
/// seen exactly how much they are over by.
void saveAnyway(CommitmentRequest request) async {
if (!await hasNetwork(() => saveAnyway(request))) return;
showLoading("Saving");
try {
await getDataManager().saveCommitmentEntry(request);
closeLoading();
connection.onSaved();
} catch (e) {
handleError(e, () => saveAnyway(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,5 @@
import '../../about/external/data/ExcuseCluster.dart';
abstract class ConnectExcuseReport {
void onClustersLoaded(List<ExcuseCluster> clusters);
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'ExcuseReportState.dart';
class ExcuseReport extends StatefulWidget {
const ExcuseReport({super.key});
@override
State<ExcuseReport> createState() => ExcuseReportState();
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectExcuseReport.dart';
import 'ExcuseReport.dart';
import 'ViewExcuseReport.dart';
class ExcuseReportState extends State<ExcuseReport>
implements ConnectExcuseReport {
ViewExcuseReport? _model;
List<ExcuseCluster> _clusters = <ExcuseCluster>[];
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewExcuseReport>.reactive(
viewModelBuilder: () => ViewExcuseReport(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadClusters();
}
void _onBack() {
Navigator.pop(context);
}
Widget _mobileView(BoxConstraints constraints) {
final int total = _clusters.fold(0, (sum, item) => sum + item.occurrences);
return Sheet(
eyebrow: "Last 30 days",
title: "Excuses",
onBack: _onBack,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("What you tell\nyourself."),
const SizedBox(height: 14),
text(
"Every deferral you wrote, grouped. Read the concentrations rather than the totals — that is where the pattern is.",
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
if (_clusters.isEmpty)
emptyState(
CupertinoIcons.text_quote,
"Nothing to confront yet",
"Excuses appear here once you have deferred a few things. There is no shame in an empty page.",
accent: colorPositive,
)
else ...[
card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
child: Row(
children: [
Expanded(
child: labelled(
"Total excuses",
"$total",
valueSize: 30,
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
Expanded(
child: labelled(
"Distinct kinds",
"${_clusters.length}",
valueSize: 30,
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
],
),
),
const SizedBox(height: 24),
sectionBreak("The taxonomy", caption: "most frequent first"),
..._clusters.map(_clusterCard),
],
],
),
);
}
Widget _clusterCard(ExcuseCluster cluster) {
final MapEntry<int, int>? peakDay = _peak(cluster.byWeekday);
final MapEntry<int, int>? peakHour = _peak(cluster.byHour);
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: text(cluster.label, 19, TextType.Light,
color: colorPrimaryDark),
),
pill("${cluster.occurrences}×", colorPrimaryDark, colorMuted,
textSize: 10),
],
),
if (cluster.insight.isNotEmpty) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorInset,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorBorder, width: 1),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(CupertinoIcons.quote_bubble_fill,
size: 14, color: colorGrey),
const SizedBox(width: 10),
Expanded(
child: text(cluster.insight, 13, TextType.Regular,
color: colorPrimaryDark, height: 1.55),
),
],
),
),
],
hairline(margin: const EdgeInsets.symmetric(vertical: 16)),
Row(
children: [
Expanded(
child: labelled(
"Worst day",
peakDay == null ? "" : weekdayName(peakDay.key),
valueSize: 13,
),
),
Expanded(
child: labelled(
"Worst hour",
peakHour == null ? "" : hourLabel(peakHour.key),
valueSize: 13,
),
),
Expanded(
child: labelled(
"Category",
cluster.dominantCategory.isEmpty
? ""
: cluster.dominantCategory,
valueSize: 13,
),
),
],
),
],
),
),
);
}
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;
}
@override
void onClustersLoaded(List<ExcuseCluster> clusters) {
setState(() {
_clusters = clusters;
});
}
}

View File

@@ -0,0 +1,37 @@
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/initial/ReportCardRequest.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectExcuseReport.dart';
class ViewExcuseReport extends ParentViewModel {
ConnectExcuseReport connection;
ViewExcuseReport(super.context, this.connection);
void loadClusters() async {
if (!await hasNetwork(() => loadClusters())) return;
showLoading("Reading your excuses");
try {
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final response =
await getDataManager().getExcuseClusters(ReportCardRequest(
periodStart: start.toIso8601String(),
periodEnd: now.toIso8601String(),
));
final List<ExcuseCluster> clusters =
getExcuseClusterList(response.data);
closeLoading();
connection.onClustersLoaded(clusters);
} catch (e) {
handleError(e, () => loadClusters(), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,9 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
abstract class ConnectGoalDetail {
void onGoalLoaded(Goal goal, List<Commitment> tasks);
/// The task is ready to run — hand off to the full-screen runner.
void onTaskReady(Commitment task);
}

View File

@@ -0,0 +1,7 @@
import '../../about/external/data/Goal.dart';
abstract class ConnectGoals {
void onGoalsLoaded(List<Goal> goals);
void onGoalSaved();
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Goal.dart';
import 'GoalDetailState.dart';
class GoalDetail extends StatefulWidget {
final Goal goal;
const GoalDetail({super.key, required this.goal});
@override
State<GoalDetail> createState() => GoalDetailState();
}

View File

@@ -0,0 +1,310 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/CommitmentStatus.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart';
import '../commitment/NewCommitment.dart';
import '../live/LiveTask.dart';
import 'ConnectGoalDetail.dart';
import 'GoalDetail.dart';
import 'ViewGoalDetail.dart';
class GoalDetailState extends State<GoalDetail>
implements ConnectGoalDetail {
ViewGoalDetail? _model;
Goal _goal = Goal();
List<Commitment> _tasks = <Commitment>[];
bool _changed = false;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGoalDetail>.reactive(
viewModelBuilder: () => ViewGoalDetail(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
setState(() {
_goal = widget.goal;
});
_model?.loadTasks(widget.goal);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, _changed);
}
void _onStartTask(Commitment task) {
_model?.startTask(task);
}
void _onAddTask() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context);
if (result == true) {
_changed = true;
_model?.loadTasks(_goal);
}
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
final List<Commitment> open = _tasks
.where((task) =>
task.status != CommitmentStatus.Completed &&
task.status != CommitmentStatus.LateCompleted &&
task.status != CommitmentStatus.Abandoned)
.toList();
final List<Commitment> done = _tasks
.where((task) =>
task.status == CommitmentStatus.Completed ||
task.status == CommitmentStatus.LateCompleted)
.toList();
return Sheet(
eyebrow: "Goal",
title: _goal.title,
onBack: _onBack,
action: chromeAction(CupertinoIcons.add, _onAddTask),
banner: _progressBanner(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_goal.description.isNotEmpty) ...[
text(_goal.description, 15, TextType.Regular,
color: colorGrey2, height: 1.6),
const SizedBox(height: 28),
],
sectionBreak("To do", caption: "${open.length} open"),
if (open.isEmpty)
emptyState(
CupertinoIcons.square_list,
"Nothing scheduled",
"Add the actual sessions — Monday shoulders, Wednesday legs — and they start counting.",
)
else
...open.map(_taskRow),
if (done.isNotEmpty) ...[
const SizedBox(height: 28),
sectionBreak("Done", caption: "${done.length}"),
...done.map(_doneRow),
],
const SizedBox(height: 24),
roundedCornerButton("Add a task", _onAddTask,
icon: CupertinoIcons.add),
],
),
);
}
Widget _progressBanner() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("PROGRESS", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
text("${(_goal.progress * 100).round()}%", 13, TextType.Bold,
color: colorWhite),
],
),
const SizedBox(height: 12),
meter(
_goal.progress,
fill: colorWhite,
track: colorWhite.withValues(alpha: 0.14),
height: 5,
),
],
),
);
}
/// A task row leads with the action: the point of opening a goal is to start
/// something, not to admire the list.
Widget _taskRow(Commitment task) {
final Color accent = classColor(task.commitmentClass);
final bool late = task.windowClosed;
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 3,
height: 38,
margin: const EdgeInsets.only(right: 14, top: 2),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(task.title, 16, TextType.Medium,
color: colorPrimaryDark,
maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 7),
Row(
children: [
text(formatWindow(task), 11, TextType.Regular,
color: colorGrey2),
const SizedBox(width: 9),
Container(
width: 3,
height: 3,
decoration: BoxDecoration(
color: colorGrey, shape: BoxShape.circle),
),
const SizedBox(width: 9),
text(formatMinutes(task.estMinutes), 11,
TextType.Regular, color: colorGrey2),
],
),
],
),
),
const SizedBox(width: 8),
pill(proofLabel(task.proofType), colorGrey2, colorMuted,
textSize: 9),
],
),
if (late) ...[
const SizedBox(height: 12),
Row(
children: [
pill(overdueLabel(task), colorStandingGrounded,
colorStandingGroundedBg, textSize: 9),
const SizedBox(width: 6),
pill("${formatDebt(DebtEngine.commitmentDebt(task))}",
colorGrey2, colorMuted, textSize: 9),
],
),
],
const SizedBox(height: 14),
roundedCornerButton(
"Start",
() => _onStartTask(task),
icon: CupertinoIcons.play_fill,
verticalPadding: 13,
),
],
),
),
);
}
Widget _doneRow(Commitment task) {
final bool late = task.status == CommitmentStatus.LateCompleted;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Icon(
late
? CupertinoIcons.checkmark_circle
: CupertinoIcons.checkmark_circle_fill,
size: 17,
color: late ? colorStandingWarned : colorPositive,
),
const SizedBox(width: 12),
Expanded(
child: text(task.title, 13, TextType.Regular,
color: colorGrey2,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
if (late)
pill("Late", colorStandingWarned, colorStandingWarnedBg,
textSize: 9),
],
),
),
);
}
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
@override
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
setState(() {
_goal = goal;
_tasks = tasks;
});
}
@override
void onTaskReady(Commitment task) async {
// The runner takes over the whole screen — a task you are running is the
// thing you are doing, not a row in a list.
final result = await GroundedNavigation().navigateToPageWithData(
LiveTask(commitment: task, goalTitle: _goal.title),
context,
);
if (result == true) {
_changed = true;
_model?.loadTasks(_goal);
}
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'GoalsState.dart';
class Goals extends StatefulWidget {
const Goals({super.key});
@override
State<Goals> createState() => GoalsState();
}

View File

@@ -0,0 +1,311 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/initial/GoalRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectGoals.dart';
import 'GoalDetail.dart';
import 'Goals.dart';
import 'ViewGoals.dart';
class GoalsState extends State<Goals> implements ConnectGoals {
ViewGoals? _model;
List<Goal> _goals = <Goal>[];
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGoals>.reactive(
viewModelBuilder: () => ViewGoals(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadGoals();
}
void _onBack() {
Navigator.pop(context);
}
void _onOpenGoal(Goal goal) async {
final result = await GroundedNavigation()
.navigateToPageWithData(GoalDetail(goal: goal), context);
if (result == true) {
_model?.loadGoals();
}
}
void _onNewGoal() {
_openGoalSheet();
}
/// Goals are lightweight on purpose — a name and a default class. The
/// weight lives on the tasks inside them.
void _openGoalSheet() {
final TextEditingController title = TextEditingController();
final TextEditingController description = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
CommitmentClass defaultClass = CommitmentClass.Standard;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
builder: (BuildContext sheetContext) {
return StatefulBuilder(
builder: (BuildContext sheetContext, StateSetter setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
decoration: BoxDecoration(
color: colorSheetBackground,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: colorGrey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(999),
),
),
),
text("NEW GOAL", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
text("What are you\nworking toward?", 26,
TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 20),
inputField(
"Goal",
title,
hint: "Workout, thesis, get the flat sorted…",
validator: Validators.title,
),
const SizedBox(height: 18),
inputField(
"Why it matters",
description,
hint: "Optional, but it helps on the bad days",
maxLines: 3,
),
const SizedBox(height: 20),
text("TASKS DEFAULT TO", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(height: 10),
segmentedSelector<CommitmentClass>(
options: CommitmentClass.values,
selected: defaultClass,
label: classLabel,
onSelected: (value) => setSheetState(() {
defaultClass = value;
}),
activeColor: classColor(defaultClass),
),
const SizedBox(height: 24),
roundedCornerButton(
"Create goal",
() {
if (formKey.currentState?.validate() != true) {
return;
}
Navigator.pop(sheetContext);
_model?.save(GoalRequest(
title: title.text.trim(),
description: description.text.trim(),
defaultClass: defaultClass.name,
startDate: DateTime.now().toIso8601String(),
));
},
icon: CupertinoIcons.add,
),
const SizedBox(height: 8),
Center(
child: textButton("Cancel",
() => Navigator.pop(sheetContext),
textSize: 13),
),
],
),
),
),
),
);
},
);
},
);
}
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: "Grounded",
title: "Goals",
onBack: _onBack,
action: chromeAction(CupertinoIcons.add, _onNewGoal),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("What you are\nworking toward."),
const SizedBox(height: 14),
text(
"A goal holds the tasks that get you there. The goal never carries debt — the tasks inside it do.",
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
if (_goals.isEmpty)
emptyState(
CupertinoIcons.flag,
"No goals yet",
"Create one — Workout, say — then put the actual sessions inside it.",
)
else
..._goals.map(_goalCard),
const SizedBox(height: 24),
roundedCornerButton("New goal", _onNewGoal,
icon: CupertinoIcons.add),
],
),
);
}
Widget _goalCard(Goal goal) {
final Color accent =
goal.slipping ? colorStandingGrounded : colorPrimaryDark;
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
onTap: () => _onOpenGoal(goal),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(goal.title, 19, TextType.Light,
color: colorPrimaryDark,
maxLines: 2,
overflow: TextOverflow.ellipsis),
if (goal.description.isNotEmpty) ...[
const SizedBox(height: 6),
text(goal.description, 12, TextType.Regular,
color: colorGrey2,
maxLines: 2,
overflow: TextOverflow.ellipsis),
],
],
),
),
const SizedBox(width: 10),
if (goal.overdueTasks > 0)
pill("${goal.overdueTasks} late", colorStandingGrounded,
colorStandingGroundedBg, textSize: 9),
],
),
const SizedBox(height: 18),
meter(goal.progress, fill: accent),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: labelled(
"Done",
"${goal.completedTasks} of ${goal.totalTasks}",
valueSize: 13,
),
),
Expanded(
child: labelled(
"Remaining",
"${goal.remainingTasks}",
valueSize: 13,
),
),
Expanded(
child: labelled(
"Debt",
formatDebt(goal.debtContribution),
valueSize: 13,
valueColor: goal.debtContribution > 0
? colorStandingGrounded
: colorPrimaryDark,
),
),
],
),
],
),
),
);
}
@override
void onGoalsLoaded(List<Goal> goals) {
setState(() {
_goals = goals;
});
}
@override
void onGoalSaved() {
_model?.loadGoals();
}
}

View File

@@ -0,0 +1,37 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/initial/IdRequest.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGoalDetail.dart';
class ViewGoalDetail extends ParentViewModel {
ConnectGoalDetail connection;
ViewGoalDetail(super.context, this.connection);
void loadTasks(Goal goal) async {
if (!await hasNetwork(() => loadTasks(goal))) return;
showLoading("Loading ${goal.title}");
try {
final response =
await getDataManager().getGoalTasks(IdRequest(id: goal.id ?? ""));
closeLoading();
connection.onGoalLoaded(goal, getCommitmentList(response.data));
} catch (e) {
handleError(e, () => loadTasks(goal), () => dismissError(), "Retry");
}
}
/// Stashes the task as the active one before the runner opens, so the
/// ongoing notification and any relaunch land back on the right thing.
void startTask(Commitment task) async {
await getDataManager().setActiveCommitment(task);
connection.onTaskReady(task);
}
}

View File

@@ -0,0 +1,51 @@
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/initial/GoalRequest.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGoals.dart';
class ViewGoals extends ParentViewModel {
ConnectGoals connection;
ViewGoals(super.context, this.connection);
void loadGoals() async {
if (!await hasNetwork(() => loadGoals())) return;
showLoading("Loading your goals");
try {
final response = await getDataManager().getMyGoals(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'startDate'),
page: Pageable(0, 0, 50, 0),
),
));
closeLoading();
connection.onGoalsLoaded(getGoalList(response.data));
} catch (e) {
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
}
}
void save(GoalRequest request) async {
if (!await hasNetwork(() => save(request))) return;
showLoading("Saving");
try {
await getDataManager().saveGoalEntry(request);
closeLoading();
connection.onGoalSaved();
} catch (e) {
handleError(e, () => save(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,24 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/UserDetails.dart';
abstract class ConnectHome {
void onUserLoaded(UserDetails details);
void onPlanLoaded(List<Commitment> plan);
void onOverdueLoaded(List<Commitment> overdue);
/// Standing arrives derived, with the debt it was derived from.
void onStandingResolved(Standing standing, double debtScore);
/// The one excuse pattern worth confronting the user with today.
void onExcuseInsight(ExcuseCluster? cluster);
/// Distress detected — the strict persona drops entirely.
void onDistressDetected();
/// Creating a commitment is refused at this standing.
void onCreationBlocked(String reason);
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'HomeState.dart';
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => HomeState();
}

View File

@@ -0,0 +1,696 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/CommitmentStatus.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/application/ToneLevel.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart';
import '../../utils/StandingEngine.dart';
import '../../utils/Thresholds.dart';
import '../../utils/ToneEngine.dart';
import '../commitment/NewCommitment.dart';
import '../excuse/ExcuseReport.dart';
import '../goal/Goals.dart';
import '../overdue/OverdueQueue.dart';
import '../reportcard/ReportCardScreen.dart';
import '../settings/Settings.dart';
import '../training/Training.dart';
import 'ConnectHome.dart';
import 'Home.dart';
import 'ViewHome.dart';
class HomeState extends State<Home> implements ConnectHome {
ViewHome? _model;
UserDetails _user = UserDetails(pic: '', name: '');
List<Commitment> _plan = <Commitment>[];
List<Commitment> _overdue = <Commitment>[];
Standing _standing = Standing.Good;
double _debt = 0;
ExcuseCluster? _insight;
bool _distressed = false;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHome>.reactive(
viewModelBuilder: () => ViewHome(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.initialise();
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onOpenOverdue() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const OverdueQueue(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onAddCommitment() async {
if (!StandingEngine.permitsNewCommitment(_standing)) {
_model?.requestNewCommitment(_standing, CommitmentClass.Standard);
return;
}
final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenReportCard() {
GroundedNavigation().navigateToPage(
NavigatorType.justOpen, const ReportCardScreen(), context);
}
void _onOpenGoals() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const Goals(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenTraining() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Training(), context);
}
void _onOpenSettings() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
}
void _onOpenExcuses() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
// Grounded and Lockdown replace the home screen with the overdue queue —
// you do not get to look at your nice plans, only at your mess.
final bool queueIsHome =
StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed;
return Sheet(
eyebrow: _user.name.isEmpty ? "Grounded" : _user.name,
title: queueIsHome ? "What you owe" : "Today",
chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing),
banner: _standingBanner(),
action: chromeAction(
CupertinoIcons.person,
_onOpenSettings,
dotted: _user.sickMode,
dotColor: colorStandingWarned,
),
child: _distressed
? _distressBody()
: queueIsHome
? _groundedBody()
: _planBody(),
);
}
/// The chrome carries the standing colour, so the tier is legible before a
/// single word is read.
Color _chromeFor(Standing standing) {
switch (standing) {
case Standing.Good:
return colorPrimaryDark;
case Standing.Warned:
return colorPrimaryDark;
case Standing.Grounded:
return colorStandingGrounded;
case Standing.Lockdown:
return colorStandingLockdown;
}
}
/// The debt strip that sits in the black chrome under the title.
Widget _standingBanner() {
if (_distressed) {
return const SizedBox.shrink();
}
final Color tone = standingColor(_standing);
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: _standing == Standing.Good ? tone : colorWhite,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
text(
standingLabel(_standing).toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.75),
letterSpacing: 1.2,
),
],
),
const SizedBox(height: 8),
text(
ToneEngine.standingHeadline(_standing, _user.tone),
16,
TextType.Medium,
color: colorWhite,
),
],
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text("DEBT", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.0),
const SizedBox(height: 4),
text(formatDebt(_debt), 30, TextType.Light, color: colorWhite),
],
),
],
),
);
}
/// The normal day: the plan, with the overdue count kept visible above it so
/// it is never out of sight.
Widget _planBody() {
final int overdueCount = _overdue.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (overdueCount > 0) ...[
_overdueCallout(overdueCount),
const SizedBox(height: 24),
],
if (_insight != null) ...[
_insightCard(_insight!),
const SizedBox(height: 24),
],
sectionBreak(
"The plan",
caption: "${_plan.length} committed",
trailing: _plan.isEmpty
? null
: text(formatMinutes(_plannedMinutes()), 12, TextType.Bold,
color: colorGrey2),
),
if (_plan.isEmpty)
emptyState(
CupertinoIcons.square_list,
"Nothing committed today",
"An empty plan is a decision too. Add something you actually intend to do.",
)
else
..._plan.map(_commitmentRow),
const SizedBox(height: 28),
_quickLinks(),
const SizedBox(height: 24),
roundedCornerButton(
"Commit to something",
_onAddCommitment,
icon: CupertinoIcons.add,
enabled: StandingEngine.permitsNewCommitment(_standing),
),
if (!StandingEngine.permitsNewCommitment(_standing)) ...[
const SizedBox(height: 10),
text(
ToneEngine.standingBody(_standing, _user.tone),
12,
TextType.Regular,
color: colorGrey2,
align: TextAlign.center,
),
],
],
);
}
/// Grounded: the plan is hidden entirely and only the mess is shown.
Widget _groundedBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(
_standing == Standing.Lockdown
? "One at a time."
: "Clear this first.",
),
const SizedBox(height: 12),
text(
ToneEngine.standingBody(_standing, _user.tone),
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
card(
background: standingBackground(_standing),
borderColor: standingColor(_standing).withValues(alpha: 0.20),
child: Row(
children: [
Expanded(
child: labelled(
"Open overdue",
"${_overdue.length}",
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
Expanded(
child: labelled(
"Debt to clear",
formatDebt(
StandingEngine.debtToNextTierDown(_debt, _standing)),
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Outstanding", caption: "${_overdue.length} items"),
if (_overdue.isEmpty)
emptyState(
CupertinoIcons.checkmark_seal,
"The queue is empty",
"Your standing will recover as the debt decays.",
)
else
..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length)
.map(_commitmentRow),
const SizedBox(height: 24),
roundedCornerButton(
_standing == Standing.Lockdown ? "Deal with this one" : "Open the queue",
_onOpenOverdue,
background: standingColor(_standing),
icon: CupertinoIcons.arrow_right,
),
],
);
}
/// Distress: the strict persona drops entirely. This is the difference
/// between a product people keep and one they resent.
Widget _distressBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("A NOTE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(ToneEngine.distressHeadline()),
const SizedBox(height: 14),
text(
ToneEngine.distressBody(),
15,
TextType.Regular,
color: colorGrey2,
height: 1.6,
),
const SizedBox(height: 28),
card(
background: colorStandingGoodBg,
borderColor: colorPositive.withValues(alpha: 0.20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("PAUSED", 9, TextType.Bold,
color: colorPositive, letterSpacing: 1.2),
const SizedBox(height: 8),
text("Debt is not accruing right now.", 16, TextType.Medium,
color: colorPrimaryDark),
const SizedBox(height: 6),
text(
"Nothing you miss this week is counting against you.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Three things", caption: "that actually matter"),
..._plan
.where((item) =>
item.commitmentClass == CommitmentClass.NonNegotiable)
.take(3)
.map(_commitmentRow),
const SizedBox(height: 24),
outlinedActionButton("Open settings", _onOpenSettings,
icon: CupertinoIcons.slider_horizontal_3),
],
);
}
Widget _overdueCallout(int count) {
return card(
background: colorStandingGroundedBg,
borderColor: colorStandingGrounded.withValues(alpha: 0.20),
onTap: _onOpenOverdue,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorStandingGrounded,
borderRadius: BorderRadius.circular(13),
),
child: text("$count", 17, TextType.Bold, color: colorWhite),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("OVERDUE", 9, TextType.Bold,
color: colorStandingGrounded, letterSpacing: 1.2),
const SizedBox(height: 5),
text(
count >= Thresholds.maxOpenOverdue
? "You are at the cap. Nothing new until this drops."
: "$count item${count == 1 ? "" : "s"} past the window.",
14,
TextType.Medium,
color: colorPrimaryDark,
),
],
),
),
Icon(CupertinoIcons.chevron_right,
size: 15, color: colorStandingGrounded),
],
),
);
}
/// The excuse confrontation. One pattern, stated plainly, with the
/// suggestion attached.
Widget _insightCard(ExcuseCluster cluster) {
return card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
onTap: _onOpenExcuses,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("PATTERN", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
text("${cluster.occurrences}×", 11, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45)),
],
),
const SizedBox(height: 12),
text(cluster.insight, 15, TextType.Regular,
color: colorWhite, height: 1.55),
],
),
);
}
Widget _commitmentRow(Commitment item) {
final bool late = item.windowClosed &&
item.status != CommitmentStatus.Completed &&
item.status != CommitmentStatus.LateCompleted;
final Color accent = classColor(item.commitmentClass);
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
onTap: _onOpenOverdue,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 3,
height: 42,
margin: const EdgeInsets.only(right: 14, top: 2),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(item.title, 15, TextType.Medium,
color: colorPrimaryDark,
maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
Row(
children: [
text(formatWindow(item), 11, TextType.Regular,
color: colorGrey2),
const SizedBox(width: 10),
Container(width: 3, height: 3, decoration: BoxDecoration(
color: colorGrey, shape: BoxShape.circle)),
const SizedBox(width: 10),
text(formatMinutes(item.estMinutes), 11,
TextType.Regular, color: colorGrey2),
],
),
if (late) ...[
const SizedBox(height: 10),
Row(
children: [
pill(
overdueLabel(item),
colorStandingGrounded,
colorStandingGroundedBg,
textSize: 9,
),
const SizedBox(width: 6),
pill(
"${formatDebt(DebtEngine.commitmentDebt(item))}",
colorGrey2,
colorMuted,
textSize: 9,
),
],
),
],
],
),
),
const SizedBox(width: 10),
pill(
classLabel(item.commitmentClass),
accent,
classBackground(item.commitmentClass),
textSize: 9,
),
],
),
),
);
}
Widget _quickLinks() {
return Row(
children: [
Expanded(
child: _quickLink(
CupertinoIcons.flag_fill,
"Goals",
_onOpenGoals,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.chart_bar_alt_fill,
"Report",
_onOpenReportCard,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.flame_fill,
"Training",
_onOpenTraining,
),
),
],
);
}
Widget _quickLink(IconData icon, String label, VoidCallback onTap) {
return card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
onTap: onTap,
child: Row(
children: [
Icon(icon, size: 17, color: colorPrimaryDark),
const SizedBox(width: 10),
Expanded(
child: text(label, 13, TextType.Medium,
color: colorPrimaryDark, maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
);
}
double _plannedMinutes() {
double total = 0;
for (Commitment item in _plan) {
total = total + item.estMinutes;
}
return total;
}
// ── ConnectHome ───────────────────────────────────────────────────────────
@override
void onUserLoaded(UserDetails details) {
setState(() {
_user = details;
});
}
@override
void onPlanLoaded(List<Commitment> plan) {
setState(() {
_plan = plan;
});
}
@override
void onOverdueLoaded(List<Commitment> overdue) {
setState(() {
_overdue = overdue;
});
}
@override
void onStandingResolved(Standing standing, double debtScore) {
setState(() {
_standing = standing;
_debt = debtScore;
});
}
@override
void onExcuseInsight(ExcuseCluster? cluster) {
setState(() {
_insight = cluster;
});
}
@override
void onDistressDetected() {
setState(() {
_distressed = true;
});
}
@override
void onCreationBlocked(String reason) {
_model?.showApplicationNotification(
NotificationType.warning,
"Not right now",
reason,
true,
true,
null,
);
}
}

View File

@@ -0,0 +1,164 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/external/initial/ReportCardRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../utils/DebtEngine.dart';
import '../../utils/GuardrailEngine.dart';
import '../../utils/StandingEngine.dart';
import '../../utils/ToneEngine.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectHome.dart';
class ViewHome extends ParentViewModel {
ConnectHome connection;
ViewHome(super.context, this.connection);
/// Loads the cached user first so the screen never opens on a spinner, then
/// refreshes everything from the server.
void initialise() async {
final UserDetails cached = await getDataManager().getUserDetails();
connection.onUserLoaded(cached);
loadPlan();
}
void loadPlan() async {
if (!await hasNetwork(() => loadPlan())) return;
showLoading("Loading your day");
try {
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
query: PageAndSort(
sort: Sort('asc', 'dueStart'),
page: Pageable(0, 0, 50, 0),
),
));
final CommitmentPage page = CommitmentPage.fromJson(response.data);
closeLoading();
connection.onPlanLoaded(page.content);
loadOverdue();
} catch (e) {
handleError(e, () => loadPlan(), () => dismissError(), "Retry");
}
}
void loadOverdue() async {
try {
final response = await getDataManager().getOverdueQueue(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'dueEnd'),
page: Pageable(0, 0, 50, 0),
),
));
final CommitmentPage page = CommitmentPage.fromJson(response.data);
connection.onOverdueLoaded(page.content);
resolveStanding(page.content);
loadExcuseInsight();
} catch (e) {
handleError(e, () => loadOverdue(), () => dismissError(), "Retry");
}
}
/// Standing is derived on device from the same formula the server uses, so
/// the number on screen is never stale relative to the queue beneath it.
void resolveStanding(List<Commitment> overdue) async {
final UserDetails details = await getDataManager().getUserDetails();
final bool distressed = await _checkDistress();
final double debt = DebtEngine.totalDebt(overdue);
final Standing standing = StandingEngine.evaluate(
debt,
missedNonNegotiables: DebtEngine.missedNonNegotiables(overdue),
sickMode: details.sickMode,
distressed: distressed,
);
await getDataManager().setCachedDebtScore(debt);
connection.onStandingResolved(standing, debt);
if (distressed) {
connection.onDistressDetected();
}
}
Future<bool> _checkDistress() async {
final double previous = await getDataManager().getCachedDebtScore();
final int opens = await getDataManager().getEngagementCount();
final double current = await getDataManager().getCachedDebtScore();
return GuardrailEngine.detectDistress(
debtDelta: current - previous,
appOpensThisWeek: opens,
meanReadiness: 0,
);
}
void loadExcuseInsight() async {
try {
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final response =
await getDataManager().getExcuseClusters(ReportCardRequest(
periodStart: start.toIso8601String(),
periodEnd: now.toIso8601String(),
));
final List<ExcuseCluster> clusters = (response.data as List)
.map((item) => ExcuseCluster.fromJson(item))
.toList();
// Only the strongest pattern is surfaced on the home screen — a wall of
// findings reads as noise and gets ignored.
final List<ExcuseCluster> worth =
clusters.where((cluster) => cluster.insight.isNotEmpty).toList();
connection.onExcuseInsight(worth.isEmpty ? null : worth.first);
} catch (e) {
// The insight is a bonus, never a blocker — a failure here stays silent.
connection.onExcuseInsight(null);
}
}
/// The gate on creating anything new. Grounded blocks everything; Warned
/// blocks electives only.
void requestNewCommitment(Standing standing, CommitmentClass intended) async {
final bool elective = intended == CommitmentClass.Elective;
if (StandingEngine.permitsNewCommitment(standing, elective: elective)) {
return;
}
if (standing == Standing.Warned && elective) {
connection.onCreationBlocked(
"Electives are blocked while you are warned. Clear some debt first.");
return;
}
final UserDetails details = await getDataManager().getUserDetails();
connection
.onCreationBlocked(ToneEngine.standingBody(standing, details.tone));
}
}

View File

@@ -0,0 +1,8 @@
abstract class ConnectLiveTask {
void onCompleted();
/// Completion attempted before the required foreground time was reached.
void onTooEarly(int secondsRemaining);
void onAbandoned();
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Commitment.dart';
import 'LiveTaskState.dart';
class LiveTask extends StatefulWidget {
/// The commitment being run.
final Commitment commitment;
/// The goal it belongs to, shown as context in the runner and the ongoing
/// notification.
final String goalTitle;
const LiveTask({super.key, required this.commitment, this.goalTitle = ""});
@override
State<LiveTask> createState() => LiveTaskState();
}

View File

@@ -0,0 +1,456 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/LiveSession.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectLiveTask.dart';
import 'LiveTask.dart';
import 'ViewLiveTask.dart';
/// The full-screen runner. Deliberately the only thing on screen: a task you
/// are running is not a row in a list, it is the thing you are doing.
class LiveTaskState extends State<LiveTask>
with WidgetsBindingObserver
implements ConnectLiveTask {
ViewLiveTask? _model;
late LiveSession _session;
Timer? _ticker;
/// Drives the display only. The elapsed value itself is derived from
/// wall-clock, so a throttled ticker during screen-off cannot lose time.
int _tick = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_session = LiveSession(
commitmentId: widget.commitment.id ?? "",
title: widget.commitment.title,
goalTitle: widget.goalTitle,
startedAt: DateTime.now(),
requiredSeconds: widget.commitment.proofType == ProofType.Timer
? widget.commitment.proofTimerMinutes * 60
: 0,
);
_session.resume();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewLiveTask>.reactive(
viewModelBuilder: () => ViewLiveTask(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => PopScope(
// Leaving mid-run is a decision, not a back gesture.
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
_onRequestExit();
}
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: colorPrimaryDark,
body: SafeArea(child: _runnerView()),
),
),
),
);
}
void _initiate() {
_startTicker();
_model?.publishSession(_session);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
return;
}
setState(() {
_tick = _tick + 1;
});
// Refresh the lock-screen notification every 5s rather than every tick,
// so the ongoing notification stays current without thrashing.
if (_tick % 5 == 0 && _session.running) {
_model?.publishSession(_session);
}
});
}
/// Backgrounding pauses the clock — that is what makes Timer proof mean
/// something. The count is kept and shown rather than hidden.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (!_session.running) {
setState(() {
_session.resume();
});
_model?.publishSession(_session);
}
return;
}
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden) {
if (_session.running) {
setState(() {
_session.pause();
_session.backgroundedCount = _session.backgroundedCount + 1;
});
_model?.publishSession(_session);
}
}
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onTogglePause() {
setState(() {
if (_session.running) {
_session.pause();
} else {
_session.resume();
}
});
_model?.publishSession(_session);
}
void _onFinish() {
_model?.complete(widget.commitment, _session);
}
void _onRequestExit() {
_model?.showApplicationNotification(
NotificationType.warning,
"Leave this running?",
_session.satisfied()
? "You have met the requirement. You can finish it properly instead of walking away."
: "You are ${formatClock(_session.remainingSeconds())} short. Leaving now logs nothing.",
true,
true,
null,
action: "Leave anyway",
positiveAction: () {
Navigator.pop(context);
_model?.clearSession();
Navigator.pop(context, false);
},
);
}
void _onAbandon() {
final TextEditingController reason = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorBlack.withValues(alpha: 0.7),
builder: (BuildContext sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
decoration: BoxDecoration(
color: colorSheetBackground,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
text("ABANDONING MID-RUN", 9, TextType.Bold,
color: colorStandingLockdown, letterSpacing: 1.2),
const SizedBox(height: 10),
text(widget.commitment.title, 24, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 16),
inputField(
"Reason",
reason,
hint: "Why is this stopping here?",
validator: Validators.excuse,
maxLines: 3,
),
const SizedBox(height: 20),
destructiveButton("Abandon", () {
if (formKey.currentState?.validate() != true) {
return;
}
Navigator.pop(sheetContext);
_model?.abandon(widget.commitment, reason.text.trim());
}),
const SizedBox(height: 8),
Center(
child: textButton("Keep going",
() => Navigator.pop(sheetContext), textSize: 13),
),
],
),
),
),
),
);
},
);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _runnerView() {
final bool timed = _session.requiredSeconds > 0;
final bool satisfied = _session.satisfied();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_runnerHeader(),
_runnerClock(timed, satisfied),
_runnerControls(timed, satisfied),
],
),
);
}
Widget _runnerHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: _session.running ? colorPositive : colorWarning,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
text(
_session.running ? "IN PROGRESS" : "PAUSED",
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.55),
letterSpacing: 1.4,
),
],
),
iconButton(
Icon(CupertinoIcons.xmark,
size: 15, color: colorWhite.withValues(alpha: 0.7)),
_onRequestExit,
),
],
),
const SizedBox(height: 28),
if (widget.goalTitle.isNotEmpty) ...[
text(widget.goalTitle.toUpperCase(), 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.40), letterSpacing: 1.4),
const SizedBox(height: 10),
],
text(widget.commitment.title, 34, TextType.Light,
color: colorWhite, height: 1.15),
const SizedBox(height: 16),
Row(
children: [
pill(
classLabel(widget.commitment.commitmentClass),
colorWhite,
colorWhite.withValues(alpha: 0.12),
textSize: 9,
),
const SizedBox(width: 8),
pill(
proofLabel(widget.commitment.proofType),
colorWhite.withValues(alpha: 0.75),
colorWhite.withValues(alpha: 0.08),
textSize: 9,
),
],
),
],
);
}
Widget _runnerClock(bool timed, bool satisfied) {
final int elapsed = _session.elapsedSeconds();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
text(
timed ? (satisfied ? "REQUIREMENT MET" : "REMAINING") : "ELAPSED",
9,
TextType.Bold,
color: satisfied
? colorPositive
: colorWhite.withValues(alpha: 0.40),
letterSpacing: 1.6,
),
const SizedBox(height: 18),
text(
timed && !satisfied
? formatClock(_session.remainingSeconds())
: formatClock(elapsed),
78,
TextType.Light,
color: colorWhite,
height: 1.0,
),
if (timed) ...[
const SizedBox(height: 28),
meter(
_session.progress(),
fill: satisfied ? colorPositive : colorWhite,
track: colorWhite.withValues(alpha: 0.12),
height: 5,
),
const SizedBox(height: 14),
text(
satisfied
? "You can finish this now."
: "Leaving the app pauses the clock.",
12,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.45),
align: TextAlign.center,
),
],
if (_session.backgroundedCount > 0) ...[
const SizedBox(height: 18),
pill(
"Left ${_session.backgroundedCount}×",
colorWarning,
colorWarning.withValues(alpha: 0.12),
textSize: 9,
),
],
],
);
}
Widget _runnerControls(bool timed, bool satisfied) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
roundedCornerButton(
satisfied || !timed ? "Finish" : "Finish early",
_onFinish,
background: satisfied || !timed ? colorWhite : colorWhite.withValues(alpha: 0.14),
foreground: satisfied || !timed ? colorPrimaryDark : colorWhite,
icon: CupertinoIcons.checkmark,
),
const SizedBox(height: 10),
outlinedActionButton(
_session.running ? "Pause" : "Resume",
_onTogglePause,
foreground: colorWhite,
icon: _session.running
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill,
),
const SizedBox(height: 6),
Center(
child: textButton(
"Abandon this",
_onAbandon,
textSize: 12,
color: colorWhite.withValues(alpha: 0.45),
),
),
],
);
}
// ── ConnectLiveTask ───────────────────────────────────────────────────────
@override
void onCompleted() {
_model?.showApplicationNotification(
NotificationType.success,
widget.commitment.wouldBeLate ? "Late complete" : "Done",
widget.commitment.wouldBeLate
? "Recorded as a late complete — the window had already closed."
: "${formatClock(_session.elapsedSeconds())} of focused work, recorded.",
true,
true,
() {
Navigator.pop(context, true);
},
);
}
@override
void onTooEarly(int secondsRemaining) {
_model?.showApplicationNotification(
NotificationType.warning,
"Not yet",
"${formatClock(secondsRemaining)} still to go. The timer is the proof — finishing early would just be the checkbox again.",
true,
true,
null,
);
}
@override
void onAbandoned() {
Navigator.pop(context, true);
}
@override
void dispose() {
_ticker?.cancel();
WidgetsBinding.instance.removeObserver(this);
_model?.clearSession();
super.dispose();
}
}

Some files were not shown because too many files have changed in this diff Show More