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:
129
frontend/lib/Grounded/about/external/data/Commitment.dart
vendored
Normal file
129
frontend/lib/Grounded/about/external/data/Commitment.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
53
frontend/lib/Grounded/about/external/data/CommitmentEvent.dart
vendored
Normal file
53
frontend/lib/Grounded/about/external/data/CommitmentEvent.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
46
frontend/lib/Grounded/about/external/data/DebtEntry.dart
vendored
Normal file
46
frontend/lib/Grounded/about/external/data/DebtEntry.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
72
frontend/lib/Grounded/about/external/data/ExcuseCluster.dart
vendored
Normal file
72
frontend/lib/Grounded/about/external/data/ExcuseCluster.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
91
frontend/lib/Grounded/about/external/data/ExercisePrescription.dart
vendored
Normal file
91
frontend/lib/Grounded/about/external/data/ExercisePrescription.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
110
frontend/lib/Grounded/about/external/data/Goal.dart
vendored
Normal file
110
frontend/lib/Grounded/about/external/data/Goal.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
45
frontend/lib/Grounded/about/external/data/GroundedError.dart
vendored
Normal file
45
frontend/lib/Grounded/about/external/data/GroundedError.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
73
frontend/lib/Grounded/about/external/data/Habit.dart
vendored
Normal file
73
frontend/lib/Grounded/about/external/data/Habit.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
118
frontend/lib/Grounded/about/external/data/LiveSession.dart
vendored
Normal file
118
frontend/lib/Grounded/about/external/data/LiveSession.dart
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
66
frontend/lib/Grounded/about/external/data/Program.dart
vendored
Normal file
66
frontend/lib/Grounded/about/external/data/Program.dart
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
192
frontend/lib/Grounded/about/external/data/ReportCard.dart
vendored
Normal file
192
frontend/lib/Grounded/about/external/data/ReportCard.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/lib/Grounded/about/external/data/ResponseState.dart
vendored
Normal file
1
frontend/lib/Grounded/about/external/data/ResponseState.dart
vendored
Normal file
@@ -0,0 +1 @@
|
||||
enum ResponseState { Success, Failure, Pending }
|
||||
97
frontend/lib/Grounded/about/external/data/RoutineChain.dart
vendored
Normal file
97
frontend/lib/Grounded/about/external/data/RoutineChain.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
105
frontend/lib/Grounded/about/external/data/SessionLog.dart
vendored
Normal file
105
frontend/lib/Grounded/about/external/data/SessionLog.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
53
frontend/lib/Grounded/about/external/data/SessionTemplate.dart
vendored
Normal file
53
frontend/lib/Grounded/about/external/data/SessionTemplate.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
76
frontend/lib/Grounded/about/external/data/SetLog.dart
vendored
Normal file
76
frontend/lib/Grounded/about/external/data/SetLog.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/lib/Grounded/about/external/data/Severity.dart
vendored
Normal file
1
frontend/lib/Grounded/about/external/data/Severity.dart
vendored
Normal file
@@ -0,0 +1 @@
|
||||
enum Severity { warning, alert, message, error }
|
||||
41
frontend/lib/Grounded/about/external/data/StandingChange.dart
vendored
Normal file
41
frontend/lib/Grounded/about/external/data/StandingChange.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
40
frontend/lib/Grounded/about/external/data/SystemResponse.dart
vendored
Normal file
40
frontend/lib/Grounded/about/external/data/SystemResponse.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
21
frontend/lib/Grounded/about/external/data/pages/request/CommitmentsRequest.dart
vendored
Normal file
21
frontend/lib/Grounded/about/external/data/pages/request/CommitmentsRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/about/external/data/pages/request/HistoryRequest.dart
vendored
Normal file
14
frontend/lib/Grounded/about/external/data/pages/request/HistoryRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
17
frontend/lib/Grounded/about/external/data/pages/request/PageAndSort.dart
vendored
Normal file
17
frontend/lib/Grounded/about/external/data/pages/request/PageAndSort.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
20
frontend/lib/Grounded/about/external/data/pages/request/Pageable.dart
vendored
Normal file
20
frontend/lib/Grounded/about/external/data/pages/request/Pageable.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/about/external/data/pages/request/Sort.dart
vendored
Normal file
14
frontend/lib/Grounded/about/external/data/pages/request/Sort.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
47
frontend/lib/Grounded/about/external/data/pages/response/CommitmentEventPage.dart
vendored
Normal file
47
frontend/lib/Grounded/about/external/data/pages/response/CommitmentEventPage.dart
vendored
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
frontend/lib/Grounded/about/external/data/pages/response/CommitmentPage.dart
vendored
Normal file
47
frontend/lib/Grounded/about/external/data/pages/response/CommitmentPage.dart
vendored
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
frontend/lib/Grounded/about/external/data/pages/response/SessionLogPage.dart
vendored
Normal file
47
frontend/lib/Grounded/about/external/data/pages/response/SessionLogPage.dart
vendored
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
frontend/lib/Grounded/about/external/initial/AbandonRequest.dart
vendored
Normal file
16
frontend/lib/Grounded/about/external/initial/AbandonRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
13
frontend/lib/Grounded/about/external/initial/AmnestyRequest.dart
vendored
Normal file
13
frontend/lib/Grounded/about/external/initial/AmnestyRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
58
frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart
vendored
Normal file
58
frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
38
frontend/lib/Grounded/about/external/initial/CompletionRequest.dart
vendored
Normal file
38
frontend/lib/Grounded/about/external/initial/CompletionRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
27
frontend/lib/Grounded/about/external/initial/DeferralRequest.dart
vendored
Normal file
27
frontend/lib/Grounded/about/external/initial/DeferralRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
25
frontend/lib/Grounded/about/external/initial/DeviceRequest.dart
vendored
Normal file
25
frontend/lib/Grounded/about/external/initial/DeviceRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
41
frontend/lib/Grounded/about/external/initial/GoalRequest.dart
vendored
Normal file
41
frontend/lib/Grounded/about/external/initial/GoalRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
11
frontend/lib/Grounded/about/external/initial/IdRequest.dart
vendored
Normal file
11
frontend/lib/Grounded/about/external/initial/IdRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/about/external/initial/LoginData.dart
vendored
Normal file
14
frontend/lib/Grounded/about/external/initial/LoginData.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/about/external/initial/ReportCardRequest.dart
vendored
Normal file
14
frontend/lib/Grounded/about/external/initial/ReportCardRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
45
frontend/lib/Grounded/about/external/initial/SessionLogRequest.dart
vendored
Normal file
45
frontend/lib/Grounded/about/external/initial/SessionLogRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/lib/Grounded/about/external/initial/SickModeRequest.dart
vendored
Normal file
18
frontend/lib/Grounded/about/external/initial/SickModeRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
11
frontend/lib/Grounded/about/external/initial/ToneRequest.dart
vendored
Normal file
11
frontend/lib/Grounded/about/external/initial/ToneRequest.dart
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
class DialogData {
|
||||
String title;
|
||||
|
||||
String description;
|
||||
|
||||
DialogData(this.title, this.description);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain }
|
||||
@@ -0,0 +1 @@
|
||||
enum NotificationType { info, success, error, warning }
|
||||
@@ -0,0 +1,7 @@
|
||||
class Pair {
|
||||
String key;
|
||||
|
||||
dynamic value;
|
||||
|
||||
Pair(this.key, this.value);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
enum TextType {
|
||||
Bold,
|
||||
Light,
|
||||
Regular,
|
||||
Medium,
|
||||
}
|
||||
34
frontend/lib/Grounded/about/internal/application/Token.dart
Normal file
34
frontend/lib/Grounded/about/internal/application/Token.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
53
frontend/lib/Grounded/about/internal/file/FileStorage.dart
Normal file
53
frontend/lib/Grounded/about/internal/file/FileStorage.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
508
frontend/lib/Grounded/comms/Comms.dart
Normal file
508
frontend/lib/Grounded/comms/Comms.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
174
frontend/lib/Grounded/comms/CommsDirections.dart
Normal file
174
frontend/lib/Grounded/comms/CommsDirections.dart
Normal 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';
|
||||
145
frontend/lib/Grounded/comms/ConnectComms.dart
Normal file
145
frontend/lib/Grounded/comms/ConnectComms.dart
Normal 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);
|
||||
}
|
||||
7
frontend/lib/Grounded/configs/Env.dart
Normal file
7
frontend/lib/Grounded/configs/Env.dart
Normal 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: '');
|
||||
61
frontend/lib/Grounded/configs/Navigator.dart
Normal file
61
frontend/lib/Grounded/configs/Navigator.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
204
frontend/lib/Grounded/configs/NotificationServiceConfig.dart
Normal file
204
frontend/lib/Grounded/configs/NotificationServiceConfig.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
202
frontend/lib/Grounded/designs/Component.dart
Normal file
202
frontend/lib/Grounded/designs/Component.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
53
frontend/lib/Grounded/designs/Responsive.dart
Normal file
53
frontend/lib/Grounded/designs/Responsive.dart
Normal 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)],
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
287
frontend/lib/Grounded/designs/Shell.dart
Normal file
287
frontend/lib/Grounded/designs/Shell.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
178
frontend/lib/Grounded/designs/buttons/Buttons.dart
Normal file
178
frontend/lib/Grounded/designs/buttons/Buttons.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
206
frontend/lib/Grounded/designs/input/InputFields.dart
Normal file
206
frontend/lib/Grounded/designs/input/InputFields.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
39
frontend/lib/Grounded/designs/text/Text.dart
Normal file
39
frontend/lib/Grounded/designs/text/Text.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
362
frontend/lib/Grounded/informatics/AppDataManager.dart
Normal file
362
frontend/lib/Grounded/informatics/AppDataManager.dart
Normal 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);
|
||||
}
|
||||
6
frontend/lib/Grounded/informatics/DataManager.dart
Normal file
6
frontend/lib/Grounded/informatics/DataManager.dart
Normal 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 {}
|
||||
65
frontend/lib/Grounded/memory/ConnectInternalMemory.dart
Normal file
65
frontend/lib/Grounded/memory/ConnectInternalMemory.dart
Normal 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);
|
||||
}
|
||||
270
frontend/lib/Grounded/memory/InternalMemory.dart
Normal file
270
frontend/lib/Grounded/memory/InternalMemory.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/commitment/NewCommitment.dart
Normal file
10
frontend/lib/Grounded/see/commitment/NewCommitment.dart
Normal 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();
|
||||
}
|
||||
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal file
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
93
frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart
Normal file
93
frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
|
||||
abstract class ConnectExcuseReport {
|
||||
void onClustersLoaded(List<ExcuseCluster> clusters);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/excuse/ExcuseReport.dart
Normal file
10
frontend/lib/Grounded/see/excuse/ExcuseReport.dart
Normal 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();
|
||||
}
|
||||
216
frontend/lib/Grounded/see/excuse/ExcuseReportState.dart
Normal file
216
frontend/lib/Grounded/see/excuse/ExcuseReportState.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart
Normal file
37
frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
9
frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart
Normal file
9
frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart
Normal 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);
|
||||
}
|
||||
7
frontend/lib/Grounded/see/goal/ConnectGoals.dart
Normal file
7
frontend/lib/Grounded/see/goal/ConnectGoals.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectGoals {
|
||||
void onGoalsLoaded(List<Goal> goals);
|
||||
|
||||
void onGoalSaved();
|
||||
}
|
||||
13
frontend/lib/Grounded/see/goal/GoalDetail.dart
Normal file
13
frontend/lib/Grounded/see/goal/GoalDetail.dart
Normal 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();
|
||||
}
|
||||
310
frontend/lib/Grounded/see/goal/GoalDetailState.dart
Normal file
310
frontend/lib/Grounded/see/goal/GoalDetailState.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
frontend/lib/Grounded/see/goal/Goals.dart
Normal file
10
frontend/lib/Grounded/see/goal/Goals.dart
Normal 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();
|
||||
}
|
||||
311
frontend/lib/Grounded/see/goal/GoalsState.dart
Normal file
311
frontend/lib/Grounded/see/goal/GoalsState.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/goal/ViewGoalDetail.dart
Normal file
37
frontend/lib/Grounded/see/goal/ViewGoalDetail.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
51
frontend/lib/Grounded/see/goal/ViewGoals.dart
Normal file
51
frontend/lib/Grounded/see/goal/ViewGoals.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
24
frontend/lib/Grounded/see/home/ConnectHome.dart
Normal file
24
frontend/lib/Grounded/see/home/ConnectHome.dart
Normal 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);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/home/Home.dart
Normal file
10
frontend/lib/Grounded/see/home/Home.dart
Normal 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();
|
||||
}
|
||||
696
frontend/lib/Grounded/see/home/HomeState.dart
Normal file
696
frontend/lib/Grounded/see/home/HomeState.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
164
frontend/lib/Grounded/see/home/ViewHome.dart
Normal file
164
frontend/lib/Grounded/see/home/ViewHome.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
8
frontend/lib/Grounded/see/live/ConnectLiveTask.dart
Normal file
8
frontend/lib/Grounded/see/live/ConnectLiveTask.dart
Normal 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();
|
||||
}
|
||||
18
frontend/lib/Grounded/see/live/LiveTask.dart
Normal file
18
frontend/lib/Grounded/see/live/LiveTask.dart
Normal 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();
|
||||
}
|
||||
456
frontend/lib/Grounded/see/live/LiveTaskState.dart
Normal file
456
frontend/lib/Grounded/see/live/LiveTaskState.dart
Normal 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
Reference in New Issue
Block a user