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:
@@ -0,0 +1,12 @@
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
|
||||
abstract class ConnectNewCommitment {
|
||||
void onSaved();
|
||||
|
||||
/// The plan is over capacity — the save is refused until something is cut.
|
||||
void onCapacityBlocked(CapacityVerdict verdict);
|
||||
|
||||
/// The learned multiplier for this category, so the estimate field can show
|
||||
/// what the app actually expects the task to take.
|
||||
void onMultiplierResolved(double multiplier);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/commitment/NewCommitment.dart
Normal file
10
frontend/lib/Grounded/see/commitment/NewCommitment.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'NewCommitmentState.dart';
|
||||
|
||||
class NewCommitment extends StatefulWidget {
|
||||
const NewCommitment({super.key});
|
||||
|
||||
@override
|
||||
State<NewCommitment> createState() => NewCommitmentState();
|
||||
}
|
||||
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal file
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal file
@@ -0,0 +1,552 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/initial/CommitmentRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/EnergyCost.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectNewCommitment.dart';
|
||||
import 'NewCommitment.dart';
|
||||
import 'ViewNewCommitment.dart';
|
||||
|
||||
class NewCommitmentState extends State<NewCommitment>
|
||||
implements ConnectNewCommitment {
|
||||
ViewNewCommitment? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _category = TextEditingController();
|
||||
|
||||
final TextEditingController _estimate = TextEditingController();
|
||||
|
||||
CommitmentClass _class = CommitmentClass.Standard;
|
||||
|
||||
EnergyCost _energy = EnergyCost.Medium;
|
||||
|
||||
ProofType _proof = ProofType.Honour;
|
||||
|
||||
DateTime? _windowStart;
|
||||
|
||||
DateTime? _windowEnd;
|
||||
|
||||
double _multiplier = 1.0;
|
||||
|
||||
String _windowError = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewNewCommitment>.reactive(
|
||||
viewModelBuilder: () => ViewNewCommitment(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
// A window, not a date: the default opens now and closes in two hours, so
|
||||
// the field is never left as a bare day.
|
||||
final DateTime now = DateTime.now();
|
||||
setState(() {
|
||||
_windowStart = now;
|
||||
_windowEnd = now.add(const Duration(hours: 2));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onCategoryChanged(String value) {
|
||||
_model?.resolveMultiplier(value.trim());
|
||||
}
|
||||
|
||||
void _onClassSelected(CommitmentClass value) {
|
||||
setState(() {
|
||||
_class = value;
|
||||
// Non-negotiables carry real consequences, so they default to real proof
|
||||
// rather than the honour checkbox.
|
||||
if (value == CommitmentClass.NonNegotiable &&
|
||||
_proof == ProofType.Honour) {
|
||||
_proof = ProofType.Photo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onEnergySelected(EnergyCost value) {
|
||||
setState(() {
|
||||
_energy = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onProofSelected(ProofType value) {
|
||||
setState(() {
|
||||
_proof = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickWindowStart() async {
|
||||
final DateTime? picked = await _pickMoment(_windowStart);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_windowStart = picked;
|
||||
if (_windowEnd == null || !_windowEnd!.isAfter(picked)) {
|
||||
_windowEnd = picked.add(const Duration(hours: 2));
|
||||
}
|
||||
_windowError = "";
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickWindowEnd() async {
|
||||
final DateTime? picked = await _pickMoment(_windowEnd);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_windowEnd = picked;
|
||||
_windowError = Validators.window(_windowStart, _windowEnd) ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
Future<DateTime?> _pickMoment(DateTime? initial) async {
|
||||
final DateTime base = initial ?? DateTime.now();
|
||||
|
||||
final DateTime? day = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: base,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
|
||||
if (day == null || !mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final TimeOfDay? time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(base),
|
||||
);
|
||||
|
||||
if (time == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTime(day.year, day.month, day.day, time.hour, time.minute);
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final String? windowIssue = Validators.window(_windowStart, _windowEnd);
|
||||
|
||||
if (windowIssue != null) {
|
||||
setState(() {
|
||||
_windowError = windowIssue;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_model?.save(_buildRequest(), _buildCandidate());
|
||||
}
|
||||
|
||||
CommitmentRequest _buildRequest() {
|
||||
return CommitmentRequest(
|
||||
commitmentClass: _class.name,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
dueStart: _windowStart?.toIso8601String() ?? "",
|
||||
dueEnd: _windowEnd?.toIso8601String() ?? "",
|
||||
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
|
||||
energy: _energy.name,
|
||||
proofType: _proof.name,
|
||||
proofTimerMinutes:
|
||||
_proof == ProofType.Timer ? int.tryParse(_estimate.text.trim()) ?? 0 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
Commitment _buildCandidate() {
|
||||
return Commitment(
|
||||
commitmentClass: _class,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
dueStart: _windowStart,
|
||||
dueEnd: _windowEnd,
|
||||
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
|
||||
energy: _energy,
|
||||
proofType: _proof,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final int estimate = int.tryParse(_estimate.text.trim()) ?? 0;
|
||||
|
||||
final bool multiplierWorthShowing = _multiplier > 1.15 && estimate > 0;
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "New",
|
||||
title: "Commitment",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What are you\ncommitting to?"),
|
||||
const SizedBox(height: 28),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: "Say it the way you would say it out loud",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Category",
|
||||
_category,
|
||||
hint: "Admin, gym, deep work…",
|
||||
onChanged: _onCategoryChanged,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Class", caption: "decides the weight"),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: _class,
|
||||
label: classLabel,
|
||||
onSelected: _onClassSelected,
|
||||
activeColor: classColor(_class),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
_classDescription(_class),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Window", caption: "not just a day"),
|
||||
if (_windowError.isNotEmpty) ...[
|
||||
text(_windowError, 11, TextType.Regular, color: colorNegative),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Opens",
|
||||
_windowStart == null ? "" : formatDateTime(_windowStart),
|
||||
_onPickWindowStart,
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Closes",
|
||||
_windowEnd == null ? "" : formatDateTime(_windowEnd),
|
||||
_onPickWindowEnd,
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
"When this window closes, the item goes overdue. It does not roll over to tomorrow.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Effort"),
|
||||
inputField(
|
||||
"Estimated minutes",
|
||||
_estimate,
|
||||
hint: "How long you think it takes",
|
||||
validator: Validators.estimateMinutes,
|
||||
keyboard: TextInputType.number,
|
||||
onChanged: (value) => setState(() {}),
|
||||
),
|
||||
if (multiplierWorthShowing) ...[
|
||||
const SizedBox(height: 12),
|
||||
card(
|
||||
background: colorStandingWarnedBg,
|
||||
borderColor: colorStandingWarned.withValues(alpha: 0.20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(CupertinoIcons.info_circle_fill,
|
||||
size: 15, color: colorStandingWarned),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(
|
||||
"On ${_category.text.trim().isEmpty ? "this category" : _category.text.trim()} you historically take ${_multiplier.toStringAsFixed(1)}× your estimate. Your day is planned against ${formatMinutes(estimate * _multiplier)}.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorPrimaryDark,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
text("ENERGY COST", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 10),
|
||||
segmentedSelector<EnergyCost>(
|
||||
options: EnergyCost.values,
|
||||
selected: _energy,
|
||||
label: (value) => value.name,
|
||||
onSelected: _onEnergySelected,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Proof", caption: "the checkbox is the enemy"),
|
||||
segmentedSelector<ProofType>(
|
||||
options: ProofType.values,
|
||||
selected: _proof,
|
||||
label: proofLabel,
|
||||
onSelected: _onProofSelected,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
_proofDescription(_proof),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton(
|
||||
"Commit",
|
||||
_onSave,
|
||||
icon: CupertinoIcons.checkmark,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: text(
|
||||
"You can change the details later. You cannot change the history.",
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _classDescription(CommitmentClass value) {
|
||||
switch (value) {
|
||||
case CommitmentClass.NonNegotiable:
|
||||
return "Never deferrable, heaviest debt, hardest escalation. Meds, deadlines, rent.";
|
||||
case CommitmentClass.Standard:
|
||||
return "Normal weight, two deferrals, then it is complete or abandon.";
|
||||
case CommitmentClass.Elective:
|
||||
return "No debt if you miss it. Auto-archives if it sits untouched.";
|
||||
}
|
||||
}
|
||||
|
||||
String _proofDescription(ProofType value) {
|
||||
switch (value) {
|
||||
case ProofType.Honour:
|
||||
return "A plain checkbox. Fine for trivia, worthless for anything you actually lie to yourself about.";
|
||||
case ProofType.Photo:
|
||||
return "Camera only, no gallery. Timestamped, and near-duplicate photos get flagged.";
|
||||
case ProofType.Timer:
|
||||
return "A foreground session. Backgrounding the app pauses the clock.";
|
||||
case ProofType.Location:
|
||||
return "Geofence dwell. Being near it does not count as being there.";
|
||||
case ProofType.Health:
|
||||
return "Your health platform confirms the workout happened inside the window.";
|
||||
case ProofType.Witness:
|
||||
return "Someone else confirms it. The hardest one to talk your way around.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The capacity refusal. It shows the arithmetic rather than just saying no,
|
||||
/// because the point is to make overcommitment visible.
|
||||
void _openCapacitySheet(CapacityVerdict verdict) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
text("OVER CAPACITY", 9, TextType.Bold,
|
||||
color: colorStandingWarned, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text("This day is already full.", 26, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 14),
|
||||
text(verdict.message, 14, TextType.Regular,
|
||||
color: colorGrey2, height: 1.55),
|
||||
const SizedBox(height: 24),
|
||||
card(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Planned",
|
||||
formatMinutes(verdict.projectedMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"You do",
|
||||
formatMinutes(verdict.historicalMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Cut",
|
||||
formatMinutes(verdict.excessMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorStandingGrounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text(
|
||||
"Chronic overdue is usually an overcommitment problem wearing a laziness costume. Cutting something now is the cheapest fix available.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Let me cut something",
|
||||
() => Navigator.pop(sheetContext),
|
||||
icon: CupertinoIcons.scissors,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Add it anyway",
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.saveAnyway(_buildRequest());
|
||||
},
|
||||
textSize: 12,
|
||||
color: colorGrey2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectNewCommitment ──────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onSaved() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Committed",
|
||||
"It is on the record now. The window closes ${formatDateTime(_windowEnd)}.",
|
||||
true,
|
||||
true,
|
||||
() {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onCapacityBlocked(CapacityVerdict verdict) {
|
||||
_openCapacitySheet(verdict);
|
||||
}
|
||||
|
||||
@override
|
||||
void onMultiplierResolved(double multiplier) {
|
||||
setState(() {
|
||||
_multiplier = multiplier;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_category.dispose();
|
||||
_estimate.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
93
frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart
Normal file
93
frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../about/external/data/pages/response/CommitmentPage.dart';
|
||||
import '../../about/external/initial/CommitmentRequest.dart';
|
||||
import '../../about/internal/application/CapacityProfile.dart';
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectNewCommitment.dart';
|
||||
|
||||
class ViewNewCommitment extends ParentViewModel {
|
||||
ConnectNewCommitment connection;
|
||||
|
||||
ViewNewCommitment(super.context, this.connection);
|
||||
|
||||
/// Surfaces the learned multiplier for the category so the estimate field
|
||||
/// can show what the app actually expects, rather than silently overriding.
|
||||
void resolveMultiplier(String category) async {
|
||||
final CapacityProfile profile =
|
||||
await getDataManager().getCapacityProfile();
|
||||
|
||||
connection.onMultiplierResolved(profile.multiplierFor(category));
|
||||
}
|
||||
|
||||
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
|
||||
/// as laziness, so the plan is checked against what history says actually
|
||||
/// gets done before anything is accepted.
|
||||
void save(CommitmentRequest request, Commitment candidate) async {
|
||||
if (!await hasNetwork(() => save(request, candidate))) return;
|
||||
|
||||
showLoading("Checking your day");
|
||||
|
||||
try {
|
||||
final DateTime day = candidate.dueStart ?? DateTime.now();
|
||||
|
||||
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
|
||||
day: day.toIso8601String(),
|
||||
query: PageAndSort(
|
||||
sort: Sort('asc', 'dueStart'),
|
||||
page: Pageable(0, 0, 100, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final CommitmentPage page = CommitmentPage.fromJson(response.data);
|
||||
|
||||
final CapacityProfile profile =
|
||||
await getDataManager().getCapacityProfile();
|
||||
|
||||
final List<Commitment> proposed = <Commitment>[
|
||||
...page.content,
|
||||
candidate,
|
||||
];
|
||||
|
||||
final CapacityVerdict verdict =
|
||||
CapacityEngine.check(proposed, profile, day.weekday);
|
||||
|
||||
if (verdict.blocked) {
|
||||
closeLoading();
|
||||
connection.onCapacityBlocked(verdict);
|
||||
return;
|
||||
}
|
||||
|
||||
await getDataManager().saveCommitmentEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onSaved();
|
||||
} catch (e) {
|
||||
handleError(
|
||||
e, () => save(request, candidate), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving past a capacity block, which is only reachable after the user has
|
||||
/// seen exactly how much they are over by.
|
||||
void saveAnyway(CommitmentRequest request) async {
|
||||
if (!await hasNetwork(() => saveAnyway(request))) return;
|
||||
|
||||
showLoading("Saving");
|
||||
|
||||
try {
|
||||
await getDataManager().saveCommitmentEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => saveAnyway(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
|
||||
abstract class ConnectExcuseReport {
|
||||
void onClustersLoaded(List<ExcuseCluster> clusters);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/excuse/ExcuseReport.dart
Normal file
10
frontend/lib/Grounded/see/excuse/ExcuseReport.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'ExcuseReportState.dart';
|
||||
|
||||
class ExcuseReport extends StatefulWidget {
|
||||
const ExcuseReport({super.key});
|
||||
|
||||
@override
|
||||
State<ExcuseReport> createState() => ExcuseReportState();
|
||||
}
|
||||
216
frontend/lib/Grounded/see/excuse/ExcuseReportState.dart
Normal file
216
frontend/lib/Grounded/see/excuse/ExcuseReportState.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectExcuseReport.dart';
|
||||
import 'ExcuseReport.dart';
|
||||
import 'ViewExcuseReport.dart';
|
||||
|
||||
class ExcuseReportState extends State<ExcuseReport>
|
||||
implements ConnectExcuseReport {
|
||||
ViewExcuseReport? _model;
|
||||
|
||||
List<ExcuseCluster> _clusters = <ExcuseCluster>[];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewExcuseReport>.reactive(
|
||||
viewModelBuilder: () => ViewExcuseReport(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadClusters();
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final int total = _clusters.fold(0, (sum, item) => sum + item.occurrences);
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "Last 30 days",
|
||||
title: "Excuses",
|
||||
onBack: _onBack,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What you tell\nyourself."),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"Every deferral you wrote, grouped. Read the concentrations rather than the totals — that is where the pattern is.",
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_clusters.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.text_quote,
|
||||
"Nothing to confront yet",
|
||||
"Excuses appear here once you have deferred a few things. There is no shame in an empty page.",
|
||||
accent: colorPositive,
|
||||
)
|
||||
else ...[
|
||||
card(
|
||||
background: colorPrimaryDark,
|
||||
borderColor: colorPrimaryDark,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Total excuses",
|
||||
"$total",
|
||||
valueSize: 30,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Distinct kinds",
|
||||
"${_clusters.length}",
|
||||
valueSize: 30,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
sectionBreak("The taxonomy", caption: "most frequent first"),
|
||||
..._clusters.map(_clusterCard),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _clusterCard(ExcuseCluster cluster) {
|
||||
final MapEntry<int, int>? peakDay = _peak(cluster.byWeekday);
|
||||
final MapEntry<int, int>? peakHour = _peak(cluster.byHour);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(cluster.label, 19, TextType.Light,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
pill("${cluster.occurrences}×", colorPrimaryDark, colorMuted,
|
||||
textSize: 10),
|
||||
],
|
||||
),
|
||||
if (cluster.insight.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorInset,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(CupertinoIcons.quote_bubble_fill,
|
||||
size: 14, color: colorGrey),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(cluster.insight, 13, TextType.Regular,
|
||||
color: colorPrimaryDark, height: 1.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
hairline(margin: const EdgeInsets.symmetric(vertical: 16)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Worst day",
|
||||
peakDay == null ? "—" : weekdayName(peakDay.key),
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Worst hour",
|
||||
peakHour == null ? "—" : hourLabel(peakHour.key),
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Category",
|
||||
cluster.dominantCategory.isEmpty
|
||||
? "—"
|
||||
: cluster.dominantCategory,
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
MapEntry<int, int>? _peak(Map<int, int> histogram) {
|
||||
if (histogram.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MapEntry<int, int>? peak;
|
||||
for (MapEntry<int, int> entry in histogram.entries) {
|
||||
if (peak == null || entry.value > peak.value) {
|
||||
peak = entry;
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClustersLoaded(List<ExcuseCluster> clusters) {
|
||||
setState(() {
|
||||
_clusters = clusters;
|
||||
});
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart
Normal file
37
frontend/lib/Grounded/see/excuse/ViewExcuseReport.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/initial/ReportCardRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectExcuseReport.dart';
|
||||
|
||||
class ViewExcuseReport extends ParentViewModel {
|
||||
ConnectExcuseReport connection;
|
||||
|
||||
ViewExcuseReport(super.context, this.connection);
|
||||
|
||||
void loadClusters() async {
|
||||
if (!await hasNetwork(() => loadClusters())) return;
|
||||
|
||||
showLoading("Reading your excuses");
|
||||
|
||||
try {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = now.subtract(const Duration(days: 30));
|
||||
|
||||
final response =
|
||||
await getDataManager().getExcuseClusters(ReportCardRequest(
|
||||
periodStart: start.toIso8601String(),
|
||||
periodEnd: now.toIso8601String(),
|
||||
));
|
||||
|
||||
final List<ExcuseCluster> clusters =
|
||||
getExcuseClusterList(response.data);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onClustersLoaded(clusters);
|
||||
} catch (e) {
|
||||
handleError(e, () => loadClusters(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
9
frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart
Normal file
9
frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectGoalDetail {
|
||||
void onGoalLoaded(Goal goal, List<Commitment> tasks);
|
||||
|
||||
/// The task is ready to run — hand off to the full-screen runner.
|
||||
void onTaskReady(Commitment task);
|
||||
}
|
||||
7
frontend/lib/Grounded/see/goal/ConnectGoals.dart
Normal file
7
frontend/lib/Grounded/see/goal/ConnectGoals.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectGoals {
|
||||
void onGoalsLoaded(List<Goal> goals);
|
||||
|
||||
void onGoalSaved();
|
||||
}
|
||||
13
frontend/lib/Grounded/see/goal/GoalDetail.dart
Normal file
13
frontend/lib/Grounded/see/goal/GoalDetail.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import 'GoalDetailState.dart';
|
||||
|
||||
class GoalDetail extends StatefulWidget {
|
||||
final Goal goal;
|
||||
|
||||
const GoalDetail({super.key, required this.goal});
|
||||
|
||||
@override
|
||||
State<GoalDetail> createState() => GoalDetailState();
|
||||
}
|
||||
310
frontend/lib/Grounded/see/goal/GoalDetailState.dart
Normal file
310
frontend/lib/Grounded/see/goal/GoalDetailState.dart
Normal file
@@ -0,0 +1,310 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/CommitmentStatus.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../commitment/NewCommitment.dart';
|
||||
import '../live/LiveTask.dart';
|
||||
import 'ConnectGoalDetail.dart';
|
||||
import 'GoalDetail.dart';
|
||||
import 'ViewGoalDetail.dart';
|
||||
|
||||
class GoalDetailState extends State<GoalDetail>
|
||||
implements ConnectGoalDetail {
|
||||
ViewGoalDetail? _model;
|
||||
|
||||
Goal _goal = Goal();
|
||||
|
||||
List<Commitment> _tasks = <Commitment>[];
|
||||
|
||||
bool _changed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGoalDetail>.reactive(
|
||||
viewModelBuilder: () => ViewGoalDetail(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
setState(() {
|
||||
_goal = widget.goal;
|
||||
});
|
||||
_model?.loadTasks(widget.goal);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, _changed);
|
||||
}
|
||||
|
||||
void _onStartTask(Commitment task) {
|
||||
_model?.startTask(task);
|
||||
}
|
||||
|
||||
void _onAddTask() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const NewCommitment(), context);
|
||||
|
||||
if (result == true) {
|
||||
_changed = true;
|
||||
_model?.loadTasks(_goal);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<Commitment> open = _tasks
|
||||
.where((task) =>
|
||||
task.status != CommitmentStatus.Completed &&
|
||||
task.status != CommitmentStatus.LateCompleted &&
|
||||
task.status != CommitmentStatus.Abandoned)
|
||||
.toList();
|
||||
|
||||
final List<Commitment> done = _tasks
|
||||
.where((task) =>
|
||||
task.status == CommitmentStatus.Completed ||
|
||||
task.status == CommitmentStatus.LateCompleted)
|
||||
.toList();
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "Goal",
|
||||
title: _goal.title,
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onAddTask),
|
||||
banner: _progressBanner(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_goal.description.isNotEmpty) ...[
|
||||
text(_goal.description, 15, TextType.Regular,
|
||||
color: colorGrey2, height: 1.6),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
sectionBreak("To do", caption: "${open.length} open"),
|
||||
if (open.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.square_list,
|
||||
"Nothing scheduled",
|
||||
"Add the actual sessions — Monday shoulders, Wednesday legs — and they start counting.",
|
||||
)
|
||||
else
|
||||
...open.map(_taskRow),
|
||||
if (done.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Done", caption: "${done.length}"),
|
||||
...done.map(_doneRow),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton("Add a task", _onAddTask,
|
||||
icon: CupertinoIcons.add),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressBanner() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
text("PROGRESS", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
letterSpacing: 1.2),
|
||||
text("${(_goal.progress * 100).round()}%", 13, TextType.Bold,
|
||||
color: colorWhite),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
meter(
|
||||
_goal.progress,
|
||||
fill: colorWhite,
|
||||
track: colorWhite.withValues(alpha: 0.14),
|
||||
height: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A task row leads with the action: the point of opening a goal is to start
|
||||
/// something, not to admire the list.
|
||||
Widget _taskRow(Commitment task) {
|
||||
final Color accent = classColor(task.commitmentClass);
|
||||
|
||||
final bool late = task.windowClosed;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 38,
|
||||
margin: const EdgeInsets.only(right: 14, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(task.title, 16, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 7),
|
||||
Row(
|
||||
children: [
|
||||
text(formatWindow(task), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(width: 9),
|
||||
Container(
|
||||
width: 3,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
text(formatMinutes(task.estMinutes), 11,
|
||||
TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
pill(proofLabel(task.proofType), colorGrey2, colorMuted,
|
||||
textSize: 9),
|
||||
],
|
||||
),
|
||||
if (late) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
pill(overdueLabel(task), colorStandingGrounded,
|
||||
colorStandingGroundedBg, textSize: 9),
|
||||
const SizedBox(width: 6),
|
||||
pill("−${formatDebt(DebtEngine.commitmentDebt(task))}",
|
||||
colorGrey2, colorMuted, textSize: 9),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
roundedCornerButton(
|
||||
"Start",
|
||||
() => _onStartTask(task),
|
||||
icon: CupertinoIcons.play_fill,
|
||||
verticalPadding: 13,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _doneRow(Commitment task) {
|
||||
final bool late = task.status == CommitmentStatus.LateCompleted;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: card(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
late
|
||||
? CupertinoIcons.checkmark_circle
|
||||
: CupertinoIcons.checkmark_circle_fill,
|
||||
size: 17,
|
||||
color: late ? colorStandingWarned : colorPositive,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: text(task.title, 13, TextType.Regular,
|
||||
color: colorGrey2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (late)
|
||||
pill("Late", colorStandingWarned, colorStandingWarnedBg,
|
||||
textSize: 9),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
||||
setState(() {
|
||||
_goal = goal;
|
||||
_tasks = tasks;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onTaskReady(Commitment task) async {
|
||||
// The runner takes over the whole screen — a task you are running is the
|
||||
// thing you are doing, not a row in a list.
|
||||
final result = await GroundedNavigation().navigateToPageWithData(
|
||||
LiveTask(commitment: task, goalTitle: _goal.title),
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_changed = true;
|
||||
_model?.loadTasks(_goal);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
frontend/lib/Grounded/see/goal/Goals.dart
Normal file
10
frontend/lib/Grounded/see/goal/Goals.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'GoalsState.dart';
|
||||
|
||||
class Goals extends StatefulWidget {
|
||||
const Goals({super.key});
|
||||
|
||||
@override
|
||||
State<Goals> createState() => GoalsState();
|
||||
}
|
||||
311
frontend/lib/Grounded/see/goal/GoalsState.dart
Normal file
311
frontend/lib/Grounded/see/goal/GoalsState.dart
Normal file
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/NavigatorType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGoals.dart';
|
||||
import 'GoalDetail.dart';
|
||||
import 'Goals.dart';
|
||||
import 'ViewGoals.dart';
|
||||
|
||||
class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
ViewGoals? _model;
|
||||
|
||||
List<Goal> _goals = <Goal>[];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGoals>.reactive(
|
||||
viewModelBuilder: () => ViewGoals(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _onOpenGoal(Goal goal) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GoalDetail(goal: goal), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
}
|
||||
|
||||
void _onNewGoal() {
|
||||
_openGoalSheet();
|
||||
}
|
||||
|
||||
/// Goals are lightweight on purpose — a name and a default class. The
|
||||
/// weight lives on the tasks inside them.
|
||||
void _openGoalSheet() {
|
||||
final TextEditingController title = TextEditingController();
|
||||
final TextEditingController description = TextEditingController();
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
CommitmentClass defaultClass = CommitmentClass.Standard;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext sheetContext, StateSetter setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
text("NEW GOAL", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text("What are you\nworking toward?", 26,
|
||||
TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Goal",
|
||||
title,
|
||||
hint: "Workout, thesis, get the flat sorted…",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
inputField(
|
||||
"Why it matters",
|
||||
description,
|
||||
hint: "Optional, but it helps on the bad days",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text("TASKS DEFAULT TO", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 10),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: defaultClass,
|
||||
label: classLabel,
|
||||
onSelected: (value) => setSheetState(() {
|
||||
defaultClass = value;
|
||||
}),
|
||||
activeColor: classColor(defaultClass),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Create goal",
|
||||
() {
|
||||
if (formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.save(GoalRequest(
|
||||
title: title.text.trim(),
|
||||
description: description.text.trim(),
|
||||
defaultClass: defaultClass.name,
|
||||
startDate: DateTime.now().toIso8601String(),
|
||||
));
|
||||
},
|
||||
icon: CupertinoIcons.add,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton("Cancel",
|
||||
() => Navigator.pop(sheetContext),
|
||||
textSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "Grounded",
|
||||
title: "Goals",
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onNewGoal),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What you are\nworking toward."),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"A goal holds the tasks that get you there. The goal never carries debt — the tasks inside it do.",
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_goals.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.flag,
|
||||
"No goals yet",
|
||||
"Create one — Workout, say — then put the actual sessions inside it.",
|
||||
)
|
||||
else
|
||||
..._goals.map(_goalCard),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton("New goal", _onNewGoal,
|
||||
icon: CupertinoIcons.add),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _goalCard(Goal goal) {
|
||||
final Color accent =
|
||||
goal.slipping ? colorStandingGrounded : colorPrimaryDark;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: card(
|
||||
onTap: () => _onOpenGoal(goal),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(goal.title, 19, TextType.Light,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
if (goal.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
text(goal.description, 12, TextType.Regular,
|
||||
color: colorGrey2,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (goal.overdueTasks > 0)
|
||||
pill("${goal.overdueTasks} late", colorStandingGrounded,
|
||||
colorStandingGroundedBg, textSize: 9),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
meter(goal.progress, fill: accent),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Done",
|
||||
"${goal.completedTasks} of ${goal.totalTasks}",
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Remaining",
|
||||
"${goal.remainingTasks}",
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Debt",
|
||||
formatDebt(goal.debtContribution),
|
||||
valueSize: 13,
|
||||
valueColor: goal.debtContribution > 0
|
||||
? colorStandingGrounded
|
||||
: colorPrimaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalsLoaded(List<Goal> goals) {
|
||||
setState(() {
|
||||
_goals = goals;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalSaved() {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/goal/ViewGoalDetail.dart
Normal file
37
frontend/lib/Grounded/see/goal/ViewGoalDetail.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGoalDetail.dart';
|
||||
|
||||
class ViewGoalDetail extends ParentViewModel {
|
||||
ConnectGoalDetail connection;
|
||||
|
||||
ViewGoalDetail(super.context, this.connection);
|
||||
|
||||
void loadTasks(Goal goal) async {
|
||||
if (!await hasNetwork(() => loadTasks(goal))) return;
|
||||
|
||||
showLoading("Loading ${goal.title}");
|
||||
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGoalTasks(IdRequest(id: goal.id ?? ""));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalLoaded(goal, getCommitmentList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadTasks(goal), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stashes the task as the active one before the runner opens, so the
|
||||
/// ongoing notification and any relaunch land back on the right thing.
|
||||
void startTask(Commitment task) async {
|
||||
await getDataManager().setActiveCommitment(task);
|
||||
|
||||
connection.onTaskReady(task);
|
||||
}
|
||||
}
|
||||
51
frontend/lib/Grounded/see/goal/ViewGoals.dart
Normal file
51
frontend/lib/Grounded/see/goal/ViewGoals.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGoals.dart';
|
||||
|
||||
class ViewGoals extends ParentViewModel {
|
||||
ConnectGoals connection;
|
||||
|
||||
ViewGoals(super.context, this.connection);
|
||||
|
||||
void loadGoals() async {
|
||||
if (!await hasNetwork(() => loadGoals())) return;
|
||||
|
||||
showLoading("Loading your goals");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGoals(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'startDate'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalsLoaded(getGoalList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void save(GoalRequest request) async {
|
||||
if (!await hasNetwork(() => save(request))) return;
|
||||
|
||||
showLoading("Saving");
|
||||
|
||||
try {
|
||||
await getDataManager().saveGoalEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
24
frontend/lib/Grounded/see/home/ConnectHome.dart
Normal file
24
frontend/lib/Grounded/see/home/ConnectHome.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
|
||||
abstract class ConnectHome {
|
||||
void onUserLoaded(UserDetails details);
|
||||
|
||||
void onPlanLoaded(List<Commitment> plan);
|
||||
|
||||
void onOverdueLoaded(List<Commitment> overdue);
|
||||
|
||||
/// Standing arrives derived, with the debt it was derived from.
|
||||
void onStandingResolved(Standing standing, double debtScore);
|
||||
|
||||
/// The one excuse pattern worth confronting the user with today.
|
||||
void onExcuseInsight(ExcuseCluster? cluster);
|
||||
|
||||
/// Distress detected — the strict persona drops entirely.
|
||||
void onDistressDetected();
|
||||
|
||||
/// Creating a commitment is refused at this standing.
|
||||
void onCreationBlocked(String reason);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/home/Home.dart
Normal file
10
frontend/lib/Grounded/see/home/Home.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'HomeState.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
const Home({super.key});
|
||||
|
||||
@override
|
||||
State<Home> createState() => HomeState();
|
||||
}
|
||||
696
frontend/lib/Grounded/see/home/HomeState.dart
Normal file
696
frontend/lib/Grounded/see/home/HomeState.dart
Normal file
@@ -0,0 +1,696 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/CommitmentStatus.dart';
|
||||
import '../../about/internal/application/NavigatorType.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../about/internal/application/ToneLevel.dart';
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../../utils/StandingEngine.dart';
|
||||
import '../../utils/Thresholds.dart';
|
||||
import '../../utils/ToneEngine.dart';
|
||||
import '../commitment/NewCommitment.dart';
|
||||
import '../excuse/ExcuseReport.dart';
|
||||
import '../goal/Goals.dart';
|
||||
import '../overdue/OverdueQueue.dart';
|
||||
import '../reportcard/ReportCardScreen.dart';
|
||||
import '../settings/Settings.dart';
|
||||
import '../training/Training.dart';
|
||||
import 'ConnectHome.dart';
|
||||
import 'Home.dart';
|
||||
import 'ViewHome.dart';
|
||||
|
||||
class HomeState extends State<Home> implements ConnectHome {
|
||||
ViewHome? _model;
|
||||
|
||||
UserDetails _user = UserDetails(pic: '', name: '');
|
||||
|
||||
List<Commitment> _plan = <Commitment>[];
|
||||
|
||||
List<Commitment> _overdue = <Commitment>[];
|
||||
|
||||
Standing _standing = Standing.Good;
|
||||
|
||||
double _debt = 0;
|
||||
|
||||
ExcuseCluster? _insight;
|
||||
|
||||
bool _distressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewHome>.reactive(
|
||||
viewModelBuilder: () => ViewHome(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.initialise();
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onOpenOverdue() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const OverdueQueue(), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadPlan();
|
||||
}
|
||||
}
|
||||
|
||||
void _onAddCommitment() async {
|
||||
if (!StandingEngine.permitsNewCommitment(_standing)) {
|
||||
_model?.requestNewCommitment(_standing, CommitmentClass.Standard);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const NewCommitment(), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadPlan();
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenReportCard() {
|
||||
GroundedNavigation().navigateToPage(
|
||||
NavigatorType.justOpen, const ReportCardScreen(), context);
|
||||
}
|
||||
|
||||
void _onOpenGoals() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const Goals(), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadPlan();
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenTraining() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Training(), context);
|
||||
}
|
||||
|
||||
void _onOpenSettings() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
|
||||
}
|
||||
|
||||
void _onOpenExcuses() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
// Grounded and Lockdown replace the home screen with the overdue queue —
|
||||
// you do not get to look at your nice plans, only at your mess.
|
||||
final bool queueIsHome =
|
||||
StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed;
|
||||
|
||||
return Sheet(
|
||||
eyebrow: _user.name.isEmpty ? "Grounded" : _user.name,
|
||||
title: queueIsHome ? "What you owe" : "Today",
|
||||
chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing),
|
||||
banner: _standingBanner(),
|
||||
action: chromeAction(
|
||||
CupertinoIcons.person,
|
||||
_onOpenSettings,
|
||||
dotted: _user.sickMode,
|
||||
dotColor: colorStandingWarned,
|
||||
),
|
||||
child: _distressed
|
||||
? _distressBody()
|
||||
: queueIsHome
|
||||
? _groundedBody()
|
||||
: _planBody(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The chrome carries the standing colour, so the tier is legible before a
|
||||
/// single word is read.
|
||||
Color _chromeFor(Standing standing) {
|
||||
switch (standing) {
|
||||
case Standing.Good:
|
||||
return colorPrimaryDark;
|
||||
case Standing.Warned:
|
||||
return colorPrimaryDark;
|
||||
case Standing.Grounded:
|
||||
return colorStandingGrounded;
|
||||
case Standing.Lockdown:
|
||||
return colorStandingLockdown;
|
||||
}
|
||||
}
|
||||
|
||||
/// The debt strip that sits in the black chrome under the title.
|
||||
Widget _standingBanner() {
|
||||
if (_distressed) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final Color tone = standingColor(_standing);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: _standing == Standing.Good ? tone : colorWhite,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
standingLabel(_standing).toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.75),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
ToneEngine.standingHeadline(_standing, _user.tone),
|
||||
16,
|
||||
TextType.Medium,
|
||||
color: colorWhite,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("DEBT", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
letterSpacing: 1.0),
|
||||
const SizedBox(height: 4),
|
||||
text(formatDebt(_debt), 30, TextType.Light, color: colorWhite),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The normal day: the plan, with the overdue count kept visible above it so
|
||||
/// it is never out of sight.
|
||||
Widget _planBody() {
|
||||
final int overdueCount = _overdue.length;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (overdueCount > 0) ...[
|
||||
_overdueCallout(overdueCount),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (_insight != null) ...[
|
||||
_insightCard(_insight!),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
sectionBreak(
|
||||
"The plan",
|
||||
caption: "${_plan.length} committed",
|
||||
trailing: _plan.isEmpty
|
||||
? null
|
||||
: text(formatMinutes(_plannedMinutes()), 12, TextType.Bold,
|
||||
color: colorGrey2),
|
||||
),
|
||||
if (_plan.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.square_list,
|
||||
"Nothing committed today",
|
||||
"An empty plan is a decision too. Add something you actually intend to do.",
|
||||
)
|
||||
else
|
||||
..._plan.map(_commitmentRow),
|
||||
const SizedBox(height: 28),
|
||||
_quickLinks(),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Commit to something",
|
||||
_onAddCommitment,
|
||||
icon: CupertinoIcons.add,
|
||||
enabled: StandingEngine.permitsNewCommitment(_standing),
|
||||
),
|
||||
if (!StandingEngine.permitsNewCommitment(_standing)) ...[
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
ToneEngine.standingBody(_standing, _user.tone),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Grounded: the plan is hidden entirely and only the mess is shown.
|
||||
Widget _groundedBody() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
displayTitle(
|
||||
_standing == Standing.Lockdown
|
||||
? "One at a time."
|
||||
: "Clear this first.",
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
ToneEngine.standingBody(_standing, _user.tone),
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
card(
|
||||
background: standingBackground(_standing),
|
||||
borderColor: standingColor(_standing).withValues(alpha: 0.20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Open overdue",
|
||||
"${_overdue.length}",
|
||||
valueSize: 26,
|
||||
valueType: TextType.Light,
|
||||
valueColor: standingColor(_standing),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Debt to clear",
|
||||
formatDebt(
|
||||
StandingEngine.debtToNextTierDown(_debt, _standing)),
|
||||
valueSize: 26,
|
||||
valueType: TextType.Light,
|
||||
valueColor: standingColor(_standing),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
sectionBreak("Outstanding", caption: "${_overdue.length} items"),
|
||||
if (_overdue.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.checkmark_seal,
|
||||
"The queue is empty",
|
||||
"Your standing will recover as the debt decays.",
|
||||
)
|
||||
else
|
||||
..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length)
|
||||
.map(_commitmentRow),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
_standing == Standing.Lockdown ? "Deal with this one" : "Open the queue",
|
||||
_onOpenOverdue,
|
||||
background: standingColor(_standing),
|
||||
icon: CupertinoIcons.arrow_right,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Distress: the strict persona drops entirely. This is the difference
|
||||
/// between a product people keep and one they resent.
|
||||
Widget _distressBody() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("A NOTE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
displayTitle(ToneEngine.distressHeadline()),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
ToneEngine.distressBody(),
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.6,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
card(
|
||||
background: colorStandingGoodBg,
|
||||
borderColor: colorPositive.withValues(alpha: 0.20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("PAUSED", 9, TextType.Bold,
|
||||
color: colorPositive, letterSpacing: 1.2),
|
||||
const SizedBox(height: 8),
|
||||
text("Debt is not accruing right now.", 16, TextType.Medium,
|
||||
color: colorPrimaryDark),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
"Nothing you miss this week is counting against you.",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
sectionBreak("Three things", caption: "that actually matter"),
|
||||
..._plan
|
||||
.where((item) =>
|
||||
item.commitmentClass == CommitmentClass.NonNegotiable)
|
||||
.take(3)
|
||||
.map(_commitmentRow),
|
||||
const SizedBox(height: 24),
|
||||
outlinedActionButton("Open settings", _onOpenSettings,
|
||||
icon: CupertinoIcons.slider_horizontal_3),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _overdueCallout(int count) {
|
||||
return card(
|
||||
background: colorStandingGroundedBg,
|
||||
borderColor: colorStandingGrounded.withValues(alpha: 0.20),
|
||||
onTap: _onOpenOverdue,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorStandingGrounded,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: text("$count", 17, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("OVERDUE", 9, TextType.Bold,
|
||||
color: colorStandingGrounded, letterSpacing: 1.2),
|
||||
const SizedBox(height: 5),
|
||||
text(
|
||||
count >= Thresholds.maxOpenOverdue
|
||||
? "You are at the cap. Nothing new until this drops."
|
||||
: "$count item${count == 1 ? "" : "s"} past the window.",
|
||||
14,
|
||||
TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(CupertinoIcons.chevron_right,
|
||||
size: 15, color: colorStandingGrounded),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The excuse confrontation. One pattern, stated plainly, with the
|
||||
/// suggestion attached.
|
||||
Widget _insightCard(ExcuseCluster cluster) {
|
||||
return card(
|
||||
background: colorPrimaryDark,
|
||||
borderColor: colorPrimaryDark,
|
||||
onTap: _onOpenExcuses,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
text("PATTERN", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
letterSpacing: 1.2),
|
||||
text("${cluster.occurrences}×", 11, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.45)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(cluster.insight, 15, TextType.Regular,
|
||||
color: colorWhite, height: 1.55),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _commitmentRow(Commitment item) {
|
||||
final bool late = item.windowClosed &&
|
||||
item.status != CommitmentStatus.Completed &&
|
||||
item.status != CommitmentStatus.LateCompleted;
|
||||
|
||||
final Color accent = classColor(item.commitmentClass);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||
onTap: _onOpenOverdue,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 42,
|
||||
margin: const EdgeInsets.only(right: 14, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(item.title, 15, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
text(formatWindow(item), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(width: 10),
|
||||
Container(width: 3, height: 3, decoration: BoxDecoration(
|
||||
color: colorGrey, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 10),
|
||||
text(formatMinutes(item.estMinutes), 11,
|
||||
TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
if (late) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
pill(
|
||||
overdueLabel(item),
|
||||
colorStandingGrounded,
|
||||
colorStandingGroundedBg,
|
||||
textSize: 9,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
pill(
|
||||
"−${formatDebt(DebtEngine.commitmentDebt(item))}",
|
||||
colorGrey2,
|
||||
colorMuted,
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
pill(
|
||||
classLabel(item.commitmentClass),
|
||||
accent,
|
||||
classBackground(item.commitmentClass),
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _quickLinks() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.flag_fill,
|
||||
"Goals",
|
||||
_onOpenGoals,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.chart_bar_alt_fill,
|
||||
"Report",
|
||||
_onOpenReportCard,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.flame_fill,
|
||||
"Training",
|
||||
_onOpenTraining,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _quickLink(IconData icon, String label, VoidCallback onTap) {
|
||||
return card(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 17, color: colorPrimaryDark),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(label, 13, TextType.Medium,
|
||||
color: colorPrimaryDark, maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _plannedMinutes() {
|
||||
double total = 0;
|
||||
for (Commitment item in _plan) {
|
||||
total = total + item.estMinutes;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── ConnectHome ───────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onUserLoaded(UserDetails details) {
|
||||
setState(() {
|
||||
_user = details;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onPlanLoaded(List<Commitment> plan) {
|
||||
setState(() {
|
||||
_plan = plan;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onOverdueLoaded(List<Commitment> overdue) {
|
||||
setState(() {
|
||||
_overdue = overdue;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onStandingResolved(Standing standing, double debtScore) {
|
||||
setState(() {
|
||||
_standing = standing;
|
||||
_debt = debtScore;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onExcuseInsight(ExcuseCluster? cluster) {
|
||||
setState(() {
|
||||
_insight = cluster;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onDistressDetected() {
|
||||
setState(() {
|
||||
_distressed = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onCreationBlocked(String reason) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Not right now",
|
||||
reason,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
164
frontend/lib/Grounded/see/home/ViewHome.dart
Normal file
164
frontend/lib/Grounded/see/home/ViewHome.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../about/external/data/pages/response/CommitmentPage.dart';
|
||||
import '../../about/external/initial/ReportCardRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../../utils/GuardrailEngine.dart';
|
||||
import '../../utils/StandingEngine.dart';
|
||||
import '../../utils/ToneEngine.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectHome.dart';
|
||||
|
||||
class ViewHome extends ParentViewModel {
|
||||
ConnectHome connection;
|
||||
|
||||
ViewHome(super.context, this.connection);
|
||||
|
||||
/// Loads the cached user first so the screen never opens on a spinner, then
|
||||
/// refreshes everything from the server.
|
||||
void initialise() async {
|
||||
final UserDetails cached = await getDataManager().getUserDetails();
|
||||
connection.onUserLoaded(cached);
|
||||
|
||||
loadPlan();
|
||||
}
|
||||
|
||||
void loadPlan() async {
|
||||
if (!await hasNetwork(() => loadPlan())) return;
|
||||
|
||||
showLoading("Loading your day");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('asc', 'dueStart'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final CommitmentPage page = CommitmentPage.fromJson(response.data);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onPlanLoaded(page.content);
|
||||
|
||||
loadOverdue();
|
||||
} catch (e) {
|
||||
handleError(e, () => loadPlan(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void loadOverdue() async {
|
||||
try {
|
||||
final response = await getDataManager().getOverdueQueue(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'dueEnd'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final CommitmentPage page = CommitmentPage.fromJson(response.data);
|
||||
|
||||
connection.onOverdueLoaded(page.content);
|
||||
|
||||
resolveStanding(page.content);
|
||||
|
||||
loadExcuseInsight();
|
||||
} catch (e) {
|
||||
handleError(e, () => loadOverdue(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Standing is derived on device from the same formula the server uses, so
|
||||
/// the number on screen is never stale relative to the queue beneath it.
|
||||
void resolveStanding(List<Commitment> overdue) async {
|
||||
final UserDetails details = await getDataManager().getUserDetails();
|
||||
|
||||
final bool distressed = await _checkDistress();
|
||||
|
||||
final double debt = DebtEngine.totalDebt(overdue);
|
||||
|
||||
final Standing standing = StandingEngine.evaluate(
|
||||
debt,
|
||||
missedNonNegotiables: DebtEngine.missedNonNegotiables(overdue),
|
||||
sickMode: details.sickMode,
|
||||
distressed: distressed,
|
||||
);
|
||||
|
||||
await getDataManager().setCachedDebtScore(debt);
|
||||
|
||||
connection.onStandingResolved(standing, debt);
|
||||
|
||||
if (distressed) {
|
||||
connection.onDistressDetected();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _checkDistress() async {
|
||||
final double previous = await getDataManager().getCachedDebtScore();
|
||||
final int opens = await getDataManager().getEngagementCount();
|
||||
final double current = await getDataManager().getCachedDebtScore();
|
||||
|
||||
return GuardrailEngine.detectDistress(
|
||||
debtDelta: current - previous,
|
||||
appOpensThisWeek: opens,
|
||||
meanReadiness: 0,
|
||||
);
|
||||
}
|
||||
|
||||
void loadExcuseInsight() async {
|
||||
try {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = now.subtract(const Duration(days: 30));
|
||||
|
||||
final response =
|
||||
await getDataManager().getExcuseClusters(ReportCardRequest(
|
||||
periodStart: start.toIso8601String(),
|
||||
periodEnd: now.toIso8601String(),
|
||||
));
|
||||
|
||||
final List<ExcuseCluster> clusters = (response.data as List)
|
||||
.map((item) => ExcuseCluster.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// Only the strongest pattern is surfaced on the home screen — a wall of
|
||||
// findings reads as noise and gets ignored.
|
||||
final List<ExcuseCluster> worth =
|
||||
clusters.where((cluster) => cluster.insight.isNotEmpty).toList();
|
||||
|
||||
connection.onExcuseInsight(worth.isEmpty ? null : worth.first);
|
||||
} catch (e) {
|
||||
// The insight is a bonus, never a blocker — a failure here stays silent.
|
||||
connection.onExcuseInsight(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate on creating anything new. Grounded blocks everything; Warned
|
||||
/// blocks electives only.
|
||||
void requestNewCommitment(Standing standing, CommitmentClass intended) async {
|
||||
final bool elective = intended == CommitmentClass.Elective;
|
||||
|
||||
if (StandingEngine.permitsNewCommitment(standing, elective: elective)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (standing == Standing.Warned && elective) {
|
||||
connection.onCreationBlocked(
|
||||
"Electives are blocked while you are warned. Clear some debt first.");
|
||||
return;
|
||||
}
|
||||
|
||||
final UserDetails details = await getDataManager().getUserDetails();
|
||||
|
||||
connection
|
||||
.onCreationBlocked(ToneEngine.standingBody(standing, details.tone));
|
||||
}
|
||||
}
|
||||
8
frontend/lib/Grounded/see/live/ConnectLiveTask.dart
Normal file
8
frontend/lib/Grounded/see/live/ConnectLiveTask.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
abstract class ConnectLiveTask {
|
||||
void onCompleted();
|
||||
|
||||
/// Completion attempted before the required foreground time was reached.
|
||||
void onTooEarly(int secondsRemaining);
|
||||
|
||||
void onAbandoned();
|
||||
}
|
||||
18
frontend/lib/Grounded/see/live/LiveTask.dart
Normal file
18
frontend/lib/Grounded/see/live/LiveTask.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import 'LiveTaskState.dart';
|
||||
|
||||
class LiveTask extends StatefulWidget {
|
||||
/// The commitment being run.
|
||||
final Commitment commitment;
|
||||
|
||||
/// The goal it belongs to, shown as context in the runner and the ongoing
|
||||
/// notification.
|
||||
final String goalTitle;
|
||||
|
||||
const LiveTask({super.key, required this.commitment, this.goalTitle = ""});
|
||||
|
||||
@override
|
||||
State<LiveTask> createState() => LiveTaskState();
|
||||
}
|
||||
456
frontend/lib/Grounded/see/live/LiveTaskState.dart
Normal file
456
frontend/lib/Grounded/see/live/LiveTaskState.dart
Normal file
@@ -0,0 +1,456 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/LiveSession.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectLiveTask.dart';
|
||||
import 'LiveTask.dart';
|
||||
import 'ViewLiveTask.dart';
|
||||
|
||||
/// The full-screen runner. Deliberately the only thing on screen: a task you
|
||||
/// are running is not a row in a list, it is the thing you are doing.
|
||||
class LiveTaskState extends State<LiveTask>
|
||||
with WidgetsBindingObserver
|
||||
implements ConnectLiveTask {
|
||||
ViewLiveTask? _model;
|
||||
|
||||
late LiveSession _session;
|
||||
|
||||
Timer? _ticker;
|
||||
|
||||
/// Drives the display only. The elapsed value itself is derived from
|
||||
/// wall-clock, so a throttled ticker during screen-off cannot lose time.
|
||||
int _tick = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_session = LiveSession(
|
||||
commitmentId: widget.commitment.id ?? "",
|
||||
title: widget.commitment.title,
|
||||
goalTitle: widget.goalTitle,
|
||||
startedAt: DateTime.now(),
|
||||
requiredSeconds: widget.commitment.proofType == ProofType.Timer
|
||||
? widget.commitment.proofTimerMinutes * 60
|
||||
: 0,
|
||||
);
|
||||
|
||||
_session.resume();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewLiveTask>.reactive(
|
||||
viewModelBuilder: () => ViewLiveTask(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => PopScope(
|
||||
// Leaving mid-run is a decision, not a back gesture.
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
_onRequestExit();
|
||||
}
|
||||
},
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: colorPrimaryDark,
|
||||
body: SafeArea(child: _runnerView()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_startTicker();
|
||||
_model?.publishSession(_session);
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_tick = _tick + 1;
|
||||
});
|
||||
|
||||
// Refresh the lock-screen notification every 5s rather than every tick,
|
||||
// so the ongoing notification stays current without thrashing.
|
||||
if (_tick % 5 == 0 && _session.running) {
|
||||
_model?.publishSession(_session);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Backgrounding pauses the clock — that is what makes Timer proof mean
|
||||
/// something. The count is kept and shown rather than hidden.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (!_session.running) {
|
||||
setState(() {
|
||||
_session.resume();
|
||||
});
|
||||
_model?.publishSession(_session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
if (_session.running) {
|
||||
setState(() {
|
||||
_session.pause();
|
||||
_session.backgroundedCount = _session.backgroundedCount + 1;
|
||||
});
|
||||
_model?.publishSession(_session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onTogglePause() {
|
||||
setState(() {
|
||||
if (_session.running) {
|
||||
_session.pause();
|
||||
} else {
|
||||
_session.resume();
|
||||
}
|
||||
});
|
||||
_model?.publishSession(_session);
|
||||
}
|
||||
|
||||
void _onFinish() {
|
||||
_model?.complete(widget.commitment, _session);
|
||||
}
|
||||
|
||||
void _onRequestExit() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Leave this running?",
|
||||
_session.satisfied()
|
||||
? "You have met the requirement. You can finish it properly instead of walking away."
|
||||
: "You are ${formatClock(_session.remainingSeconds())} short. Leaving now logs nothing.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: "Leave anyway",
|
||||
positiveAction: () {
|
||||
Navigator.pop(context);
|
||||
_model?.clearSession();
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onAbandon() {
|
||||
final TextEditingController reason = TextEditingController();
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.7),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
text("ABANDONING MID-RUN", 9, TextType.Bold,
|
||||
color: colorStandingLockdown, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(widget.commitment.title, 24, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Reason",
|
||||
reason,
|
||||
hint: "Why is this stopping here?",
|
||||
validator: Validators.excuse,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
destructiveButton("Abandon", () {
|
||||
if (formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.abandon(widget.commitment, reason.text.trim());
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton("Keep going",
|
||||
() => Navigator.pop(sheetContext), textSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _runnerView() {
|
||||
final bool timed = _session.requiredSeconds > 0;
|
||||
|
||||
final bool satisfied = _session.satisfied();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_runnerHeader(),
|
||||
_runnerClock(timed, satisfied),
|
||||
_runnerControls(timed, satisfied),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runnerHeader() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: _session.running ? colorPositive : colorWarning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
_session.running ? "IN PROGRESS" : "PAUSED",
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.55),
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
],
|
||||
),
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark,
|
||||
size: 15, color: colorWhite.withValues(alpha: 0.7)),
|
||||
_onRequestExit,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (widget.goalTitle.isNotEmpty) ...[
|
||||
text(widget.goalTitle.toUpperCase(), 10, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.40), letterSpacing: 1.4),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
text(widget.commitment.title, 34, TextType.Light,
|
||||
color: colorWhite, height: 1.15),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
pill(
|
||||
classLabel(widget.commitment.commitmentClass),
|
||||
colorWhite,
|
||||
colorWhite.withValues(alpha: 0.12),
|
||||
textSize: 9,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
pill(
|
||||
proofLabel(widget.commitment.proofType),
|
||||
colorWhite.withValues(alpha: 0.75),
|
||||
colorWhite.withValues(alpha: 0.08),
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runnerClock(bool timed, bool satisfied) {
|
||||
final int elapsed = _session.elapsedSeconds();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
text(
|
||||
timed ? (satisfied ? "REQUIREMENT MET" : "REMAINING") : "ELAPSED",
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: satisfied
|
||||
? colorPositive
|
||||
: colorWhite.withValues(alpha: 0.40),
|
||||
letterSpacing: 1.6,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
text(
|
||||
timed && !satisfied
|
||||
? formatClock(_session.remainingSeconds())
|
||||
: formatClock(elapsed),
|
||||
78,
|
||||
TextType.Light,
|
||||
color: colorWhite,
|
||||
height: 1.0,
|
||||
),
|
||||
if (timed) ...[
|
||||
const SizedBox(height: 28),
|
||||
meter(
|
||||
_session.progress(),
|
||||
fill: satisfied ? colorPositive : colorWhite,
|
||||
track: colorWhite.withValues(alpha: 0.12),
|
||||
height: 5,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
satisfied
|
||||
? "You can finish this now."
|
||||
: "Leaving the app pauses the clock.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (_session.backgroundedCount > 0) ...[
|
||||
const SizedBox(height: 18),
|
||||
pill(
|
||||
"Left ${_session.backgroundedCount}×",
|
||||
colorWarning,
|
||||
colorWarning.withValues(alpha: 0.12),
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runnerControls(bool timed, bool satisfied) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
roundedCornerButton(
|
||||
satisfied || !timed ? "Finish" : "Finish early",
|
||||
_onFinish,
|
||||
background: satisfied || !timed ? colorWhite : colorWhite.withValues(alpha: 0.14),
|
||||
foreground: satisfied || !timed ? colorPrimaryDark : colorWhite,
|
||||
icon: CupertinoIcons.checkmark,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
outlinedActionButton(
|
||||
_session.running ? "Pause" : "Resume",
|
||||
_onTogglePause,
|
||||
foreground: colorWhite,
|
||||
icon: _session.running
|
||||
? CupertinoIcons.pause_fill
|
||||
: CupertinoIcons.play_fill,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Abandon this",
|
||||
_onAbandon,
|
||||
textSize: 12,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectLiveTask ───────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onCompleted() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
widget.commitment.wouldBeLate ? "Late complete" : "Done",
|
||||
widget.commitment.wouldBeLate
|
||||
? "Recorded as a late complete — the window had already closed."
|
||||
: "${formatClock(_session.elapsedSeconds())} of focused work, recorded.",
|
||||
true,
|
||||
true,
|
||||
() {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onTooEarly(int secondsRemaining) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Not yet",
|
||||
"${formatClock(secondsRemaining)} still to go. The timer is the proof — finishing early would just be the checkbox again.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onAbandoned() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_model?.clearSession();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
75
frontend/lib/Grounded/see/live/ViewLiveTask.dart
Normal file
75
frontend/lib/Grounded/see/live/ViewLiveTask.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
5
frontend/lib/Grounded/see/login/ConnectLogin.dart
Normal file
5
frontend/lib/Grounded/see/login/ConnectLogin.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
|
||||
abstract class ConnectLogin {
|
||||
void onLoggedIn(UserDetails details);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/login/Login.dart
Normal file
10
frontend/lib/Grounded/see/login/Login.dart
Normal 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();
|
||||
}
|
||||
187
frontend/lib/Grounded/see/login/LoginState.dart
Normal file
187
frontend/lib/Grounded/see/login/LoginState.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
39
frontend/lib/Grounded/see/login/ViewLogin.dart
Normal file
39
frontend/lib/Grounded/see/login/ViewLogin.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/lib/Grounded/see/overdue/ConnectOverdueQueue.dart
Normal file
16
frontend/lib/Grounded/see/overdue/ConnectOverdueQueue.dart
Normal 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();
|
||||
}
|
||||
10
frontend/lib/Grounded/see/overdue/OverdueQueue.dart
Normal file
10
frontend/lib/Grounded/see/overdue/OverdueQueue.dart
Normal 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();
|
||||
}
|
||||
691
frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
Normal file
691
frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
190
frontend/lib/Grounded/see/overdue/ViewOverdueQueue.dart
Normal file
190
frontend/lib/Grounded/see/overdue/ViewOverdueQueue.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
652
frontend/lib/Grounded/see/parent/ParentViewModel.dart
Normal file
652
frontend/lib/Grounded/see/parent/ParentViewModel.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
|
||||
abstract class ConnectReportCardScreen {
|
||||
void onReportLoaded(ReportCard report);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/reportcard/ReportCardScreen.dart
Normal file
10
frontend/lib/Grounded/see/reportcard/ReportCardScreen.dart
Normal 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();
|
||||
}
|
||||
374
frontend/lib/Grounded/see/reportcard/ReportCardScreenState.dart
Normal file
374
frontend/lib/Grounded/see/reportcard/ReportCardScreenState.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
frontend/lib/Grounded/see/settings/ConnectSettings.dart
Normal file
11
frontend/lib/Grounded/see/settings/ConnectSettings.dart
Normal 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();
|
||||
}
|
||||
10
frontend/lib/Grounded/see/settings/Settings.dart
Normal file
10
frontend/lib/Grounded/see/settings/Settings.dart
Normal 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();
|
||||
}
|
||||
344
frontend/lib/Grounded/see/settings/SettingsState.dart
Normal file
344
frontend/lib/Grounded/see/settings/SettingsState.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
91
frontend/lib/Grounded/see/settings/ViewSettings.dart
Normal file
91
frontend/lib/Grounded/see/settings/ViewSettings.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
7
frontend/lib/Grounded/see/splash/ConnectSplash.dart
Normal file
7
frontend/lib/Grounded/see/splash/ConnectSplash.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../about/internal/application/MeDescription.dart';
|
||||
|
||||
abstract class ConnectSplash {
|
||||
void launchHome(MeDescription value);
|
||||
|
||||
void launchLogin();
|
||||
}
|
||||
10
frontend/lib/Grounded/see/splash/Splash.dart
Normal file
10
frontend/lib/Grounded/see/splash/Splash.dart
Normal 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();
|
||||
}
|
||||
124
frontend/lib/Grounded/see/splash/SplashState.dart
Normal file
124
frontend/lib/Grounded/see/splash/SplashState.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
29
frontend/lib/Grounded/see/splash/ViewSplash.dart
Normal file
29
frontend/lib/Grounded/see/splash/ViewSplash.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class ConnectSessionExpired {
|
||||
void launchLogin();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class ConnectUpdateMe {
|
||||
void onStoreOpened();
|
||||
}
|
||||
10
frontend/lib/Grounded/see/system/updateme/UpdateMe.dart
Normal file
10
frontend/lib/Grounded/see/system/updateme/UpdateMe.dart
Normal 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();
|
||||
}
|
||||
116
frontend/lib/Grounded/see/system/updateme/UpdateMeState.dart
Normal file
116
frontend/lib/Grounded/see/system/updateme/UpdateMeState.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
frontend/lib/Grounded/see/system/updateme/ViewUpdateMe.dart
Normal file
12
frontend/lib/Grounded/see/system/updateme/ViewUpdateMe.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
16
frontend/lib/Grounded/see/training/ConnectTraining.dart
Normal file
16
frontend/lib/Grounded/see/training/ConnectTraining.dart
Normal 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);
|
||||
}
|
||||
10
frontend/lib/Grounded/see/training/Training.dart
Normal file
10
frontend/lib/Grounded/see/training/Training.dart
Normal 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();
|
||||
}
|
||||
350
frontend/lib/Grounded/see/training/TrainingState.dart
Normal file
350
frontend/lib/Grounded/see/training/TrainingState.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
129
frontend/lib/Grounded/see/training/ViewTraining.dart
Normal file
129
frontend/lib/Grounded/see/training/ViewTraining.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user