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/CompletionRequest.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 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 _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 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 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)); } }