Home now opens on a deep green header: greeting, a Today / Weekly toggle, a seven-day streak with a leaf on every kept day, minutes held with a + ring, plan thumbnails, the report-card letter as the score, and one bar per commitment class (weekly view swaps in per-day bars and class rates). The + opens an "Add to today" sheet over the blurred day. A water tracker sits below the header, kept on the device. The nav marks the selected tab as a filled block and drops the side action button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
255 lines
7.9 KiB
Dart
255 lines
7.9 KiB
Dart
import '../../about/external/data/Commitment.dart';
|
|
import '../../about/external/data/ExcuseCluster.dart';
|
|
import '../../about/external/data/ReportCard.dart';
|
|
import '../../about/external/data/RoutineChain.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/CompletionRequest.dart';
|
|
import '../../about/external/initial/ReportCardRequest.dart';
|
|
import '../../about/internal/application/CommitmentClass.dart';
|
|
import '../../about/internal/application/Standing.dart';
|
|
import '../../about/internal/application/TodoItem.dart';
|
|
import '../../about/internal/application/UserDetails.dart';
|
|
import '../../utils/DebtEngine.dart';
|
|
import '../../utils/GuardrailEngine.dart';
|
|
import '../../utils/ObjectConvertors.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);
|
|
|
|
loadTodos();
|
|
|
|
loadWater();
|
|
|
|
loadWeek();
|
|
|
|
loadPlan();
|
|
}
|
|
|
|
/// The last seven days. Quiet on failure: the header falls back to today.
|
|
void loadWeek() async {
|
|
try {
|
|
final DateTime now = DateTime.now();
|
|
final DateTime start = DateTime(now.year, now.month, now.day)
|
|
.subtract(const Duration(days: 6));
|
|
|
|
final response =
|
|
await getDataManager().getWeeklyReportCard(ReportCardRequest(
|
|
periodStart: start.toIso8601String(),
|
|
periodEnd: now.toIso8601String(),
|
|
));
|
|
|
|
connection.onWeekLoaded(ReportCard.fromJson(response.data));
|
|
} catch (e) {
|
|
connection.onWeekLoaded(null);
|
|
}
|
|
}
|
|
|
|
void loadWater() async {
|
|
connection.onWaterLoaded(await getDataManager().getWaterToday());
|
|
}
|
|
|
|
void saveWater(int ml) async {
|
|
await getDataManager().setWaterToday(ml);
|
|
}
|
|
|
|
void loadTodos() async {
|
|
connection.onTodosLoaded(await getDataManager().getTodos());
|
|
}
|
|
|
|
/// Saves the whole list; it is small and only ever lives on the device.
|
|
void saveTodos(List<TodoItem> todos) async {
|
|
await getDataManager().setTodos(todos);
|
|
}
|
|
|
|
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();
|
|
|
|
loadRoutines();
|
|
} catch (e) {
|
|
handleError(e, () => loadPlan(), () => dismissError(), "Retry");
|
|
}
|
|
}
|
|
|
|
/// The routines feed the home rail. Quiet on failure — the plan is the
|
|
/// screen; the rail is a shortcut.
|
|
void loadRoutines() async {
|
|
try {
|
|
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
|
query: PageAndSort(
|
|
sort: Sort('asc', 'startMinute'),
|
|
page: Pageable(0, 0, 50, 0),
|
|
),
|
|
));
|
|
|
|
connection.onRoutinesLoaded(getRoutineChainList(response.data));
|
|
} catch (e) {
|
|
connection.onRoutinesLoaded(<RoutineChain>[]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Ticking a row completes it. The window decides whether that lands as a
|
|
/// clean complete or a late one — the client never claims which.
|
|
void tick(Commitment item) async {
|
|
if (!await hasNetwork(() => tick(item))) return;
|
|
|
|
final bool late = item.wouldBeLate;
|
|
|
|
try {
|
|
await getDataManager().completeCommitmentEntry(CompletionRequest(
|
|
commitmentId: item.id ?? "",
|
|
proofType: item.proofType.name,
|
|
));
|
|
|
|
connection.onTicked(item, late);
|
|
|
|
// Clearing an item moves the debt, so the standing has to be re-derived
|
|
// rather than left showing the number from before the tick.
|
|
loadPlan();
|
|
} catch (e) {
|
|
connection.onTickFailed(item);
|
|
handleError(e, () => tick(item), () => dismissError(), "Retry");
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|