focus-flow/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart

150 lines
5.5 KiB
Dart

// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Difficulty Bars pieces for the FocusFlow Flutter timeline.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/focus_flow_tokens.dart';
/// Five-bar effort indicator for a timeline task.
class DifficultyBars extends StatelessWidget {
/// Creates difficulty bars for a scheduler difficulty token.
const DifficultyBars({
required this.difficulty,
this.color = FocusFlowTokens.restPurple,
this.size = const Size(54, 34),
super.key,
});
/// Creates difficulty bars directly from a domain difficulty level.
factory DifficultyBars.forLevel({
required DifficultyLevel difficulty,
Color color = FocusFlowTokens.restPurple,
Size size = const Size(54, 34),
Key? key,
}) {
return DifficultyBars(
key: key,
difficulty: tokenForLevel(difficulty),
color: color,
size: size,
);
}
/// Difficulty token that determines how many bars are filled.
final TimelineDifficultyIconToken difficulty;
/// Color used for filled bars.
final Color color;
/// Fixed paint size for the indicator.
final Size size;
/// Converts a timeline difficulty token to a filled bar count.
static int fillCount(TimelineDifficultyIconToken difficulty) {
return switch (difficulty) {
TimelineDifficultyIconToken.notSet => 0,
TimelineDifficultyIconToken.veryEasy => 1,
TimelineDifficultyIconToken.easy => 2,
TimelineDifficultyIconToken.medium => 3,
TimelineDifficultyIconToken.hard => 4,
TimelineDifficultyIconToken.veryHard => 5,
};
}
/// Converts a domain difficulty level to a filled bar count.
static int fillCountForLevel(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => 0,
DifficultyLevel.veryEasy => 1,
DifficultyLevel.easy => 2,
DifficultyLevel.medium => 3,
DifficultyLevel.hard => 4,
DifficultyLevel.veryHard => 5,
};
}
/// Converts a domain difficulty level to a timeline difficulty token.
static TimelineDifficultyIconToken tokenForLevel(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => TimelineDifficultyIconToken.notSet,
DifficultyLevel.veryEasy => TimelineDifficultyIconToken.veryEasy,
DifficultyLevel.easy => TimelineDifficultyIconToken.easy,
DifficultyLevel.medium => TimelineDifficultyIconToken.medium,
DifficultyLevel.hard => TimelineDifficultyIconToken.hard,
DifficultyLevel.veryHard => TimelineDifficultyIconToken.veryHard,
};
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return CustomPaint(
size: size,
painter: _DifficultyBarsPainter(
filledCount: fillCount(difficulty),
color: color,
),
);
}
}
/// Private implementation type for `_DifficultyBarsPainter` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _DifficultyBarsPainter extends CustomPainter {
/// Creates a `_DifficultyBarsPainter` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _DifficultyBarsPainter({
required this.filledCount,
required this.color,
});
/// Stores the `filledCount` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final int filledCount;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Performs the `paint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
void paint(Canvas canvas, Size size) {
final gap = size.width * 0.08;
final barWidth = (size.width - gap * 4) / 5;
final strokePaint = Paint()
..color = FocusFlowTokens.textFaint
..style = PaintingStyle.stroke
..strokeWidth = 1.3;
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill;
for (var index = 0; index < 5; index += 1) {
final left = index * (barWidth + gap);
final height = size.height * (0.34 + index * 0.13);
final top = size.height - height;
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, barWidth, height),
Radius.circular(barWidth / 2),
);
if (index < filledCount) {
canvas.drawRRect(rect, fillPaint);
} else {
canvas.drawRRect(rect, strokePaint);
}
}
}
/// Performs the `shouldRepaint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) {
return oldDelegate.filledCount != filledCount || oldDelegate.color != color;
}
}