focus-flow/apps/focus_flow_flutter/lib/models/today_screen_models.dart

342 lines
9.9 KiB
Dart

/// Defines Today Screen Models models for the FocusFlow Flutter UI.
library;
import 'package:scheduler_core/scheduler_core.dart';
/// Presentation model for the next-required-task banner.
class RequiredBannerModel {
/// Creates a required banner model.
const RequiredBannerModel({
required this.timelineCardId,
required this.title,
required this.timeText,
});
/// Timeline card id linked to the highlighted required task.
final String timelineCardId;
/// Title of the next required task.
final String title;
/// Display-ready time for the next required task.
final String timeText;
}
/// Visible timeline range expressed as minutes since midnight.
class TimelineRangeModel {
/// Creates a timeline range model.
const TimelineRangeModel({
required this.startMinutes,
required this.endMinutes,
});
/// First visible minute from midnight.
final int startMinutes;
/// Last visible minute from midnight.
final int endMinutes;
}
/// Visual category used to style timeline cards.
enum TaskVisualKind {
/// Flexible planned work.
flexible,
/// Critical required task.
required,
/// Inflexible appointment-style task.
appointment,
/// Intentional free time.
freeSlot,
/// Completed surprise task.
completedSurprise,
}
/// Presentation model for the compact Today screen.
class TodayScreenData {
/// Creates Today screen data.
const TodayScreenData({
required this.dateLabel,
required this.timelineRange,
required this.cards,
this.requiredBanner,
});
/// Creates an empty Today screen model for [dateLabel].
factory TodayScreenData.empty({required String dateLabel}) {
return TodayScreenData(
dateLabel: dateLabel,
timelineRange: defaultRange,
cards: const [],
);
}
/// Maps scheduler core [TodayState] into UI presentation data.
factory TodayScreenData.fromTodayState(TodayState state) {
final cards = [
for (final item in state.timelineItems) TimelineCardModel.fromItem(item),
];
final nextRequired = state.nextRequiredItem;
return TodayScreenData(
dateLabel: _dateLabel(state.date),
timelineRange: defaultRange,
requiredBanner: nextRequired == null
? null
: RequiredBannerModel(
timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start),
),
cards: List<TimelineCardModel>.unmodifiable(cards),
);
}
/// Default visible range for the compact timeline.
static const defaultRange = TimelineRangeModel(
startMinutes: 0,
endMinutes: 24 * 60,
);
/// Display-ready date label.
final String dateLabel;
/// Optional next-required-task banner model.
final RequiredBannerModel? requiredBanner;
/// Visible timeline range.
final TimelineRangeModel timelineRange;
/// Timeline cards to render.
final List<TimelineCardModel> cards;
}
/// Presentation model for a single compact timeline card.
class TimelineCardModel {
/// Creates a timeline card model.
const TimelineCardModel({
required this.id,
required this.title,
required this.typeLabel,
required this.subtitle,
required this.timeText,
required this.startMinutes,
required this.endMinutes,
required this.taskType,
required this.visualKind,
required this.rewardIconToken,
required this.difficultyIconToken,
required this.isCompleted,
required this.isSelectable,
required this.showsQuickActions,
this.completedTimeText,
this.durationMinutes,
});
/// Maps a scheduler timeline item into card presentation data.
factory TimelineCardModel.fromItem(TodayTimelineItem item) {
final start = item.start;
final end = item.end;
final visualKind = _visualKindFor(item);
final isCompleted = item.taskStatus == TaskStatus.completed;
final title = item.item.displayTitle;
final duration = item.item.durationMinutes;
final timeText = start == null || end == null
? duration == null
? ''
: '$duration min'
: '${_formatTime(start)} - ${_formatTime(end)}';
final completedAt = item.item.completedAt ?? item.item.end ?? item.end;
return TimelineCardModel(
id: item.id,
title: title,
typeLabel: _typeLabelFor(item.taskType, visualKind),
subtitle: _subtitleFor(item, visualKind, timeText),
timeText: timeText,
startMinutes: _minutesSinceMidnight(start),
endMinutes: _minutesSinceMidnight(end),
taskType: item.taskType,
visualKind: visualKind,
rewardIconToken: item.item.rewardIconToken,
difficultyIconToken: item.item.difficultyIconToken,
durationMinutes: duration,
completedTimeText: isCompleted ? _formatTime(completedAt) : null,
isCompleted: isCompleted,
isSelectable: true,
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
);
}
/// Stable card identifier.
final String id;
/// Primary task title shown on the card.
final String title;
/// Short task type label.
final String typeLabel;
/// Secondary line shown under the title.
final String subtitle;
/// Display-ready time or duration text.
final String timeText;
/// Display-ready completion time, if the task has been completed.
final String? completedTimeText;
/// Card start position in minutes since midnight.
final int startMinutes;
/// Card end position in minutes since midnight.
final int endMinutes;
/// Optional task duration in minutes.
final int? durationMinutes;
/// Source task scheduling behavior type.
final TaskType taskType;
/// Visual category used for styling.
final TaskVisualKind visualKind;
/// Reward icon token from the scheduler core.
final TimelineRewardIconToken rewardIconToken;
/// Difficulty icon token from the scheduler core.
final TimelineDifficultyIconToken difficultyIconToken;
/// Whether the card represents completed work.
final bool isCompleted;
/// Whether tapping the card should open its selection modal.
final bool isSelectable;
/// Whether the card should show compact quick-action controls.
final bool showsQuickActions;
/// Whether the left status control can toggle this task's completion.
bool get canToggleCompletion {
return switch (taskType) {
TaskType.flexible || TaskType.critical || TaskType.inflexible => true,
TaskType.locked || TaskType.surprise || TaskType.freeSlot => false,
};
}
/// Whether the left status control can complete this task.
bool get canComplete => canToggleCompletion && !isCompleted;
/// Whether the left status control can return this task to planned.
bool get canUncomplete => canToggleCompletion && isCompleted;
/// Accessibility and modal label for the reward icon.
String get rewardLabel {
return switch (rewardIconToken) {
TimelineRewardIconToken.notSet => 'Reward not set',
TimelineRewardIconToken.veryLow => 'Reward level 1',
TimelineRewardIconToken.low => 'Reward level 2',
TimelineRewardIconToken.medium => 'Reward level 3',
TimelineRewardIconToken.high => 'Reward level 4',
TimelineRewardIconToken.veryHigh => 'Reward level 5',
};
}
/// Accessibility and modal label for the effort icon.
String get effortLabel {
return switch (difficultyIconToken) {
TimelineDifficultyIconToken.notSet => 'Effort not set',
TimelineDifficultyIconToken.veryEasy => 'Very easy effort',
TimelineDifficultyIconToken.easy => 'Easy effort',
TimelineDifficultyIconToken.medium => 'Medium effort',
TimelineDifficultyIconToken.hard => 'Hard effort',
TimelineDifficultyIconToken.veryHard => 'Very hard effort',
};
}
}
TaskVisualKind _visualKindFor(TodayTimelineItem item) {
if (item.taskStatus == TaskStatus.completed ||
(item.taskType == TaskType.surprise &&
item.taskStatus == TaskStatus.completed)) {
return TaskVisualKind.completedSurprise;
}
return switch (item.taskType) {
TaskType.flexible => TaskVisualKind.flexible,
TaskType.critical => TaskVisualKind.required,
TaskType.inflexible => TaskVisualKind.appointment,
TaskType.freeSlot => TaskVisualKind.freeSlot,
TaskType.surprise => TaskVisualKind.completedSurprise,
TaskType.locked => TaskVisualKind.completedSurprise,
};
}
String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) {
if (visualKind == TaskVisualKind.completedSurprise) {
return 'Completed';
}
return switch (taskType) {
TaskType.flexible => 'Flexible',
TaskType.critical => 'Required',
TaskType.inflexible => 'Required',
TaskType.freeSlot => 'Free Slot',
TaskType.surprise => 'Surprise task',
TaskType.locked => 'Locked',
};
}
String _subtitleFor(
TodayTimelineItem item,
TaskVisualKind visualKind,
String timeText,
) {
if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest';
}
if (item.taskStatus == TaskStatus.completed ||
visualKind == TaskVisualKind.completedSurprise) {
return 'Completed at: '
'${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}';
}
if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) {
return '${item.item.durationMinutes} min';
}
return timeText;
}
String _dateLabel(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
int _minutesSinceMidnight(DateTime? value) {
if (value == null) {
return 0;
}
return value.hour * 60 + value.minute;
}
String _formatTime(DateTime? value) {
if (value == null) {
return '';
}
final period = value.hour >= 12 ? 'PM' : 'AM';
final rawHour = value.hour % 12;
final hour = rawHour == 0 ? 12 : rawHour;
final minute = value.minute.toString().padLeft(2, '0');
return '$hour:$minute $period';
}