304 lines
8.6 KiB
Dart
304 lines
8.6 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.title, required this.timeText});
|
|
|
|
/// 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(
|
|
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.visualKind,
|
|
required this.rewardIconToken,
|
|
required this.difficultyIconToken,
|
|
required this.isCompleted,
|
|
required this.isSelectable,
|
|
required this.showsQuickActions,
|
|
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)}';
|
|
return TimelineCardModel(
|
|
id: item.id,
|
|
title: title,
|
|
typeLabel: _typeLabelFor(visualKind),
|
|
subtitle: _subtitleFor(item, visualKind, timeText),
|
|
timeText: timeText,
|
|
startMinutes: _minutesSinceMidnight(start),
|
|
endMinutes: _minutesSinceMidnight(end),
|
|
visualKind: visualKind,
|
|
rewardIconToken: item.item.rewardIconToken,
|
|
difficultyIconToken: item.item.difficultyIconToken,
|
|
durationMinutes: duration,
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
/// 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;
|
|
|
|
/// 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(TaskVisualKind visualKind) {
|
|
return switch (visualKind) {
|
|
TaskVisualKind.flexible => 'Flexible',
|
|
TaskVisualKind.required => 'Required',
|
|
TaskVisualKind.appointment => 'Required',
|
|
TaskVisualKind.freeSlot => 'Free Slot',
|
|
TaskVisualKind.completedSurprise => 'Surprise task',
|
|
};
|
|
}
|
|
|
|
String _subtitleFor(
|
|
TodayTimelineItem item,
|
|
TaskVisualKind visualKind,
|
|
String timeText,
|
|
) {
|
|
if (visualKind == TaskVisualKind.freeSlot) {
|
|
return 'Intentional rest';
|
|
}
|
|
if (visualKind == TaskVisualKind.completedSurprise) {
|
|
final completedAt = _formatTime(item.item.end ?? item.end);
|
|
return 'Surprise task - Completed at $completedAt';
|
|
}
|
|
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';
|
|
}
|