334 lines
9.3 KiB
Dart
334 lines
9.3 KiB
Dart
// UI/platform-independent reminder policy.
|
|
//
|
|
// This file decides whether the core would deliver, suppress, defer, or require
|
|
// acknowledgement for a reminder at a specific instant. It does not schedule OS
|
|
// notifications, run background timers, or move tasks.
|
|
|
|
import 'models.dart';
|
|
import 'occupancy_policy.dart';
|
|
|
|
/// High-level reminder directive for application/platform layers.
|
|
enum ReminderDirectiveAction {
|
|
deliver,
|
|
suppress,
|
|
defer,
|
|
requireAcknowledgement,
|
|
}
|
|
|
|
/// Stable reason codes for reminder directives.
|
|
enum ReminderDirectiveReason {
|
|
deliverNormal,
|
|
requireRequiredAcknowledgement,
|
|
deferUntilTaskWindow,
|
|
deferRequiredDuringProtectedRest,
|
|
suppressSilentProfile,
|
|
suppressProtectedRest,
|
|
suppressNoScheduledWindow,
|
|
suppressUnsupportedTaskType,
|
|
suppressInactiveStatus,
|
|
suppressHiddenLockedTime,
|
|
suppressFreeSlotRecord,
|
|
}
|
|
|
|
/// Result of resolving the effective reminder profile for one task.
|
|
class EffectiveReminderProfile {
|
|
const EffectiveReminderProfile({
|
|
required this.profile,
|
|
required this.source,
|
|
});
|
|
|
|
/// Resolved reminder profile.
|
|
final ReminderProfile profile;
|
|
|
|
/// Where [profile] came from.
|
|
final EffectiveReminderProfileSource source;
|
|
}
|
|
|
|
/// Source for an effective reminder profile.
|
|
enum EffectiveReminderProfileSource {
|
|
taskOverride,
|
|
projectDefault,
|
|
fallback,
|
|
}
|
|
|
|
/// UI/platform-independent directive for one task reminder check.
|
|
class ReminderDirective {
|
|
const ReminderDirective({
|
|
required this.taskId,
|
|
required this.action,
|
|
required this.reason,
|
|
required this.effectiveProfile,
|
|
required this.effectiveProfileSource,
|
|
required this.evaluatedAt,
|
|
this.scheduledStart,
|
|
this.scheduledEnd,
|
|
this.protectedFreeSlotTaskId,
|
|
this.nextEligibleAt,
|
|
});
|
|
|
|
/// Task evaluated for reminder behavior.
|
|
final String taskId;
|
|
|
|
/// What an application layer may do.
|
|
final ReminderDirectiveAction action;
|
|
|
|
/// Stable reason code for tests, telemetry, and later UI copy.
|
|
final ReminderDirectiveReason reason;
|
|
|
|
/// Effective reminder profile used for the decision.
|
|
final ReminderProfile effectiveProfile;
|
|
|
|
/// Source of [effectiveProfile].
|
|
final EffectiveReminderProfileSource effectiveProfileSource;
|
|
|
|
/// Instant used to evaluate the directive.
|
|
final DateTime evaluatedAt;
|
|
|
|
/// Task scheduled start, when present.
|
|
final DateTime? scheduledStart;
|
|
|
|
/// Task scheduled end, when present.
|
|
final DateTime? scheduledEnd;
|
|
|
|
/// Active protected Free Slot that affected the decision, when any.
|
|
final String? protectedFreeSlotTaskId;
|
|
|
|
/// Next known time the application may re-check, when deterministic.
|
|
final DateTime? nextEligibleAt;
|
|
}
|
|
|
|
/// Resolves reminder profiles and reminder directives.
|
|
class ReminderPolicyService {
|
|
const ReminderPolicyService({
|
|
this.fallbackProfile = ReminderProfile.gentle,
|
|
this.occupancyPolicy = const OccupancyPolicy(),
|
|
});
|
|
|
|
/// Fallback used when no task override or project profile is available.
|
|
final ReminderProfile fallbackProfile;
|
|
|
|
/// Occupancy classifier used to detect active protected Free Slots.
|
|
final OccupancyPolicy occupancyPolicy;
|
|
|
|
/// Resolve effective profile without considering learned suggestions.
|
|
EffectiveReminderProfile resolveEffectiveProfile({
|
|
required Task task,
|
|
ProjectProfile? project,
|
|
}) {
|
|
final override = task.reminderOverride;
|
|
if (override != null) {
|
|
return EffectiveReminderProfile(
|
|
profile: override,
|
|
source: EffectiveReminderProfileSource.taskOverride,
|
|
);
|
|
}
|
|
|
|
if (project != null) {
|
|
return EffectiveReminderProfile(
|
|
profile: project.defaultReminderProfile,
|
|
source: EffectiveReminderProfileSource.projectDefault,
|
|
);
|
|
}
|
|
|
|
return EffectiveReminderProfile(
|
|
profile: fallbackProfile,
|
|
source: EffectiveReminderProfileSource.fallback,
|
|
);
|
|
}
|
|
|
|
/// Resolve one reminder directive at [now].
|
|
ReminderDirective directiveForTask({
|
|
required Task task,
|
|
required DateTime now,
|
|
ProjectProfile? project,
|
|
Iterable<Task> contextTasks = const <Task>[],
|
|
}) {
|
|
final effective = resolveEffectiveProfile(task: task, project: project);
|
|
final activeFreeSlot = _activeProtectedFreeSlot(
|
|
now: now,
|
|
contextTasks: contextTasks,
|
|
);
|
|
|
|
return _directiveForTask(
|
|
task: task,
|
|
now: now,
|
|
effective: effective,
|
|
activeFreeSlot: activeFreeSlot,
|
|
);
|
|
}
|
|
|
|
ReminderDirective _directiveForTask({
|
|
required Task task,
|
|
required DateTime now,
|
|
required EffectiveReminderProfile effective,
|
|
required OccupancyEntry? activeFreeSlot,
|
|
}) {
|
|
final start = task.scheduledStart;
|
|
final end = task.scheduledEnd;
|
|
|
|
ReminderDirective build({
|
|
required ReminderDirectiveAction action,
|
|
required ReminderDirectiveReason reason,
|
|
DateTime? nextEligibleAt,
|
|
}) {
|
|
return ReminderDirective(
|
|
taskId: task.id,
|
|
action: action,
|
|
reason: reason,
|
|
effectiveProfile: effective.profile,
|
|
effectiveProfileSource: effective.source,
|
|
evaluatedAt: now,
|
|
scheduledStart: start,
|
|
scheduledEnd: end,
|
|
protectedFreeSlotTaskId: activeFreeSlot?.taskId,
|
|
nextEligibleAt: nextEligibleAt,
|
|
);
|
|
}
|
|
|
|
if (effective.profile == ReminderProfile.silent) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressSilentProfile,
|
|
);
|
|
}
|
|
|
|
if (task.type == TaskType.locked) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressHiddenLockedTime,
|
|
);
|
|
}
|
|
|
|
if (task.type == TaskType.freeSlot) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressFreeSlotRecord,
|
|
);
|
|
}
|
|
|
|
if (!_isReminderEligibleStatus(task.status)) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressInactiveStatus,
|
|
);
|
|
}
|
|
|
|
if (!_isReminderEligibleType(task.type)) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressUnsupportedTaskType,
|
|
);
|
|
}
|
|
|
|
if (start == null || end == null || !start.isBefore(end)) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressNoScheduledWindow,
|
|
);
|
|
}
|
|
|
|
if (now.isBefore(start)) {
|
|
return build(
|
|
action: ReminderDirectiveAction.defer,
|
|
reason: ReminderDirectiveReason.deferUntilTaskWindow,
|
|
nextEligibleAt: start,
|
|
);
|
|
}
|
|
|
|
final protectedRestActive = activeFreeSlot != null;
|
|
if (protectedRestActive && task.isFlexible) {
|
|
return build(
|
|
action: ReminderDirectiveAction.suppress,
|
|
reason: ReminderDirectiveReason.suppressProtectedRest,
|
|
nextEligibleAt: activeFreeSlot.interval?.end,
|
|
);
|
|
}
|
|
|
|
if (protectedRestActive && task.isRequiredVisible) {
|
|
return _requiredDuringProtectedRest(
|
|
task: task,
|
|
effective: effective,
|
|
build: build,
|
|
freeSlotEnd: activeFreeSlot.interval?.end,
|
|
);
|
|
}
|
|
|
|
if (task.type == TaskType.critical &&
|
|
effective.profile == ReminderProfile.strict) {
|
|
return build(
|
|
action: ReminderDirectiveAction.requireAcknowledgement,
|
|
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
|
);
|
|
}
|
|
|
|
return build(
|
|
action: ReminderDirectiveAction.deliver,
|
|
reason: ReminderDirectiveReason.deliverNormal,
|
|
);
|
|
}
|
|
|
|
ReminderDirective _requiredDuringProtectedRest({
|
|
required Task task,
|
|
required EffectiveReminderProfile effective,
|
|
required ReminderDirective Function({
|
|
required ReminderDirectiveAction action,
|
|
required ReminderDirectiveReason reason,
|
|
DateTime? nextEligibleAt,
|
|
}) build,
|
|
required DateTime? freeSlotEnd,
|
|
}) {
|
|
if (task.type == TaskType.critical &&
|
|
effective.profile == ReminderProfile.strict) {
|
|
return build(
|
|
action: ReminderDirectiveAction.requireAcknowledgement,
|
|
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
|
);
|
|
}
|
|
|
|
if (task.type == TaskType.inflexible &&
|
|
effective.profile == ReminderProfile.gentle) {
|
|
return build(
|
|
action: ReminderDirectiveAction.defer,
|
|
reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest,
|
|
nextEligibleAt: freeSlotEnd,
|
|
);
|
|
}
|
|
|
|
return build(
|
|
action: ReminderDirectiveAction.deliver,
|
|
reason: ReminderDirectiveReason.deliverNormal,
|
|
);
|
|
}
|
|
|
|
OccupancyEntry? _activeProtectedFreeSlot({
|
|
required DateTime now,
|
|
required Iterable<Task> contextTasks,
|
|
}) {
|
|
for (final entry in occupancyPolicy.classify(tasks: contextTasks)) {
|
|
final interval = entry.interval;
|
|
if (entry.category != OccupancyCategory.protectedFreeSlot ||
|
|
interval == null) {
|
|
continue;
|
|
}
|
|
|
|
final startsAtOrBeforeNow =
|
|
interval.start.isBefore(now) || interval.start.isAtSameMomentAs(now);
|
|
final endsAfterNow = interval.end.isAfter(now);
|
|
if (startsAtOrBeforeNow && endsAfterNow) {
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
bool _isReminderEligibleStatus(TaskStatus status) {
|
|
return status == TaskStatus.planned || status == TaskStatus.active;
|
|
}
|
|
|
|
bool _isReminderEligibleType(TaskType type) {
|
|
return type == TaskType.flexible ||
|
|
type == TaskType.critical ||
|
|
type == TaskType.inflexible;
|
|
}
|