697 lines
21 KiB
Dart
697 lines
21 KiB
Dart
// Project-level aggregate statistics and deterministic learned suggestions.
|
|
//
|
|
// These types stay in the pure Dart core. They provide persistence-friendly
|
|
// counters and optional suggestions, but they do not overwrite configured
|
|
// project defaults or implement reports UI.
|
|
|
|
import 'models.dart';
|
|
import 'task_lifecycle.dart';
|
|
|
|
/// Coarse completion-time buckets used for learned project suggestions.
|
|
enum ProjectCompletionTimeBucket {
|
|
overnight,
|
|
morning,
|
|
afternoon,
|
|
evening,
|
|
night,
|
|
}
|
|
|
|
/// Suggestion categories derived from project observations.
|
|
enum ProjectSuggestionType {
|
|
durationMinutes,
|
|
completionTimeBucket,
|
|
reward,
|
|
difficulty,
|
|
reminderProfile,
|
|
}
|
|
|
|
/// How concentrated the supporting observations are for a suggestion.
|
|
enum ProjectSuggestionConfidence {
|
|
low,
|
|
medium,
|
|
high,
|
|
}
|
|
|
|
/// Immutable project-level observations used for future filtering and hints.
|
|
class ProjectStatistics {
|
|
ProjectStatistics({
|
|
required String projectId,
|
|
this.completedTaskCount = 0,
|
|
Map<int, int> durationMinuteCounts = const <int, int>{},
|
|
Map<ProjectCompletionTimeBucket, int> completionTimeBucketCounts =
|
|
const <ProjectCompletionTimeBucket, int>{},
|
|
this.totalPushesBeforeCompletion = 0,
|
|
this.completedAfterPushCount = 0,
|
|
Map<RewardLevel, int> rewardCounts = const <RewardLevel, int>{},
|
|
Map<DifficultyLevel, int> difficultyCounts = const <DifficultyLevel, int>{},
|
|
Map<ReminderProfile, int> reminderProfileCounts =
|
|
const <ReminderProfile, int>{},
|
|
}) : projectId = _requiredTrimmed(projectId, 'projectId'),
|
|
durationMinuteCounts = Map<int, int>.unmodifiable(
|
|
_positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'),
|
|
),
|
|
completionTimeBucketCounts =
|
|
Map<ProjectCompletionTimeBucket, int>.unmodifiable(
|
|
_enumCounts(
|
|
completionTimeBucketCounts,
|
|
'completionTimeBucketCounts',
|
|
),
|
|
),
|
|
rewardCounts = Map<RewardLevel, int>.unmodifiable(
|
|
_enumCounts(rewardCounts, 'rewardCounts'),
|
|
),
|
|
difficultyCounts = Map<DifficultyLevel, int>.unmodifiable(
|
|
_enumCounts(difficultyCounts, 'difficultyCounts'),
|
|
),
|
|
reminderProfileCounts = Map<ReminderProfile, int>.unmodifiable(
|
|
_enumCounts(reminderProfileCounts, 'reminderProfileCounts'),
|
|
) {
|
|
_validateNonNegative(completedTaskCount, 'completedTaskCount');
|
|
_validateNonNegative(
|
|
totalPushesBeforeCompletion,
|
|
'totalPushesBeforeCompletion',
|
|
);
|
|
_validateNonNegative(completedAfterPushCount, 'completedAfterPushCount');
|
|
}
|
|
|
|
/// Project these observations belong to.
|
|
final String projectId;
|
|
|
|
/// Completion activities applied to this project aggregate.
|
|
final int completedTaskCount;
|
|
|
|
/// Duration samples stored as minute-to-count buckets.
|
|
final Map<int, int> durationMinuteCounts;
|
|
|
|
/// Completion timestamp samples stored as coarse bucket counts.
|
|
final Map<ProjectCompletionTimeBucket, int> completionTimeBucketCounts;
|
|
|
|
/// Sum of push counts present when tasks were completed.
|
|
final int totalPushesBeforeCompletion;
|
|
|
|
/// Number of completed tasks that had at least one prior push.
|
|
final int completedAfterPushCount;
|
|
|
|
/// Reward observations from completed tasks.
|
|
final Map<RewardLevel, int> rewardCounts;
|
|
|
|
/// Difficulty observations from completed tasks.
|
|
final Map<DifficultyLevel, int> difficultyCounts;
|
|
|
|
/// Reminder-profile observations when explicitly known.
|
|
final Map<ReminderProfile, int> reminderProfileCounts;
|
|
|
|
/// Known duration sample count derived from the duration distribution.
|
|
int get knownDurationSampleCount => _countTotal(durationMinuteCounts);
|
|
|
|
/// Sum of known duration samples in minutes.
|
|
int get totalKnownDurationMinutes {
|
|
var total = 0;
|
|
for (final entry in durationMinuteCounts.entries) {
|
|
total += entry.key * entry.value;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// Numerator for average pushes before completion.
|
|
int get averagePushesBeforeCompletionNumerator {
|
|
return totalPushesBeforeCompletion;
|
|
}
|
|
|
|
/// Denominator for average pushes before completion.
|
|
int get averagePushesBeforeCompletionDenominator {
|
|
return completedTaskCount;
|
|
}
|
|
|
|
/// Derived average push count, without storing floating-point aggregate state.
|
|
double? get averagePushesBeforeCompletion {
|
|
if (completedTaskCount == 0) {
|
|
return null;
|
|
}
|
|
return totalPushesBeforeCompletion / completedTaskCount;
|
|
}
|
|
|
|
/// Return a copy with selected aggregate fields changed.
|
|
ProjectStatistics copyWith({
|
|
String? projectId,
|
|
int? completedTaskCount,
|
|
Map<int, int>? durationMinuteCounts,
|
|
Map<ProjectCompletionTimeBucket, int>? completionTimeBucketCounts,
|
|
int? totalPushesBeforeCompletion,
|
|
int? completedAfterPushCount,
|
|
Map<RewardLevel, int>? rewardCounts,
|
|
Map<DifficultyLevel, int>? difficultyCounts,
|
|
Map<ReminderProfile, int>? reminderProfileCounts,
|
|
}) {
|
|
return ProjectStatistics(
|
|
projectId: projectId ?? this.projectId,
|
|
completedTaskCount: completedTaskCount ?? this.completedTaskCount,
|
|
durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts,
|
|
completionTimeBucketCounts:
|
|
completionTimeBucketCounts ?? this.completionTimeBucketCounts,
|
|
totalPushesBeforeCompletion:
|
|
totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion,
|
|
completedAfterPushCount:
|
|
completedAfterPushCount ?? this.completedAfterPushCount,
|
|
rewardCounts: rewardCounts ?? this.rewardCounts,
|
|
difficultyCounts: difficultyCounts ?? this.difficultyCounts,
|
|
reminderProfileCounts:
|
|
reminderProfileCounts ?? this.reminderProfileCounts,
|
|
);
|
|
}
|
|
|
|
/// Record one completed task observation.
|
|
ProjectStatistics recordCompletion({
|
|
required DateTime completedAt,
|
|
int? durationMinutes,
|
|
int pushesBeforeCompletion = 0,
|
|
RewardLevel? reward,
|
|
DifficultyLevel? difficulty,
|
|
ReminderProfile? reminderProfile,
|
|
}) {
|
|
if (durationMinutes != null && durationMinutes <= 0) {
|
|
throw ArgumentError.value(
|
|
durationMinutes,
|
|
'durationMinutes',
|
|
'Duration minutes must be positive when present.',
|
|
);
|
|
}
|
|
if (pushesBeforeCompletion < 0) {
|
|
throw ArgumentError.value(
|
|
pushesBeforeCompletion,
|
|
'pushesBeforeCompletion',
|
|
'Push count cannot be negative.',
|
|
);
|
|
}
|
|
|
|
return copyWith(
|
|
completedTaskCount: completedTaskCount + 1,
|
|
durationMinuteCounts: durationMinutes == null
|
|
? durationMinuteCounts
|
|
: _incrementCount(durationMinuteCounts, durationMinutes),
|
|
completionTimeBucketCounts: _incrementCount(
|
|
completionTimeBucketCounts,
|
|
bucketForCompletion(completedAt),
|
|
),
|
|
totalPushesBeforeCompletion:
|
|
totalPushesBeforeCompletion + pushesBeforeCompletion,
|
|
completedAfterPushCount: pushesBeforeCompletion > 0
|
|
? completedAfterPushCount + 1
|
|
: completedAfterPushCount,
|
|
rewardCounts: reward == null
|
|
? rewardCounts
|
|
: _incrementCount(
|
|
rewardCounts,
|
|
reward,
|
|
),
|
|
difficultyCounts: difficulty == null
|
|
? difficultyCounts
|
|
: _incrementCount(
|
|
difficultyCounts,
|
|
difficulty,
|
|
),
|
|
reminderProfileCounts: reminderProfile == null
|
|
? reminderProfileCounts
|
|
: _incrementCount(
|
|
reminderProfileCounts,
|
|
reminderProfile,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Resolve the completion-time bucket for [completedAt].
|
|
static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) {
|
|
final hour = completedAt.hour;
|
|
if (hour < 5) {
|
|
return ProjectCompletionTimeBucket.overnight;
|
|
}
|
|
if (hour < 12) {
|
|
return ProjectCompletionTimeBucket.morning;
|
|
}
|
|
if (hour < 17) {
|
|
return ProjectCompletionTimeBucket.afternoon;
|
|
}
|
|
if (hour < 21) {
|
|
return ProjectCompletionTimeBucket.evening;
|
|
}
|
|
return ProjectCompletionTimeBucket.night;
|
|
}
|
|
}
|
|
|
|
/// Result of applying project-statistic effects from activities.
|
|
class ProjectStatisticsApplicationResult {
|
|
ProjectStatisticsApplicationResult({
|
|
required this.statistics,
|
|
List<String> appliedActivityIds = const <String>[],
|
|
}) : appliedActivityIds = List<String>.unmodifiable(appliedActivityIds);
|
|
|
|
/// Aggregate after newly applied activity effects.
|
|
final ProjectStatistics statistics;
|
|
|
|
/// Completion activity ids that changed the aggregate.
|
|
final List<String> appliedActivityIds;
|
|
}
|
|
|
|
/// Updates project statistics from canonical task activities exactly once.
|
|
class ProjectStatisticsAggregationService {
|
|
const ProjectStatisticsAggregationService();
|
|
|
|
/// Apply completion [activities] to [statistics].
|
|
///
|
|
/// [alreadyAppliedActivityIds] is supplied by the application/persistence
|
|
/// boundary. This service returns the new ids so Block 14 can persist task,
|
|
/// activity, and aggregate mutations atomically.
|
|
ProjectStatisticsApplicationResult apply({
|
|
required ProjectStatistics statistics,
|
|
required Iterable<TaskActivity> activities,
|
|
Iterable<Task> tasks = const <Task>[],
|
|
Iterable<String> alreadyAppliedActivityIds = const <String>[],
|
|
}) {
|
|
final taskById = {
|
|
for (final task in tasks) task.id: task,
|
|
};
|
|
final appliedIds = alreadyAppliedActivityIds.toSet();
|
|
final newlyAppliedIds = <String>[];
|
|
var current = statistics;
|
|
|
|
for (final activity in activities) {
|
|
if (activity.projectId != statistics.projectId ||
|
|
activity.code != TaskActivityCode.completed ||
|
|
appliedIds.contains(activity.id)) {
|
|
continue;
|
|
}
|
|
|
|
final task = taskById[activity.taskId];
|
|
final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ??
|
|
activity.occurredAt;
|
|
final pushesBeforeCompletion =
|
|
_intMetadata(activity.metadata['pushesBeforeCompletion']) ??
|
|
_pushesBeforeCompletion(task);
|
|
|
|
current = current.recordCompletion(
|
|
completedAt: completedAt,
|
|
durationMinutes: _durationMinutes(activity, task),
|
|
pushesBeforeCompletion: pushesBeforeCompletion,
|
|
reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward,
|
|
difficulty: _difficultyLevel(activity.metadata['difficulty']) ??
|
|
task?.difficulty,
|
|
reminderProfile:
|
|
_reminderProfile(activity.metadata['reminderProfile']) ??
|
|
task?.reminderOverride,
|
|
);
|
|
appliedIds.add(activity.id);
|
|
newlyAppliedIds.add(activity.id);
|
|
}
|
|
|
|
return ProjectStatisticsApplicationResult(
|
|
statistics: current,
|
|
appliedActivityIds: newlyAppliedIds,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Minimum sample sizes for deterministic project suggestions.
|
|
class ProjectSuggestionPolicy {
|
|
const ProjectSuggestionPolicy({
|
|
this.minimumDurationSamples = 3,
|
|
this.minimumCompletionTimeSamples = 3,
|
|
this.minimumRewardSamples = 3,
|
|
this.minimumDifficultySamples = 3,
|
|
this.minimumReminderSamples = 3,
|
|
});
|
|
|
|
final int minimumDurationSamples;
|
|
final int minimumCompletionTimeSamples;
|
|
final int minimumRewardSamples;
|
|
final int minimumDifficultySamples;
|
|
final int minimumReminderSamples;
|
|
}
|
|
|
|
/// One optional learned suggestion with provenance.
|
|
class ProjectSuggestion<T extends Object> {
|
|
const ProjectSuggestion({
|
|
required this.type,
|
|
required this.value,
|
|
required this.sampleSize,
|
|
required this.supportingSampleCount,
|
|
required this.minimumSampleSize,
|
|
required this.confidence,
|
|
});
|
|
|
|
/// Suggestion category.
|
|
final ProjectSuggestionType type;
|
|
|
|
/// Suggested value.
|
|
final T value;
|
|
|
|
/// Number of meaningful observations considered.
|
|
final int sampleSize;
|
|
|
|
/// Observations that support [value].
|
|
final int supportingSampleCount;
|
|
|
|
/// Minimum sample threshold used before producing the suggestion.
|
|
final int minimumSampleSize;
|
|
|
|
/// Concentration of supporting observations.
|
|
final ProjectSuggestionConfidence confidence;
|
|
}
|
|
|
|
/// Optional suggestion set derived from one project's observations.
|
|
class ProjectSuggestionSet {
|
|
const ProjectSuggestionSet({
|
|
required this.statistics,
|
|
this.durationMinutes,
|
|
this.completionTimeBucket,
|
|
this.reward,
|
|
this.difficulty,
|
|
this.reminderProfile,
|
|
});
|
|
|
|
final ProjectStatistics statistics;
|
|
final ProjectSuggestion<int>? durationMinutes;
|
|
final ProjectSuggestion<ProjectCompletionTimeBucket>? completionTimeBucket;
|
|
final ProjectSuggestion<RewardLevel>? reward;
|
|
final ProjectSuggestion<DifficultyLevel>? difficulty;
|
|
final ProjectSuggestion<ReminderProfile>? reminderProfile;
|
|
}
|
|
|
|
/// Configured project defaults plus optional suggestions kept separate.
|
|
class ProjectDefaultResolution {
|
|
const ProjectDefaultResolution({
|
|
required this.project,
|
|
required this.suggestions,
|
|
});
|
|
|
|
/// Authoritative configured project profile.
|
|
final ProjectProfile project;
|
|
|
|
/// Optional learned suggestions that UI can present explicitly.
|
|
final ProjectSuggestionSet suggestions;
|
|
|
|
int? get configuredDurationMinutes => project.defaultDurationMinutes;
|
|
RewardLevel get configuredReward => project.defaultReward;
|
|
DifficultyLevel get configuredDifficulty => project.defaultDifficulty;
|
|
ReminderProfile get configuredReminderProfile =>
|
|
project.defaultReminderProfile;
|
|
}
|
|
|
|
/// Derives deterministic project suggestions from aggregate observations.
|
|
class ProjectSuggestionService {
|
|
const ProjectSuggestionService({
|
|
this.policy = const ProjectSuggestionPolicy(),
|
|
});
|
|
|
|
final ProjectSuggestionPolicy policy;
|
|
|
|
/// Build optional suggestions for [statistics].
|
|
ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) {
|
|
return ProjectSuggestionSet(
|
|
statistics: statistics,
|
|
durationMinutes: _durationSuggestion(statistics),
|
|
completionTimeBucket: _modeSuggestion(
|
|
type: ProjectSuggestionType.completionTimeBucket,
|
|
counts: statistics.completionTimeBucketCounts,
|
|
minimumSampleSize: policy.minimumCompletionTimeSamples,
|
|
),
|
|
reward: _modeSuggestion(
|
|
type: ProjectSuggestionType.reward,
|
|
counts: _withoutNotSetReward(statistics.rewardCounts),
|
|
minimumSampleSize: policy.minimumRewardSamples,
|
|
),
|
|
difficulty: _modeSuggestion(
|
|
type: ProjectSuggestionType.difficulty,
|
|
counts: _withoutNotSetDifficulty(statistics.difficultyCounts),
|
|
minimumSampleSize: policy.minimumDifficultySamples,
|
|
),
|
|
reminderProfile: _modeSuggestion(
|
|
type: ProjectSuggestionType.reminderProfile,
|
|
counts: statistics.reminderProfileCounts,
|
|
minimumSampleSize: policy.minimumReminderSamples,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Return configured defaults and optional suggestions without merging them.
|
|
ProjectDefaultResolution resolveDefaults({
|
|
required ProjectProfile project,
|
|
required ProjectStatistics statistics,
|
|
}) {
|
|
return ProjectDefaultResolution(
|
|
project: project,
|
|
suggestions: suggestionsFor(statistics),
|
|
);
|
|
}
|
|
|
|
ProjectSuggestion<int>? _durationSuggestion(ProjectStatistics statistics) {
|
|
final sampleSize = statistics.knownDurationSampleCount;
|
|
if (sampleSize < policy.minimumDurationSamples) {
|
|
return null;
|
|
}
|
|
|
|
final value = _weightedLowerMedian(statistics.durationMinuteCounts);
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
|
|
return ProjectSuggestion<int>(
|
|
type: ProjectSuggestionType.durationMinutes,
|
|
value: value,
|
|
sampleSize: sampleSize,
|
|
supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0,
|
|
minimumSampleSize: policy.minimumDurationSamples,
|
|
confidence: _confidence(
|
|
statistics.durationMinuteCounts[value] ?? 0,
|
|
sampleSize,
|
|
),
|
|
);
|
|
}
|
|
|
|
ProjectSuggestion<T>? _modeSuggestion<T extends Object>({
|
|
required ProjectSuggestionType type,
|
|
required Map<T, int> counts,
|
|
required int minimumSampleSize,
|
|
}) {
|
|
final sampleSize = _countTotal(counts);
|
|
if (sampleSize < minimumSampleSize) {
|
|
return null;
|
|
}
|
|
|
|
final mode = _uniqueMode(counts);
|
|
if (mode == null) {
|
|
return null;
|
|
}
|
|
|
|
final support = counts[mode] ?? 0;
|
|
return ProjectSuggestion<T>(
|
|
type: type,
|
|
value: mode,
|
|
sampleSize: sampleSize,
|
|
supportingSampleCount: support,
|
|
minimumSampleSize: minimumSampleSize,
|
|
confidence: _confidence(support, sampleSize),
|
|
);
|
|
}
|
|
}
|
|
|
|
Map<K, int> _incrementCount<K extends Object>(Map<K, int> counts, K key) {
|
|
return {
|
|
...counts,
|
|
key: (counts[key] ?? 0) + 1,
|
|
};
|
|
}
|
|
|
|
Map<int, int> _positiveIntKeyCounts(Map<int, int> counts, String name) {
|
|
final normalized = <int, int>{};
|
|
for (final entry in counts.entries) {
|
|
if (entry.key <= 0) {
|
|
throw ArgumentError.value(entry.key, name, 'Key must be positive.');
|
|
}
|
|
_validateNonNegative(entry.value, name);
|
|
if (entry.value > 0) {
|
|
normalized[entry.key] = entry.value;
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
Map<T, int> _enumCounts<T extends Enum>(Map<T, int> counts, String name) {
|
|
final normalized = <T, int>{};
|
|
for (final entry in counts.entries) {
|
|
_validateNonNegative(entry.value, name);
|
|
if (entry.value > 0) {
|
|
normalized[entry.key] = entry.value;
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
void _validateNonNegative(int value, String name) {
|
|
if (value < 0) {
|
|
throw ArgumentError.value(value, name, 'Count cannot be negative.');
|
|
}
|
|
}
|
|
|
|
int _countTotal<K extends Object>(Map<K, int> counts) {
|
|
var total = 0;
|
|
for (final value in counts.values) {
|
|
total += value;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
int? _weightedLowerMedian(Map<int, int> counts) {
|
|
if (counts.isEmpty) {
|
|
return null;
|
|
}
|
|
final sampleSize = _countTotal(counts);
|
|
final target = (sampleSize + 1) ~/ 2;
|
|
var seen = 0;
|
|
final sortedMinutes = counts.keys.toList()..sort();
|
|
for (final minutes in sortedMinutes) {
|
|
seen += counts[minutes] ?? 0;
|
|
if (seen >= target) {
|
|
return minutes;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
T? _uniqueMode<T extends Object>(Map<T, int> counts) {
|
|
T? bestKey;
|
|
var bestCount = 0;
|
|
var tied = false;
|
|
|
|
for (final entry in counts.entries) {
|
|
if (entry.value > bestCount) {
|
|
bestKey = entry.key;
|
|
bestCount = entry.value;
|
|
tied = false;
|
|
continue;
|
|
}
|
|
if (entry.value == bestCount && bestCount > 0) {
|
|
tied = true;
|
|
}
|
|
}
|
|
|
|
return tied ? null : bestKey;
|
|
}
|
|
|
|
ProjectSuggestionConfidence _confidence(int support, int sampleSize) {
|
|
if (sampleSize <= 0) {
|
|
return ProjectSuggestionConfidence.low;
|
|
}
|
|
if (support * 3 >= sampleSize * 2) {
|
|
return ProjectSuggestionConfidence.high;
|
|
}
|
|
if (support * 2 >= sampleSize) {
|
|
return ProjectSuggestionConfidence.medium;
|
|
}
|
|
return ProjectSuggestionConfidence.low;
|
|
}
|
|
|
|
Map<RewardLevel, int> _withoutNotSetReward(Map<RewardLevel, int> counts) {
|
|
return {
|
|
for (final entry in counts.entries)
|
|
if (entry.key != RewardLevel.notSet) entry.key: entry.value,
|
|
};
|
|
}
|
|
|
|
Map<DifficultyLevel, int> _withoutNotSetDifficulty(
|
|
Map<DifficultyLevel, int> counts,
|
|
) {
|
|
return {
|
|
for (final entry in counts.entries)
|
|
if (entry.key != DifficultyLevel.notSet) entry.key: entry.value,
|
|
};
|
|
}
|
|
|
|
DateTime? _dateTimeMetadata(Object? value) {
|
|
if (value is DateTime) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
return DateTime.tryParse(value)?.toUtc();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
int? _intMetadata(Object? value) {
|
|
return value is int && value >= 0 ? value : null;
|
|
}
|
|
|
|
int? _durationMinutes(TaskActivity activity, Task? task) {
|
|
final metadataDuration = _intMetadata(activity.metadata['durationMinutes']);
|
|
if (metadataDuration != null && metadataDuration > 0) {
|
|
return metadataDuration;
|
|
}
|
|
|
|
final actualStart =
|
|
_dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart;
|
|
final actualEnd =
|
|
_dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd;
|
|
if (actualStart != null &&
|
|
actualEnd != null &&
|
|
actualStart.isBefore(actualEnd)) {
|
|
final minutes = actualEnd.difference(actualStart).inMinutes;
|
|
if (minutes > 0) {
|
|
return minutes;
|
|
}
|
|
}
|
|
|
|
return task?.durationMinutes;
|
|
}
|
|
|
|
int _pushesBeforeCompletion(Task? task) {
|
|
if (task == null) {
|
|
return 0;
|
|
}
|
|
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
|
}
|
|
|
|
RewardLevel? _rewardLevel(Object? value) {
|
|
if (value is RewardLevel) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
return _enumByName(RewardLevel.values, value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
DifficultyLevel? _difficultyLevel(Object? value) {
|
|
if (value is DifficultyLevel) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
return _enumByName(DifficultyLevel.values, value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
ReminderProfile? _reminderProfile(Object? value) {
|
|
if (value is ReminderProfile) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
return _enumByName(ReminderProfile.values, value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
T? _enumByName<T extends Enum>(Iterable<T> values, String name) {
|
|
for (final value in values) {
|
|
if (value.name == name) {
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _requiredTrimmed(String value, String name) {
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) {
|
|
throw ArgumentError.value(value, name, 'Value is required.');
|
|
}
|
|
return trimmed;
|
|
}
|