Files
GroundedHelper/frontend/lib/Grounded/see/home/ViewHome.dart
alvocool 1b5d105400 Show real photos for people instead of scene art
Your avatar is your photo, picked from the device by tapping it on Home or
from a new Profile photo row in Settings, shrunk to 256 px and kept on the
phone (there is no upload endpoint yet). Group members show the photo the
server sends in a new `photo` field, everywhere avatars appear. Without a
photo, an initial on a neutral disc. Mock mode bundles free Unsplash
portraits for the sample people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 08:22:41 +03:00

289 lines
9.2 KiB
Dart

import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/Group.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/ProgressTargets.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TodoItem.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../configs/NotificationServiceConfig.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();
loadWeek();
loadGroups();
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);
}
}
/// The groups rail. Quiet on failure: the rail just stays hidden.
void loadGroups() async {
try {
final response = await getDataManager().getMyGroups(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'lastMessageAt'),
page: Pageable(0, 0, 20, 0),
),
));
connection.onGroupsLoaded(getGroupList(response.data));
} catch (e) {
connection.onGroupsLoaded(<Group>[]);
}
}
/// A new profile photo, kept on the device with the rest of the user's
/// details. There is no upload endpoint yet, so it does not leave the phone.
void savePhoto(String pic) async {
final UserDetails details = await getDataManager().getUserDetails();
details.pic = pic;
await getDataManager().setUserDetails(details);
connection.onUserLoaded(details);
}
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),
),
));
final List<RoutineChain> routines = getRoutineChainList(response.data);
connection.onRoutinesLoaded(routines);
_placeReminders(routines);
} catch (e) {
connection.onRoutinesLoaded(<RoutineChain>[]);
}
}
/// Reminders are one-shots for the coming week, so they are re-placed
/// whenever the schedule is read.
void _placeReminders(List<RoutineChain> routines) async {
final ProgressTargets targets = await getDataManager().getProgressTargets();
if (targets.reminders) {
await LocalNotificationEngine.scheduleRoutineReminders(
routines, targets.reminderLead);
}
}
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));
}
}