Initial commit: Grounded Flutter frontend

A to-do app that doesn't believe you — an enforcement layer rather than a
neutral ledger.

Architecture ported from Autoreceptives/Frontend/Receptive: stacked MVVM with
the mandatory 4-file screen pattern, one ParentViewModel owning the loading /
network / error overlays and the handleError decision tree, one AppDataManager
gateway, dio comms carrying the three identity headers, secure storage with
random-suffixed keys, and a single-chokepoint Navigator. Package root and Dart
package name are both Grounded; org is nya.

The enforcement engine, one unit per formula in utils/:

- DebtEngine      w(class) x severity(d) x decay(t), sublinear severity so old
                  misses cannot swamp the score; abandonment 2x with 30-day
                  decay immunity; late complete retains 30%
- StandingEngine  Good -> Warned -> Grounded -> Lockdown, derived not set;
                  Grounded replaces home with the overdue queue
- CapacityEngine  blocks over-scheduling against p50 of historically completed
                  minutes, with a learned per-category estimation multiplier
- IntegrityEngine session integrity, weekly volume, plyometric contact ceiling
                  and enforced recovery gaps
- ExcuseAnalyser  on-device excuse clustering plus the confrontation copy
- GuardrailEngine distress detection and rationed amnesty
- ToneEngine      all enforcement copy, so the tone cap lives in one place

CommitmentEvent is append-only and is the source of truth rather than the
status field, which is what makes honest history and excuse analysis possible.

Goals contain commitments via parentId, and a task can be run from a
full-screen runner that derives elapsed time from wall-clock so screen-off
cannot lose time. Backgrounding pauses the clock and is counted. The runner is
mirrored into an ongoing notification, with alarm-class full-screen intents
reserved for non-negotiables.

Design language, fonts, icon and native splash are in place; Mason bricks are
retargeted to this project and verified end-to-end.

flutter analyze lib/ reports no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mfu2gQLSFN21YRBcU2NrTt
This commit is contained in:
alvocool
2026-07-27 09:11:17 +03:00
commit 16bff634b5
315 changed files with 19132 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/LiveSession.dart';
import '../../about/external/initial/AbandonRequest.dart';
import '../../about/external/initial/CompletionRequest.dart';
import '../../configs/NotificationServiceConfig.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectLiveTask.dart';
class ViewLiveTask extends ParentViewModel {
ConnectLiveTask connection;
ViewLiveTask(super.context, this.connection);
/// Mirrors the live state into the ongoing notification, so the run is
/// visible and controllable from the lock screen.
void publishSession(LiveSession session) {
LocalNotificationEngine.showSessionNotification(session);
}
void clearSession() {
LocalNotificationEngine.cancelSessionNotification();
}
/// Completion is refused until the foreground requirement is actually met.
/// The timer is the proof, so it cannot be talked past.
void complete(Commitment commitment, LiveSession session) async {
if (!session.satisfied()) {
connection.onTooEarly(session.remainingSeconds());
return;
}
if (!await hasNetwork(() => complete(commitment, session))) return;
showLoading("Recording");
try {
await getDataManager().completeCommitmentEntry(CompletionRequest(
commitmentId: commitment.id ?? "",
proofType: commitment.proofType.name,
timerSeconds: session.elapsedSeconds(),
));
clearSession();
closeLoading();
connection.onCompleted();
} catch (e) {
handleError(e, () => complete(commitment, session), () => dismissError(),
"Retry");
}
}
void abandon(Commitment commitment, String reason) async {
if (!await hasNetwork(() => abandon(commitment, reason))) return;
showLoading("Recording");
try {
await getDataManager().abandonCommitmentEntry(AbandonRequest(
commitmentId: commitment.id ?? "",
reason: reason,
));
clearSession();
closeLoading();
connection.onAbandoned();
} catch (e) {
handleError(
e, () => abandon(commitment, reason), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,5 @@
import '../../about/internal/application/UserDetails.dart';
abstract class ConnectLogin {
void onLoggedIn(UserDetails details);
}

View File

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

View File

@@ -0,0 +1,187 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/initial/LoginData.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../configs/Navigator.dart';
import '../../designs/Responsive.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Validators.dart';
import '../home/Home.dart';
import 'ConnectLogin.dart';
import 'Login.dart';
import 'ViewLogin.dart';
class LoginState extends State<Login> implements ConnectLogin {
ViewLogin? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _username = TextEditingController();
final TextEditingController _password = TextEditingController();
bool _obscured = true;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewLogin>.reactive(
viewModelBuilder: () => ViewLogin(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => Scaffold(
backgroundColor: colorPrimaryDark,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
),
);
}
void _initiate() {}
void _onToggleObscured() {
setState(() {
_obscured = !_obscured;
});
}
void _onSignIn() {
if (_formKey.currentState?.validate() != true) {
return;
}
_model?.login(LoginData(
username: _username.text.trim(),
password: _password.text,
));
}
Widget _mobileView(BoxConstraints constraints) {
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("GROUNDED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 2.0),
const SizedBox(height: 22),
text("Welcome back.", 36, TextType.Light,
color: colorWhite, height: 1.1),
const SizedBox(height: 10),
text(
"Your record has been waiting exactly where you left it.",
14,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.55),
height: 1.5,
),
],
),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorPrimaryLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
inputField(
"Username",
_username,
hint: "The one you signed up with",
validator: Validators.username,
keyboard: TextInputType.emailAddress,
icon: CupertinoIcons.person,
),
const SizedBox(height: 20),
inputField(
"Password",
_password,
hint: "Your password",
validator: Validators.password,
obscure: _obscured,
icon: CupertinoIcons.lock,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: textButton(
_obscured ? "Show password" : "Hide password",
_onToggleObscured,
textSize: 12,
),
),
const SizedBox(height: 20),
roundedCornerButton(
"Sign in",
_onSignIn,
icon: CupertinoIcons.arrow_right,
),
const SizedBox(height: 24),
Center(
child: text(
"Nothing here judges you for being away.",
12,
TextType.Regular,
color: colorGrey2,
align: TextAlign.center,
),
),
],
),
),
),
),
),
],
);
}
@override
void onLoggedIn(UserDetails details) {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const Home(), context);
}
@override
void dispose() {
_username.dispose();
_password.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,39 @@
import '../../about/external/initial/LoginData.dart';
import '../../about/internal/application/MeDescription.dart';
import '../../about/internal/application/UserDetails.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectLogin.dart';
class ViewLogin extends ParentViewModel {
ConnectLogin connection;
ViewLogin(super.context, this.connection);
void login(LoginData request) async {
if (!await hasNetwork(() => login(request))) return;
showLoading("Signing in");
try {
final response = await getDataManager().login(request);
final Map<String, dynamic> body = response.data;
await getDataManager().setMyDescription(MeDescription(
id: body['id'] ?? "",
name: body['name'] ?? "",
token: body['token'] ?? "",
));
final UserDetails details = UserDetails.fromJson(body);
await getDataManager().setUserDetails(details);
closeLoading();
connection.onLoggedIn(details);
} catch (e) {
handleError(e, () => login(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,16 @@
import '../../about/external/data/Commitment.dart';
import '../../about/internal/application/Standing.dart';
abstract class ConnectOverdueQueue {
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt);
/// The item settled, with the debt it removed from the score.
void onItemCleared(Commitment item, double reliefApplied, String verb);
/// Deferral refused — cap spent, or the class does not permit it.
void onDeferralRefused(String reason);
void onAmnestySpent(int remaining);
void onAmnestyRefused();
}

View File

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

View File

@@ -0,0 +1,691 @@
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/CompletionRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/Standing.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/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart';
import '../../utils/Thresholds.dart';
import '../../utils/Validators.dart';
import 'ConnectOverdueQueue.dart';
import 'OverdueQueue.dart';
import 'ViewOverdueQueue.dart';
class OverdueQueueState extends State<OverdueQueue>
implements ConnectOverdueQueue {
ViewOverdueQueue? _model;
List<Commitment> _queue = <Commitment>[];
Standing _standing = Standing.Good;
double _debt = 0;
bool _changed = false;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewOverdueQueue>.reactive(
viewModelBuilder: () => ViewOverdueQueue(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?.loadQueue();
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, _changed);
}
void _onComplete(Commitment item) {
// Honour proof settles immediately; everything else has to produce its
// artefact before the completion is accepted.
if (item.proofType == ProofType.Honour) {
_model?.complete(
item,
CompletionRequest(
commitmentId: item.id ?? "",
proofType: item.proofType.name,
),
);
return;
}
_openProofSheet(item);
}
void _onDefer(Commitment item) {
_openDeferralSheet(item);
}
void _onAbandon(Commitment item) {
_openAbandonSheet(item);
}
void _onAmnesty(Commitment item) {
_model?.spendAmnesty(item);
}
// ── Sheets ────────────────────────────────────────────────────────────────
/// The deferral sheet. The excuse field is the whole point of the screen —
/// free text, minimum length, no template buttons to route around it.
void _openDeferralSheet(Commitment item) {
final TextEditingController excuse = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
builder: (BuildContext sheetContext) {
return StatefulBuilder(
builder: (BuildContext sheetContext, StateSetter setSheetState) {
final int remaining =
Thresholds.maxDeferralsPerTask - item.deferralCount;
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("DEFERRING", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
text(item.title, 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 14),
text(
remaining <= 1
? "This is the last deferral this task gets. After it, the only options are completing or abandoning."
: "$remaining deferrals left on this task.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 24),
excuseField(
excuse,
Thresholds.minExcuseLength,
validator: Validators.excuse,
onChanged: (value) => setSheetState(() {}),
),
const SizedBox(height: 24),
roundedCornerButton(
"Defer with this reason",
() {
if (formKey.currentState?.validate() != true) {
return;
}
Navigator.pop(sheetContext);
_model?.defer(item, excuse.text);
},
icon: CupertinoIcons.clock,
),
const SizedBox(height: 8),
Center(
child: textButton(
"Cancel",
() => Navigator.pop(sheetContext),
textSize: 13,
),
),
],
),
),
),
),
);
},
);
},
);
}
void _openAbandonSheet(Commitment item) {
final TextEditingController reason = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
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, 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("ABANDONING", 9, TextType.Bold,
color: colorStandingLockdown, letterSpacing: 1.2),
const SizedBox(height: 10),
text(item.title, 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 14),
card(
background: colorStandingLockdownBg,
borderColor:
colorStandingLockdown.withValues(alpha: 0.20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("THE COST", 9, TextType.Bold,
color: colorStandingLockdown, letterSpacing: 1.2),
const SizedBox(height: 8),
text(
"Abandoning is the most expensive outcome there is. It costs double weight and will not decay for 30 days.",
13,
TextType.Regular,
color: colorPrimaryDark,
height: 1.5,
),
],
),
),
const SizedBox(height: 20),
inputField(
"Reason",
reason,
hint: "Why is this never happening?",
validator: Validators.excuse,
maxLines: 3,
),
const SizedBox(height: 24),
destructiveButton(
"Abandon permanently",
() {
if (formKey.currentState?.validate() != true) {
return;
}
Navigator.pop(sheetContext);
_model?.abandon(item, reason.text);
},
icon: CupertinoIcons.xmark_circle,
),
const SizedBox(height: 8),
Center(
child: textButton(
"Keep it",
() => Navigator.pop(sheetContext),
textSize: 13,
),
),
],
),
),
),
),
);
},
);
}
/// Proof types other than Honour need their artefact. The sheet states what
/// is required rather than letting the user tap a checkbox and move on.
void _openProofSheet(Commitment item) {
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("PROOF REQUIRED", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
text(proofLabel(item.proofType), 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 14),
text(
_proofDescription(item),
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 24),
roundedCornerButton(
_proofAction(item.proofType),
() {
Navigator.pop(sheetContext);
_model?.complete(
item,
CompletionRequest(
commitmentId: item.id ?? "",
proofType: item.proofType.name,
),
);
},
icon: _proofIcon(item.proofType),
),
const SizedBox(height: 8),
Center(
child: textButton(
"Not now",
() => Navigator.pop(sheetContext),
textSize: 13,
),
),
],
),
),
);
},
);
}
String _proofDescription(Commitment item) {
switch (item.proofType) {
case ProofType.Honour:
return "Your word is enough for this one.";
case ProofType.Photo:
return "Camera only — no gallery imports. The timestamp is embedded and near-duplicate submissions are flagged.";
case ProofType.Timer:
return "A foreground session of at least ${item.proofTimerMinutes} minutes. Leaving the app pauses the clock.";
case ProofType.Location:
return "You need to have actually been there. Dwell time inside the geofence counts, passing by does not.";
case ProofType.Health:
return "Your health platform has to confirm a workout inside the window.";
case ProofType.Witness:
return "Your accountability partner confirms this one.";
}
}
String _proofAction(ProofType type) {
switch (type) {
case ProofType.Honour:
return "Mark complete";
case ProofType.Photo:
return "Open camera";
case ProofType.Timer:
return "Start the timer";
case ProofType.Location:
return "Check my location";
case ProofType.Health:
return "Check health data";
case ProofType.Witness:
return "Request confirmation";
}
}
IconData _proofIcon(ProofType type) {
switch (type) {
case ProofType.Honour:
return CupertinoIcons.checkmark;
case ProofType.Photo:
return CupertinoIcons.camera_fill;
case ProofType.Timer:
return CupertinoIcons.timer;
case ProofType.Location:
return CupertinoIcons.location_fill;
case ProofType.Health:
return CupertinoIcons.heart_fill;
case ProofType.Witness:
return CupertinoIcons.person_2_fill;
}
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: "Outstanding",
title: "What you owe",
chrome: _queue.isEmpty ? colorPrimaryDark : standingColor(_standing),
onBack: _onBack,
banner: _debtBanner(),
child: _queue.isEmpty
? emptyState(
CupertinoIcons.checkmark_seal,
"Nothing outstanding",
"The queue is empty. Your standing recovers as the remaining debt decays.",
accent: colorPositive,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
sectionBreak(
"The queue",
caption: "oldest first",
trailing: pill(
"${_queue.length} / ${Thresholds.maxOpenOverdue}",
_queue.length >= Thresholds.maxOpenOverdue
? colorStandingGrounded
: colorGrey2,
_queue.length >= Thresholds.maxOpenOverdue
? colorStandingGroundedBg
: colorMuted,
textSize: 9,
),
),
..._queue.map(_queueRow),
],
),
);
}
Widget _debtBanner() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("TOTAL DEBT", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.55),
letterSpacing: 1.2),
const SizedBox(height: 6),
text(formatDebt(_debt), 32, TextType.Light, color: colorWhite),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text("STANDING", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.55),
letterSpacing: 1.2),
const SizedBox(height: 6),
text(standingLabel(_standing), 15, TextType.Medium,
color: colorWhite),
],
),
],
),
);
}
Widget _queueRow(Commitment item) {
final Color accent = classColor(item.commitmentClass);
final bool deferrable =
item.deferrableUnder(Thresholds.maxDeferralsPerTask);
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
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(item.title, 17, TextType.Medium,
color: colorPrimaryDark, maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
text(formatWindow(item), 11, TextType.Regular,
color: colorGrey2),
],
),
),
const SizedBox(width: 10),
pill(
classLabel(item.commitmentClass),
accent,
classBackground(item.commitmentClass),
textSize: 9,
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: labelled(
"Overdue",
overdueLabel(item),
valueSize: 13,
valueColor: colorStandingGrounded,
),
),
Expanded(
child: labelled(
"Costing",
formatDebt(DebtEngine.commitmentDebt(item)),
valueSize: 13,
),
),
Expanded(
child: labelled(
"Deferred",
"${item.deferralCount}/${Thresholds.maxDeferralsPerTask}",
valueSize: 13,
valueColor:
deferrable ? colorPrimaryDark : colorStandingGrounded,
),
),
],
),
hairline(margin: const EdgeInsets.symmetric(vertical: 16)),
Row(
children: [
Expanded(
child: roundedCornerButton(
"Complete",
() => _onComplete(item),
icon: CupertinoIcons.checkmark,
verticalPadding: 13,
),
),
const SizedBox(width: 8),
Expanded(
child: outlinedActionButton(
deferrable ? "Defer" : "No deferrals",
() => _onDefer(item),
enabled: deferrable &&
item.commitmentClass != CommitmentClass.NonNegotiable,
icon: CupertinoIcons.clock,
),
),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
textButton("Amnesty", () => _onAmnesty(item),
textSize: 12, color: colorGrey2),
textButton("Abandon", () => _onAbandon(item),
textSize: 12, color: colorStandingLockdown),
],
),
],
),
),
);
}
// ── ConnectOverdueQueue ───────────────────────────────────────────────────
@override
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt) {
setState(() {
_queue = queue;
_standing = standing;
_debt = debt;
});
}
@override
void onItemCleared(Commitment item, double reliefApplied, String verb) {
_changed = true;
_model?.showApplicationNotification(
verb == "Abandoned"
? NotificationType.warning
: NotificationType.success,
verb,
verb == "Late complete"
? "Recorded as a late complete. It reduces the debt but does not clear it — the miss stays in your history."
: reliefApplied > 0
? "${formatDebt(reliefApplied)} came off your debt."
: "Recorded.",
true,
true,
null,
);
}
@override
void onDeferralRefused(String reason) {
_model?.showApplicationNotification(
NotificationType.warning,
"Not deferrable",
reason,
true,
true,
null,
);
}
@override
void onAmnestySpent(int remaining) {
_changed = true;
_model?.showApplicationNotification(
NotificationType.success,
"Amnesty applied",
"No questions asked. $remaining token${remaining == 1 ? "" : "s"} left this month.",
true,
true,
null,
);
}
@override
void onAmnestyRefused() {
_model?.showApplicationNotification(
NotificationType.info,
"No tokens left",
"You have spent this month's amnesty. They reset at the start of next month.",
true,
true,
null,
);
}
}

View File

@@ -0,0 +1,190 @@
import '../../about/external/data/Commitment.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/AbandonRequest.dart';
import '../../about/external/initial/AmnestyRequest.dart';
import '../../about/external/initial/CompletionRequest.dart';
import '../../about/external/initial/DeferralRequest.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/Thresholds.dart';
import '../../utils/ToneEngine.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectOverdueQueue.dart';
class ViewOverdueQueue extends ParentViewModel {
ConnectOverdueQueue connection;
ViewOverdueQueue(super.context, this.connection);
void loadQueue() async {
if (!await hasNetwork(() => loadQueue())) return;
showLoading("Loading what you owe");
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);
final UserDetails details = await getDataManager().getUserDetails();
final double debt = DebtEngine.totalDebt(page.content);
final Standing standing = StandingEngine.evaluate(
debt,
missedNonNegotiables: DebtEngine.missedNonNegotiables(page.content),
sickMode: details.sickMode,
);
await getDataManager().setCachedDebtScore(debt);
closeLoading();
connection.onQueueLoaded(page.content, standing, debt);
} catch (e) {
handleError(e, () => loadQueue(), () => dismissError(), "Retry");
}
}
/// Completion outside the due window is recorded as a late complete, not a
/// complete. The server decides which — the client never claims one.
void complete(Commitment item, CompletionRequest request) async {
if (!await hasNetwork(() => complete(item, request))) return;
showLoading("Recording");
try {
await getDataManager().completeCommitmentEntry(request);
closeLoading();
connection.onItemCleared(
item,
DebtEngine.reliefFromClearing(item),
item.wouldBeLate ? "Late complete" : "Complete",
);
loadQueue();
} catch (e) {
handleError(
e, () => complete(item, request), () => dismissError(), "Retry");
}
}
/// The deferral gate. Non-negotiables never defer; everything else runs out
/// of deferrals, after which the only routes left are completing or
/// abandoning.
void defer(Commitment item, String excuse) async {
if (item.commitmentClass == CommitmentClass.NonNegotiable) {
connection.onDeferralRefused(ToneEngine.nonNegotiableRefused());
return;
}
if (!item.deferrableUnder(Thresholds.maxDeferralsPerTask)) {
connection.onDeferralRefused(
ToneEngine.deferralRefused(Thresholds.maxDeferralsPerTask));
return;
}
if (excuse.trim().length < Thresholds.minExcuseLength) {
connection
.onDeferralRefused(ToneEngine.excuseTooShort(Thresholds.minExcuseLength));
return;
}
if (!await hasNetwork(() => defer(item, excuse))) return;
showLoading("Recording the deferral");
try {
// The new window opens tomorrow at the same time — a deferral moves the
// window, it never removes it.
final DateTime start =
(item.dueStart ?? DateTime.now()).add(const Duration(days: 1));
final DateTime end =
(item.dueEnd ?? DateTime.now()).add(const Duration(days: 1));
await getDataManager().deferCommitmentEntry(DeferralRequest(
commitmentId: item.id ?? "",
excuseText: excuse.trim(),
newDueStart: start.toIso8601String(),
newDueEnd: end.toIso8601String(),
));
closeLoading();
connection.onItemCleared(item, 0, "Deferred");
loadQueue();
} catch (e) {
handleError(e, () => defer(item, excuse), () => dismissError(), "Retry");
}
}
/// Abandoning costs the most debt of all, and resists decay for a month.
void abandon(Commitment item, String reason) async {
if (!await hasNetwork(() => abandon(item, reason))) return;
showLoading("Recording");
try {
await getDataManager().abandonCommitmentEntry(AbandonRequest(
commitmentId: item.id ?? "",
reason: reason.trim(),
));
closeLoading();
connection.onItemCleared(item, 0, "Abandoned");
loadQueue();
} catch (e) {
handleError(
e, () => abandon(item, reason), () => dismissError(), "Retry");
}
}
/// Rationed, so a bad flu does not destroy three months of progress.
void spendAmnesty(Commitment item) async {
final int spent = await getDataManager().getAmnestySpent();
if (!GuardrailEngine.canSpendAmnesty(
Thresholds.amnestyTokensPerMonth, spent)) {
connection.onAmnestyRefused();
return;
}
if (!await hasNetwork(() => spendAmnesty(item))) return;
showLoading("Applying amnesty");
try {
await getDataManager()
.spendAmnestyToken(AmnestyRequest(commitmentId: item.id ?? ""));
await getDataManager().setAmnestySpent(spent + 1);
closeLoading();
connection.onAmnestySpent(GuardrailEngine.tokensRemaining(
Thresholds.amnestyTokensPerMonth, spent + 1));
loadQueue();
} catch (e) {
handleError(e, () => spendAmnesty(item), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,652 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../about/external/data/GroundedError.dart';
import '../../about/external/data/Severity.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/file/FileStorage.dart';
import '../../comms/Comms.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/text/Text.dart';
import '../../informatics/AppDataManager.dart';
import '../../informatics/DataManager.dart';
import '../../memory/InternalMemory.dart';
import '../../utils/Colors.dart';
import '../system/sessionexpired/SessionExpired.dart';
import '../system/updateme/UpdateMe.dart';
/// Everything cross-cutting lives here: the single data gateway, the loading,
/// network and error overlays, and the error decision tree that lets the
/// backend steer the client.
class ParentViewModel extends ChangeNotifier {
OverlayEntry? loadingEntry;
OverlayEntry? networkEntry;
OverlayEntry? errorEntry;
late DataManager dataManager;
BuildContext context;
OverlayState? overlayState;
ParentViewModel(this.context) {
overlayState = Overlay.of(context);
dataManager =
AppDataManager(InternalMemory(), Comms(InternalMemory()), FileStorage());
}
DataManager getDataManager() {
return dataManager;
}
// ── Loading ───────────────────────────────────────────────────────────────
void showLoading(String loadingText) async {
if (loadingEntry == null) {
_hideKeyboard();
loadingEntry = OverlayEntry(builder: (context) {
return Scaffold(
backgroundColor: colorPrimaryDark,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Align(
alignment: Alignment.centerLeft,
child: text(
"GROUNDED",
10,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 2.0,
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 46,
height: 46,
child: CircularProgressIndicator(
strokeWidth: 2.5,
backgroundColor: colorWhite.withValues(alpha: 0.12),
color: colorWhite,
),
),
const SizedBox(height: 32),
text(
loadingText,
30,
TextType.Light,
color: colorWhite,
align: TextAlign.center,
height: 1.15,
),
],
),
text(
"Keep your connection active",
12,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.45),
),
],
),
),
),
);
});
overlayState?.insert(loadingEntry!);
notifyListeners();
}
}
closeLoading() {
if (loadingEntry != null) {
loadingEntry?.remove();
loadingEntry = null;
notifyListeners();
}
}
// ── Network ───────────────────────────────────────────────────────────────
void noNetwork(Function() actions) async {
if (networkEntry == null) {
_hideKeyboard();
networkEntry = OverlayEntry(builder: (context) {
return Scaffold(
backgroundColor: colorPrimaryLight,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: colorPrimaryDark,
borderRadius: BorderRadius.circular(18),
),
child: Icon(CupertinoIcons.wifi_slash,
size: 26, color: colorWhite),
),
const SizedBox(height: 28),
text("CONNECTION", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
text("You are offline.", 34, TextType.Light,
color: colorPrimaryDark, height: 1.15),
const SizedBox(height: 12),
text(
"Reconnect and we will pick up where you left off. Nothing has been lost.",
14,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Try again",
actions,
icon: CupertinoIcons.refresh,
),
),
],
),
),
),
),
);
});
overlayState?.insert(networkEntry!);
notifyListeners();
}
}
closeNetwork() {
if (networkEntry != null) {
networkEntry?.remove();
networkEntry = null;
notifyListeners();
}
}
/// Guard every network call with this. Returns false and raises the offline
/// overlay wired to [actions] when there is nothing to talk to.
Future<bool> hasNetwork(Function() actions) async {
closeLoading();
List<ConnectivityResult> resultList =
await (Connectivity().checkConnectivity());
ConnectivityResult connectivityResult = ConnectivityResult.none;
if (resultList.isNotEmpty) {
connectivityResult = resultList.first;
}
if (connectivityResult == ConnectivityResult.mobile ||
connectivityResult == ConnectivityResult.wifi ||
connectivityResult == ConnectivityResult.ethernet) {
closeNetwork();
return true;
}
noNetwork(actions);
return false;
}
// ── Errors ────────────────────────────────────────────────────────────────
/// The standard catch handler. Session expiry and the server-directed
/// redirects live here, so the backend can steer the client from any screen.
handleError(Object? error, Function() actions, Function() closeAction,
String buttonName) {
closeLoading();
if (error is DioException) {
DioException dioError = error;
if (error.type == DioExceptionType.connectionTimeout) {
showError(
actions,
closeAction,
GroundedError(
code: 5000.01,
message: "An error occurred while processing your request.",
helper:
"Kindly ensure that you have a stable internet connection.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
} else if (error.type == DioExceptionType.receiveTimeout) {
showError(
actions,
closeAction,
GroundedError(
code: 5000.02,
message: "An error occurred while processing your request.",
helper:
"Kindly ensure that you have a stable internet connection.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
} else if (dioError.response?.statusCode == 401) {
sessionExpired();
} else if (dioError.response?.statusCode == 403) {
showError(
actions,
closeAction,
GroundedError(
code: 5100.00,
message:
"A connection error occurred while processing your request. Usually a result of your network security blocking the request.",
helper:
"Try using your mobile data or switching to a different network.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
} else if (dioError.response?.statusCode == 413) {
showError(actions, closeAction,
getGroundedError(dioError.response?.data), buttonName);
} else if (_isBusinessStatus(dioError.response?.statusCode)) {
_handleBusinessError(dioError, actions, closeAction, buttonName);
} else {
showError(
actions,
closeAction,
GroundedError(
code: 5500.02,
message: "An error occurred while processing your request.",
helper:
"Kindly relaunch the application and try again. If the problem persists, contact us.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
}
} else if (error is Exception) {
showError(
actions,
closeAction,
GroundedError(
code: 6000.01,
message: "An error occurred while processing your request.",
helper:
"Kindly relaunch the application and try again. If the problem persists, contact us.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
} else if (error is int) {
showError(
actions,
closeAction,
GroundedError(
code: 6000.02,
message: "An error occurred while processing your request.",
helper:
"Kindly relaunch or reinstall the application and retry below.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
} else {
showError(
actions,
closeAction,
GroundedError(
code: 8700.02,
message: "An error occurred while processing your request.",
helper:
"Kindly relaunch the application and try again. If the problem persists, contact us.",
title: "Grounded Error",
severity: Severity.message.name),
buttonName);
}
}
/// Statuses that carry an actionable error body the user should see. 500 is
/// included for services not yet migrated to the per-status scheme.
bool _isBusinessStatus(int? status) {
return status == 400 ||
status == 404 ||
status == 409 ||
status == 422 ||
status == 429 ||
status == 500;
}
/// Decodes the body and routes the app-flow control codes before falling
/// back to showing the error.
void _handleBusinessError(DioException dioError, Function() actions,
Function() closeAction, String buttonName) {
GroundedError error = getGroundedError(dioError.response?.data);
if (error.code == 5000.901) {
updateMe();
} else {
showError(actions, closeAction, error, buttonName);
}
}
void showError(Function() actions, Function() closeActions,
GroundedError error, String buttonText) async {
if (errorEntry == null) {
_hideKeyboard();
errorEntry = OverlayEntry(builder: (context) {
return Scaffold(
backgroundColor: colorPrimaryLight,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 48, 24, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("SYSTEM NOTICE", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 12),
text(error.title, 34, TextType.Light,
color: colorPrimaryDark, height: 1.15),
const SizedBox(height: 24),
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorStandingGroundedBg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
CupertinoIcons.exclamationmark_triangle_fill,
size: 20,
color: colorStandingGrounded,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("CODE", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(height: 4),
text(error.code.toString(), 17,
TextType.Medium,
color: colorPrimaryDark),
],
),
),
],
),
hairline(
margin: const EdgeInsets.symmetric(vertical: 20)),
text("WHAT HAPPENED", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(height: 6),
text(error.message, 14, TextType.Regular,
color: colorPrimaryDark, height: 1.5),
const SizedBox(height: 20),
text("WHAT TO DO", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(height: 6),
text(error.helper, 14, TextType.Regular,
color: colorPrimaryDark, height: 1.5),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: roundedCornerButton(
buttonText,
actions,
icon: CupertinoIcons.arrow_clockwise,
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: outlinedActionButton("Close", () {
dismissError();
closeActions();
}),
),
],
),
),
),
);
});
overlayState?.insert(errorEntry!);
notifyListeners();
}
}
dismissError() {
if (errorEntry != null) {
errorEntry?.remove();
errorEntry = null;
notifyListeners();
}
}
GroundedError getGroundedError(data) {
GroundedError error;
try {
error = GroundedError.fromJson(data);
} catch (e) {
error = GroundedError(
code: 900,
message: "An error occurred while processing your request.",
helper: "Kindly ensure that your internet connection is working.",
title: "Grounded Error",
severity: Severity.message.name);
}
return error;
}
// ── In-app notification ───────────────────────────────────────────────────
showApplicationNotification(
NotificationType type,
String title,
String description,
bool enableDrag,
bool barrierDismiss,
VoidCallback? closeAction, {
String? action,
VoidCallback? positiveAction,
}) async {
Color actionColor;
Color actionBgColor;
IconData actionIconData;
switch (type) {
case NotificationType.success:
actionIconData = Icons.check_circle_rounded;
actionColor = colorPositive;
actionBgColor = colorStandingGoodBg;
break;
case NotificationType.info:
actionIconData = Icons.info_rounded;
actionColor = colorPrimaryDark;
actionBgColor = colorMuted;
break;
case NotificationType.warning:
actionIconData = Icons.warning_amber_rounded;
actionColor = colorStandingWarned;
actionBgColor = colorStandingWarnedBg;
break;
case NotificationType.error:
actionIconData = Icons.error_rounded;
actionColor = colorStandingGrounded;
actionBgColor = colorStandingGroundedBg;
break;
}
final bool hasAction = action != null && positiveAction != null;
await showModalBottomSheet(
context: context,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
backgroundColor: Colors.transparent,
elevation: 0,
enableDrag: enableDrag,
isDismissible: barrierDismiss,
isScrollControlled: true,
builder: (BuildContext context) {
return Container(
decoration: BoxDecoration(
color: colorSheetBackground,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: colorGrey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(999),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: actionBgColor,
borderRadius: BorderRadius.circular(15),
),
child: Icon(actionIconData,
size: 24, color: actionColor),
),
iconButton(
Icon(Icons.close_rounded,
size: 17, color: colorGrey2),
() {
Navigator.pop(context);
closeAction?.call();
},
bordered: true,
),
],
),
const SizedBox(height: 20),
text(type.name.toUpperCase(), 9, TextType.Bold,
color: actionColor, letterSpacing: 1.2),
const SizedBox(height: 10),
text(title, 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 12),
text(description, 14, TextType.Regular,
color: colorGrey2, height: 1.55),
const SizedBox(height: 28),
if (hasAction) ...[
SizedBox(
width: double.infinity,
child: roundedCornerButton(
action,
positiveAction,
background: actionColor,
icon: actionIconData,
),
),
const SizedBox(height: 10),
],
Center(
child: textButton(
hasAction ? "Cancel" : "Dismiss",
() {
Navigator.pop(context);
closeAction?.call();
},
color: colorGrey2,
textSize: 13,
),
),
],
),
),
],
),
);
},
);
}
// ── Server-directed navigation ────────────────────────────────────────────
void sessionExpired() {
GroundedNavigation().navigateToPage(
NavigatorType.makeNewMain, const SessionExpired(), context);
}
void updateMe() {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const UpdateMe(), context);
}
_hideKeyboard() {
SystemChannels.textInput.invokeMethod('TextInput.hide');
}
@override
void dispose() {
closeLoading();
closeNetwork();
dismissError();
super.dispose();
}
}

View File

@@ -0,0 +1,5 @@
import '../../about/external/data/ReportCard.dart';
abstract class ConnectReportCardScreen {
void onReportLoaded(ReportCard report);
}

View File

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

View File

@@ -0,0 +1,374 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/ReportCard.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 'ConnectReportCardScreen.dart';
import 'ReportCardScreen.dart';
import 'ViewReportCardScreen.dart';
class ReportCardScreenState extends State<ReportCardScreen>
implements ConnectReportCardScreen {
ViewReportCardScreen? _model;
ReportCard _report = ReportCard();
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewReportCardScreen>.reactive(
viewModelBuilder: () => ViewReportCardScreen(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?.loadReport();
}
void _onBack() {
Navigator.pop(context);
}
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: "This week",
title: "Report card",
onBack: _onBack,
banner: _gradeBanner(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_report.praise.isNotEmpty) ...[
_praiseCard(),
const SizedBox(height: 24),
],
_assignedAction(),
const SizedBox(height: 28),
sectionBreak("Debt", caption: "across the week"),
_debtTrend(),
const SizedBox(height: 28),
sectionBreak("Completion", caption: "by class"),
..._report.completionByClass.entries.map(_completionRow),
const SizedBox(height: 20),
_worstHour(),
const SizedBox(height: 28),
if (_report.deferralLeaderboard.isNotEmpty) ...[
sectionBreak("Most dodged", caption: "the leaderboard"),
..._report.deferralLeaderboard.take(5).map(_deferralRow),
const SizedBox(height: 28),
],
if (_report.estimationAccuracy.isNotEmpty) ...[
sectionBreak("Your estimates", caption: "against reality"),
..._report.estimationAccuracy.entries.map(_estimationRow),
const SizedBox(height: 28),
],
if (_report.trainingAdherence > 0) ...[
sectionBreak("Training"),
_trainingCard(),
],
],
),
);
}
/// The grade sits in the chrome. Cosmetic, but it is the thing people
/// actually react to.
Widget _gradeBanner() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("PERIOD", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
const SizedBox(height: 6),
text(
"${formatDate(_report.periodStart)}${formatDate(_report.periodEnd)}",
13,
TextType.Medium,
color: colorWhite,
),
],
),
),
if (_report.grade.isNotEmpty)
Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite,
borderRadius: BorderRadius.circular(16),
),
child: text(_report.grade, 26, TextType.Light,
color: colorPrimaryDark),
),
],
),
);
}
/// Rationed but real. Only rendered when something specific was earned.
Widget _praiseCard() {
return card(
background: colorStandingGoodBg,
borderColor: colorPositive.withValues(alpha: 0.20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(CupertinoIcons.checkmark_seal_fill,
size: 18, color: colorPositive),
const SizedBox(width: 12),
Expanded(
child: text(_report.praise, 14, TextType.Regular,
color: colorPrimaryDark, height: 1.55),
),
],
),
);
}
/// One assigned action for next week. Not five.
Widget _assignedAction() {
return card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("NEXT WEEK, ONE THING", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2),
const SizedBox(height: 12),
text(
_report.assignedAction.isEmpty
? "Not enough history yet to assign anything."
: _report.assignedAction,
19,
TextType.Light,
color: colorWhite,
height: 1.4,
),
],
),
);
}
Widget _debtTrend() {
if (_report.debtTrend.isEmpty) {
return card(
child: text("No debt recorded this week.", 13, TextType.Regular,
color: colorGrey2),
);
}
final List<FlSpot> spots = <FlSpot>[];
for (int index = 0; index < _report.debtTrend.length; index++) {
spots.add(FlSpot(index.toDouble(), _report.debtTrend[index]));
}
return card(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 8),
child: SizedBox(
height: 150,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) =>
const FlLine(color: colorChartGrid, strokeWidth: 1),
),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 32)),
bottomTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: <LineChartBarData>[
LineChartBarData(
spots: spots,
isCurved: true,
barWidth: 2.5,
color: colorDebtLine,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: true, color: colorDebtFill),
),
],
),
),
),
);
}
Widget _completionRow(MapEntry<String, double> entry) {
return Container(
margin: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text(entry.key, 13, TextType.Medium, color: colorPrimaryDark),
text("${(entry.value * 100).round()}%", 13, TextType.Bold,
color: colorGrey2),
],
),
const SizedBox(height: 8),
meter(
entry.value,
fill: entry.value >= 0.8
? colorPositive
: entry.value >= 0.5
? colorStandingWarned
: colorStandingGrounded,
),
],
),
);
}
/// The recurring window where things go to die.
Widget _worstHour() {
if (_report.worstHour < 0) {
return const SizedBox.shrink();
}
return card(
background: colorStandingWarnedBg,
borderColor: colorStandingWarned.withValues(alpha: 0.20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("YOUR WORST HOUR", 9, TextType.Bold,
color: colorStandingWarned, letterSpacing: 1.2),
const SizedBox(height: 10),
text(hourLabel(_report.worstHour), 30, TextType.Light,
color: colorPrimaryDark),
const SizedBox(height: 8),
text(
"This is where things go to die. Stop scheduling anything that matters into it.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
],
),
);
}
Widget _deferralRow(DeferralCount item) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Expanded(
child: text(item.title, 13, TextType.Regular,
color: colorPrimaryDark,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
pill("${item.count}×", colorStandingGrounded,
colorStandingGroundedBg, textSize: 9),
],
),
),
);
}
/// Your estimates are wrong, and this is by how much.
Widget _estimationRow(MapEntry<String, double> entry) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Expanded(
child: text(entry.key, 13, TextType.Regular,
color: colorPrimaryDark),
),
text("${entry.value.toStringAsFixed(1)}×", 15, TextType.Bold,
color: entry.value > 1.4 ? colorStandingGrounded : colorGrey2),
],
),
),
);
}
Widget _trainingCard() {
return card(
child: Row(
children: [
Expanded(
child: labelled(
"Adherence",
"${(_report.trainingAdherence * 100).round()}%",
valueSize: 26,
valueType: TextType.Light,
),
),
Expanded(
child: labelled(
"Integrity",
"${(_report.programIntegrity * 100).round()}%",
valueSize: 26,
valueType: TextType.Light,
valueColor: _report.programIntegrity < 0.7
? colorStandingGrounded
: colorPrimaryDark,
),
),
],
),
);
}
@override
void onReportLoaded(ReportCard report) {
setState(() {
_report = report;
});
}
}

View File

@@ -0,0 +1,37 @@
import '../../about/external/data/ReportCard.dart';
import '../../about/external/initial/ReportCardRequest.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectReportCardScreen.dart';
class ViewReportCardScreen extends ParentViewModel {
ConnectReportCardScreen connection;
ViewReportCardScreen(super.context, this.connection);
void loadReport() async {
if (!await hasNetwork(() => loadReport())) return;
showLoading("Preparing your report");
try {
final DateTime now = DateTime.now();
// The week runs Monday to now, so the card always covers the week you
// are actually in rather than a trailing seven days.
final DateTime start =
now.subtract(Duration(days: now.weekday - 1));
final response =
await getDataManager().getWeeklyReportCard(ReportCardRequest(
periodStart: start.toIso8601String(),
periodEnd: now.toIso8601String(),
));
closeLoading();
connection.onReportLoaded(ReportCard.fromJson(response.data));
} catch (e) {
handleError(e, () => loadReport(), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,11 @@
import '../../about/internal/application/UserDetails.dart';
abstract class ConnectSettings {
void onUserLoaded(UserDetails details, int amnestyRemaining);
void onToneChanged(UserDetails details);
void onSickModeChanged(UserDetails details);
void onSignedOut();
}

View File

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

View File

@@ -0,0 +1,344 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/NavigatorType.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/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Thresholds.dart';
import '../../utils/Validators.dart';
import '../login/Login.dart';
import 'ConnectSettings.dart';
import 'Settings.dart';
import 'ViewSettings.dart';
class SettingsState extends State<Settings> implements ConnectSettings {
ViewSettings? _model;
UserDetails _user = UserDetails(pic: '', name: '');
int _amnestyRemaining = 0;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewSettings>.reactive(
viewModelBuilder: () => ViewSettings(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 _onBack() {
Navigator.pop(context);
}
void _onToneSelected(ToneLevel tone) {
_model?.changeTone(tone);
}
void _onToggleSickMode() {
if (_user.sickMode) {
_model?.setSickMode(false, "");
return;
}
_openSickModeSheet();
}
void _onSignOut() {
_model?.signOut();
}
void _openSickModeSheet() {
final TextEditingController reason = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
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, 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("PAUSE", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
text("Sick or travelling.", 26, TextType.Light,
color: colorPrimaryDark, height: 1.2),
const SizedBox(height: 14),
text(
"Debt stops accruing entirely while this is on. It is logged in your history, which is the only reason it stays honest.",
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 24),
inputField(
"Reason",
reason,
hint: "What is going on?",
validator: Validators.excuse,
maxLines: 3,
),
const SizedBox(height: 24),
roundedCornerButton(
"Pause everything",
() {
if (formKey.currentState?.validate() != true) {
return;
}
Navigator.pop(sheetContext);
_model?.setSickMode(true, reason.text.trim());
},
icon: CupertinoIcons.pause_fill,
),
const SizedBox(height: 8),
Center(
child: textButton(
"Cancel",
() => Navigator.pop(sheetContext),
textSize: 13,
),
),
],
),
),
),
),
);
},
);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: _user.name.isEmpty ? "Account" : _user.name,
title: "Settings",
onBack: _onBack,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
sectionBreak("Tone", caption: "how it speaks to you"),
segmentedSelector<ToneLevel>(
options: ToneLevel.values,
selected: _user.tone,
label: toneLabel,
onSelected: _onToneSelected,
),
const SizedBox(height: 12),
text(
_toneDescription(_user.tone),
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 10),
text(
"Whatever you pick, nothing here will attack you as a person. It criticises what you did, never who you are.",
11,
TextType.Regular,
color: colorGrey,
height: 1.5,
),
const SizedBox(height: 28),
sectionBreak("Amnesty", caption: "rationed on purpose"),
card(
child: Row(
children: [
Expanded(
child: labelled(
"Remaining this month",
"$_amnestyRemaining of ${Thresholds.amnestyTokensPerMonth}",
valueSize: 20,
valueType: TextType.Light,
),
),
Container(
width: 46,
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _amnestyRemaining > 0
? colorStandingGoodBg
: colorMuted,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
CupertinoIcons.checkmark_shield_fill,
size: 20,
color: _amnestyRemaining > 0 ? colorPositive : colorGrey,
),
),
],
),
),
const SizedBox(height: 10),
text(
"Tokens wipe an item's debt, no questions asked. They exist so one bad flu does not undo three months.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 28),
sectionBreak("Pause", caption: "sick or travel"),
card(
background: _user.sickMode ? colorStandingGoodBg : colorCard,
borderColor: _user.sickMode
? colorPositive.withValues(alpha: 0.20)
: colorBorder,
onTap: _onToggleSickMode,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
_user.sickMode ? "Paused" : "Running",
16,
TextType.Medium,
color: colorPrimaryDark,
),
const SizedBox(height: 6),
text(
_user.sickMode
? "Debt is not accruing. Tap to resume."
: "Debt is accruing normally. Tap to pause.",
12,
TextType.Regular,
color: colorGrey2,
),
],
),
),
Icon(
_user.sickMode
? CupertinoIcons.pause_circle_fill
: CupertinoIcons.play_circle,
size: 26,
color: _user.sickMode ? colorPositive : colorGrey2,
),
],
),
),
const SizedBox(height: 36),
outlinedActionButton(
"Sign out",
_onSignOut,
foreground: colorStandingLockdown,
icon: CupertinoIcons.square_arrow_right,
),
const SizedBox(height: 20),
Center(
child: text("Grounded 1.0.0", 11, TextType.Regular,
color: colorGrey),
),
],
),
);
}
String _toneDescription(ToneLevel tone) {
switch (tone) {
case ToneLevel.Firm:
return "Direct and unsentimental. States the facts and leaves them there.";
case ToneLevel.Strict:
return "Holds you to what you said. Disappointment rather than anger, because it works better.";
case ToneLevel.DrillSergeant:
return "Blunt and relentless about the behaviour. Still never about you.";
}
}
// ── ConnectSettings ───────────────────────────────────────────────────────
@override
void onUserLoaded(UserDetails details, int amnestyRemaining) {
setState(() {
_user = details;
_amnestyRemaining = amnestyRemaining;
});
}
@override
void onToneChanged(UserDetails details) {
setState(() {
_user = details;
});
}
@override
void onSickModeChanged(UserDetails details) {
setState(() {
_user = details;
});
}
@override
void onSignedOut() {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const Login(), context);
}
}

View File

@@ -0,0 +1,91 @@
import '../../about/external/initial/SickModeRequest.dart';
import '../../about/external/initial/ToneRequest.dart';
import '../../about/internal/application/MeDescription.dart';
import '../../about/internal/application/ToneLevel.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../utils/GuardrailEngine.dart';
import '../../utils/Thresholds.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectSettings.dart';
class ViewSettings extends ParentViewModel {
ConnectSettings connection;
ViewSettings(super.context, this.connection);
void initialise() async {
final UserDetails details = await getDataManager().getUserDetails();
final int spent = await getDataManager().getAmnestySpent();
connection.onUserLoaded(
details,
GuardrailEngine.tokensRemaining(Thresholds.amnestyTokensPerMonth, spent),
);
}
/// The tone slider is capped: it changes register, never cruelty.
void changeTone(ToneLevel tone) async {
if (!await hasNetwork(() => changeTone(tone))) return;
showLoading("Saving");
try {
await getDataManager().updateTone(ToneRequest(tone: tone.name));
final UserDetails details = await getDataManager().getUserDetails();
details.tone = tone;
await getDataManager().setUserDetails(details);
closeLoading();
connection.onToneChanged(details);
} catch (e) {
handleError(e, () => changeTone(tone), () => dismissError(), "Retry");
}
}
/// Sick mode pauses debt accrual entirely. It requires a reason and is
/// logged, so it stays honest without being punitive.
void setSickMode(bool enabled, String reason) async {
if (!await hasNetwork(() => setSickMode(enabled, reason))) return;
showLoading(enabled ? "Pausing" : "Resuming");
try {
await getDataManager().updateSickMode(SickModeRequest(
enabled: enabled,
reason: reason,
));
final UserDetails details = await getDataManager().getUserDetails();
details.sickMode = enabled;
await getDataManager().setUserDetails(details);
closeLoading();
connection.onSickModeChanged(details);
} catch (e) {
handleError(e, () => setSickMode(enabled, reason), () => dismissError(),
"Retry");
}
}
void signOut() async {
showLoading("Signing out");
try {
await getDataManager().logout();
} catch (e) {
// A failed logout call must never trap the user in the app; the local
// session is cleared either way.
}
await getDataManager()
.setMyDescription(MeDescription(id: "", name: "", token: ""));
closeLoading();
connection.onSignedOut();
}
}

View File

@@ -0,0 +1,7 @@
import '../../about/internal/application/MeDescription.dart';
abstract class ConnectSplash {
void launchHome(MeDescription value);
void launchLogin();
}

View File

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

View File

@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/MeDescription.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Responsive.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Images.dart';
import '../home/Home.dart';
import '../login/Login.dart';
import 'ConnectSplash.dart';
import 'Splash.dart';
import 'ViewSplash.dart';
class SplashState extends State<Splash> implements ConnectSplash {
ViewSplash? _model;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewSplash>.reactive(
viewModelBuilder: () => ViewSplash(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorPrimaryDark,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
),
),
);
}
Future<void> _initiate() async {
// A beat on the wordmark, then the session decides where we land.
await Future.delayed(const Duration(milliseconds: 900));
_model?.initialize();
}
Widget _mobileView(BoxConstraints constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("GROUNDED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The same mark the native splash shows, so the handover from
// the OS screen into the app is invisible.
Image.asset(
splashMark,
width: 108,
height: 108,
errorBuilder: (context, error, stackTrace) =>
const SizedBox(height: 108),
),
const SizedBox(height: 28),
text("Grounded", 52, TextType.Light,
color: colorWhite, height: 1.05),
const SizedBox(height: 16),
Container(
width: 44,
height: 2,
color: colorWhite.withValues(alpha: 0.30),
),
const SizedBox(height: 20),
text(
"A to-do app that does not believe you.",
15,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.60),
height: 1.5,
),
],
),
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: colorWhite.withValues(alpha: 0.12),
color: colorWhite.withValues(alpha: 0.70),
),
),
],
),
),
);
}
@override
void launchHome(MeDescription value) {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const Home(), context);
}
@override
void launchLogin() {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const Login(), context);
}
}

View File

@@ -0,0 +1,29 @@
import '../parent/ParentViewModel.dart';
import 'ConnectSplash.dart';
class ViewSplash extends ParentViewModel {
ConnectSplash connection;
ViewSplash(super.context, this.connection);
void initialize() {
// App opens feed the distress conjunction, so the count is bumped before
// anything else happens.
_recordEngagement();
getDataManager().getMyDescription().then((value) {
if (value.token != "") {
connection.launchHome(value);
} else {
connection.launchLogin();
}
}).onError((error, stackTrace) {
connection.launchLogin();
});
}
void _recordEngagement() async {
final int count = await getDataManager().getEngagementCount();
await getDataManager().setEngagementCount(count + 1);
}
}

View File

@@ -0,0 +1,3 @@
abstract class ConnectSessionExpired {
void launchLogin();
}

View File

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

View File

@@ -0,0 +1,117 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../../about/internal/application/NavigatorType.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../configs/Navigator.dart';
import '../../../designs/Responsive.dart';
import '../../../designs/buttons/Buttons.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import '../../login/Login.dart';
import 'ConnectSessionExpired.dart';
import 'SessionExpired.dart';
import 'ViewSessionExpired.dart';
class SessionExpiredState extends State<SessionExpired>
implements ConnectSessionExpired {
ViewSessionExpired? _model;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewSessionExpired>.reactive(
viewModelBuilder: () => ViewSessionExpired(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorPrimaryDark,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
),
),
);
}
void _initiate() {}
void _onSignIn() {
_model?.signOut();
}
Widget _mobileView(BoxConstraints constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("GROUNDED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 60,
height: 60,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(18),
),
child: Icon(CupertinoIcons.lock_fill,
size: 24, color: colorWhite),
),
const SizedBox(height: 28),
text("SESSION", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
const SizedBox(height: 10),
text("Your session has ended.", 34, TextType.Light,
color: colorWhite, height: 1.15),
const SizedBox(height: 14),
text(
"Sign in again to pick up where you left off. Your record is intact — nothing was cleared while you were away.",
14,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.60),
height: 1.55,
),
],
),
SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Sign in",
_onSignIn,
background: colorWhite,
foreground: colorPrimaryDark,
icon: CupertinoIcons.arrow_right,
),
),
],
),
),
);
}
@override
void launchLogin() {
GroundedNavigation()
.navigateToPage(NavigatorType.makeNewMain, const Login(), context);
}
}

View File

@@ -0,0 +1,18 @@
import '../../../about/internal/application/MeDescription.dart';
import '../../parent/ParentViewModel.dart';
import 'ConnectSessionExpired.dart';
class ViewSessionExpired extends ParentViewModel {
ConnectSessionExpired connection;
ViewSessionExpired(super.context, this.connection);
/// Clears the stored session before sending the user back to login, so an
/// expired token can never be replayed.
void signOut() async {
await getDataManager()
.setMyDescription(MeDescription(id: "", name: "", token: ""));
connection.launchLogin();
}
}

View File

@@ -0,0 +1,3 @@
abstract class ConnectUpdateMe {
void onStoreOpened();
}

View File

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

View File

@@ -0,0 +1,116 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../designs/Responsive.dart';
import '../../../designs/buttons/Buttons.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import 'ConnectUpdateMe.dart';
import 'UpdateMe.dart';
import 'ViewUpdateMe.dart';
class UpdateMeState extends State<UpdateMe> implements ConnectUpdateMe {
ViewUpdateMe? _model;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewUpdateMe>.reactive(
viewModelBuilder: () => ViewUpdateMe(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorPrimaryDark,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
),
),
);
}
void _initiate() {}
void _onUpdate() {
_model?.openStore();
}
Widget _mobileView(BoxConstraints constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("GROUNDED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 60,
height: 60,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(18),
),
child: Icon(CupertinoIcons.arrow_up_circle_fill,
size: 24, color: colorWhite),
),
const SizedBox(height: 28),
text("UPDATE REQUIRED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
const SizedBox(height: 10),
text("This version is out of date.", 34, TextType.Light,
color: colorWhite, height: 1.15),
const SizedBox(height: 14),
text(
"Update to continue. Your commitments, debt and history are on the server and will be waiting.",
14,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.60),
height: 1.55,
),
],
),
SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Update now",
_onUpdate,
background: colorWhite,
foreground: colorPrimaryDark,
icon: CupertinoIcons.cloud_download_fill,
),
),
],
),
),
);
}
@override
void onStoreOpened() async {
final Uri store = Uri.parse("https://grounded.app/download");
if (await canLaunchUrl(store)) {
await launchUrl(store, mode: LaunchMode.externalApplication);
}
}
}

View File

@@ -0,0 +1,12 @@
import '../../parent/ParentViewModel.dart';
import 'ConnectUpdateMe.dart';
class ViewUpdateMe extends ParentViewModel {
ConnectUpdateMe connection;
ViewUpdateMe(super.context, this.connection);
void openStore() {
connection.onStoreOpened();
}
}

View File

@@ -0,0 +1,16 @@
import '../../about/external/data/Program.dart';
import '../../about/external/data/SessionLog.dart';
import '../../about/external/data/SessionTemplate.dart';
abstract class ConnectTraining {
void onProgramLoaded(Program program, List<SessionTemplate> sessions);
void onHistoryLoaded(List<SessionLog> sessions);
/// The weekly plyometric contact ceiling has been reached — the app stops
/// you rather than pushing you.
void onContactCeilingReached(int contacts, int ceiling);
/// Not enough recovery since the last hard lower-body session.
void onRecoveryBlocked(int hoursRemaining);
}

View File

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

View File

@@ -0,0 +1,350 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Program.dart';
import '../../about/external/data/SessionLog.dart';
import '../../about/external/data/SessionTemplate.dart';
import '../../about/internal/application/NotificationType.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 '../../utils/IntegrityEngine.dart';
import 'ConnectTraining.dart';
import 'Training.dart';
import 'ViewTraining.dart';
class TrainingState extends State<Training> implements ConnectTraining {
ViewTraining? _model;
Program _program = Program();
List<SessionTemplate> _sessions = <SessionTemplate>[];
List<SessionLog> _history = <SessionLog>[];
int _contacts = 0;
bool _ceilingReached = false;
int _recoveryHoursRemaining = 0;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewTraining>.reactive(
viewModelBuilder: () => ViewTraining(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?.loadProgram();
}
void _onBack() {
Navigator.pop(context);
}
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: _program.name.isEmpty ? "No program" : _program.name,
title: "Training",
onBack: _onBack,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_ceilingReached || _recoveryHoursRemaining > 0) ...[
_stopCard(),
const SizedBox(height: 24),
],
if (_program.id == null)
emptyState(
CupertinoIcons.flame,
"No active program",
"Build a program with sessions, progression rules and scheduled deloads, and missed sessions start feeding your debt.",
)
else ...[
_programCard(),
const SizedBox(height: 28),
sectionBreak("Sessions", caption: "${_sessions.length} per cycle"),
if (_sessions.isEmpty)
text("No sessions defined yet.", 13, TextType.Regular,
color: colorGrey2)
else
..._sessions.map(_sessionRow),
const SizedBox(height: 28),
sectionBreak("Volume", caption: "this week"),
_volumeCard(),
const SizedBox(height: 28),
sectionBreak("Recent", caption: "last sessions"),
if (_history.isEmpty)
text("Nothing logged yet.", 13, TextType.Regular,
color: colorGrey2)
else
..._history.take(5).map(_historyRow),
],
],
),
);
}
/// Plyo and recovery are the two places the app refuses rather than nags.
Widget _stopCard() {
final bool ceiling = _ceilingReached;
return card(
background: colorStandingLockdownBg,
borderColor: colorStandingLockdown.withValues(alpha: 0.25),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(CupertinoIcons.hand_raised_fill,
size: 16, color: colorStandingLockdown),
const SizedBox(width: 8),
text(ceiling ? "CONTACT CEILING" : "RECOVERY", 9, TextType.Bold,
color: colorStandingLockdown, letterSpacing: 1.2),
],
),
const SizedBox(height: 12),
text(
ceiling ? "Stop plyometrics this week." : "Not recovered yet.",
22,
TextType.Light,
color: colorPrimaryDark,
),
const SizedBox(height: 10),
text(
ceiling
? "You are at $_contacts of ${_program.weeklyContactCeiling} ground contacts. Connective tissue does not recover on a motivation schedule — this is the one place the app stops you."
: "$_recoveryHoursRemaining hours left before the next hard lower-body session. Training through this is not discipline, it is a shortcut to an injury.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
],
),
);
}
Widget _programCard() {
return card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("ACTIVE PROGRAM", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2),
const SizedBox(height: 10),
text(_program.name, 24, TextType.Light, color: colorWhite),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: labelled(
"Weeks",
"${_program.weeks}",
valueSize: 18,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
Expanded(
child: labelled(
"Per week",
"${_program.sessionsPerWeek}",
valueSize: 18,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
Expanded(
child: labelled(
"Deloads",
"${_program.deloadWeeks.length}",
valueSize: 18,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
],
),
],
),
);
}
Widget _sessionRow(SessionTemplate session) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(session.name, 15, TextType.Medium,
color: colorPrimaryDark),
const SizedBox(height: 6),
text("${session.prescriptions.length} exercises", 11,
TextType.Regular, color: colorGrey2),
],
),
),
if (session.highIntensityLowerBody)
pill("HIGH LOAD", colorStandingWarned, colorStandingWarnedBg,
textSize: 9),
],
),
),
);
}
/// Volume per muscle group, not "you went to the gym".
Widget _volumeCard() {
final Map<String, int> volume =
IntegrityEngine.weeklySetsByMuscleGroup(_history);
if (volume.isEmpty) {
return card(
child: text("No sets logged this week.", 13, TextType.Regular,
color: colorGrey2),
);
}
return card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: volume.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text(entry.key, 13, TextType.Regular,
color: colorPrimaryDark),
text("${entry.value} sets", 12, TextType.Bold,
color: colorGrey2),
],
),
const SizedBox(height: 6),
meter(entry.value / 20),
],
),
);
}).toList(),
),
);
}
Widget _historyRow(SessionLog session) {
final bool watered = session.integrityScore > 0 &&
session.integrityScore < 0.7;
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(session.templateName, 14, TextType.Medium,
color: colorPrimaryDark),
const SizedBox(height: 6),
text(
"${formatDate(session.startedAt)} · ${session.sets.length} sets · RPE ${session.sessionRpe.toStringAsFixed(1)}",
11,
TextType.Regular,
color: colorGrey2,
),
],
),
),
if (session.integrityScore > 0)
pill(
"${(session.integrityScore * 100).round()}%",
watered ? colorStandingGrounded : colorPositive,
watered ? colorStandingGroundedBg : colorStandingGoodBg,
textSize: 9,
),
],
),
),
);
}
// ── ConnectTraining ───────────────────────────────────────────────────────
@override
void onProgramLoaded(Program program, List<SessionTemplate> sessions) {
setState(() {
_program = program;
_sessions = sessions;
});
}
@override
void onHistoryLoaded(List<SessionLog> sessions) {
setState(() {
_history = sessions;
});
}
@override
void onContactCeilingReached(int contacts, int ceiling) {
setState(() {
_contacts = contacts;
_ceilingReached = true;
});
_model?.showApplicationNotification(
NotificationType.warning,
"Contact ceiling reached",
"$contacts of $ceiling ground contacts this week. No more plyometrics until it resets.",
true,
true,
null,
);
}
@override
void onRecoveryBlocked(int hoursRemaining) {
setState(() {
_recoveryHoursRemaining = hoursRemaining;
});
}
}

View File

@@ -0,0 +1,129 @@
import '../../about/external/data/Program.dart';
import '../../about/external/data/SessionLog.dart';
import '../../about/external/data/SessionTemplate.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/SessionLogPage.dart';
import '../../about/external/initial/IdRequest.dart';
import '../../utils/IntegrityEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectTraining.dart';
class ViewTraining extends ParentViewModel {
ConnectTraining connection;
ViewTraining(super.context, this.connection);
void loadProgram() async {
if (!await hasNetwork(() => loadProgram())) return;
showLoading("Loading your program");
try {
final response = await getDataManager().getMyPrograms(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'active'),
page: Pageable(0, 0, 20, 0),
),
));
final List<Program> programs = getProgramList(response.data);
final Program active = programs.firstWhere(
(item) => item.active,
orElse: () => programs.isEmpty ? Program() : programs.first,
);
await getDataManager().setActiveProgram(active);
final List<SessionTemplate> sessions = await _loadSessions(active);
closeLoading();
connection.onProgramLoaded(active, sessions);
loadHistory(active);
} catch (e) {
handleError(e, () => loadProgram(), () => dismissError(), "Retry");
}
}
Future<List<SessionTemplate>> _loadSessions(Program program) async {
if (program.id == null) {
return <SessionTemplate>[];
}
final response = await getDataManager()
.getProgramSessions(IdRequest(id: program.id ?? ""));
return getSessionTemplateList(response.data);
}
void loadHistory(Program program) async {
try {
final response = await getDataManager().getSessionHistory(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'startedAt'),
page: Pageable(0, 0, 20, 0),
),
));
final SessionLogPage page = SessionLogPage.fromJson(response.data);
connection.onHistoryLoaded(page.content);
_checkLoadCeilings(program, page.content);
} catch (e) {
handleError(e, () => loadHistory(program), () => dismissError(), "Retry");
}
}
/// Plyometrics is the one modality where the app should stop you rather than
/// push you — CNS and connective tissue do not recover on a motivation
/// schedule.
void _checkLoadCeilings(Program program, List<SessionLog> history) {
final DateTime weekStart =
DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1));
final List<SessionLog> thisWeek = history
.where((session) =>
session.startedAt != null &&
session.startedAt!.isAfter(weekStart))
.toList();
final int contacts = IntegrityEngine.weeklyContacts(thisWeek);
if (IntegrityEngine.contactCeilingBreached(
thisWeek, program.weeklyContactCeiling)) {
connection.onContactCeilingReached(
contacts, program.weeklyContactCeiling);
return;
}
_checkRecovery(program, history);
}
void _checkRecovery(Program program, List<SessionLog> history) {
// The most recent hard lower-body session gates the next one.
final List<SessionLog> hard = history
.where((session) => session.sessionRpe >= 8 && session.startedAt != null)
.toList();
if (hard.isEmpty) {
return;
}
final DateTime last = hard.first.startedAt!;
if (IntegrityEngine.recoveredEnough(last, program.lowerBodyRecoveryHours)) {
return;
}
final int elapsed = DateTime.now().difference(last).inHours;
connection.onRecoveryBlocked(program.lowerBodyRecoveryHours - elapsed);
}
}