forked from eva/focus-flow
refactor: split focused Flutter UI files
This commit is contained in:
parent
52dcc84b7d
commit
cbefe201d3
50 changed files with 2415 additions and 2324 deletions
|
|
@ -0,0 +1,24 @@
|
||||||
|
part of '../demo_scheduler_composition.dart';
|
||||||
|
|
||||||
|
/// Formats a civil date as a long month/day/year label.
|
||||||
|
String _formatDateLabel(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}';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _instant(CivilDate date, int hour, [int minute = 0]) {
|
||||||
|
return DateTime.utc(date.year, date.month, date.day, hour, minute);
|
||||||
|
}
|
||||||
156
apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart
Normal file
156
apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
part of '../demo_scheduler_composition.dart';
|
||||||
|
|
||||||
|
List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
|
||||||
|
return [
|
||||||
|
_seedTask(
|
||||||
|
id: 'clean-coffee-maker',
|
||||||
|
title: 'Clean coffee maker',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 16,
|
||||||
|
startMinute: 15,
|
||||||
|
endHour: 16,
|
||||||
|
endMinute: 30,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'cleaned-kitchen-counter',
|
||||||
|
title: 'Cleaned kitchen counter',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.completed,
|
||||||
|
date: date,
|
||||||
|
startHour: 8,
|
||||||
|
startMinute: 15,
|
||||||
|
endHour: 8,
|
||||||
|
endMinute: 30,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.medium,
|
||||||
|
completedAt: _instant(date, 8, 5),
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'sort-mail',
|
||||||
|
title: 'Sort mail',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 14,
|
||||||
|
startMinute: 40,
|
||||||
|
endHour: 15,
|
||||||
|
endMinute: 20,
|
||||||
|
reward: RewardLevel.low,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'start-laundry',
|
||||||
|
title: 'Start laundry',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 15,
|
||||||
|
startMinute: 0,
|
||||||
|
endHour: 15,
|
||||||
|
endMinute: 40,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.medium,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'water-plants',
|
||||||
|
title: 'Water plants',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 15,
|
||||||
|
startMinute: 20,
|
||||||
|
endHour: 16,
|
||||||
|
endMinute: 0,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.medium,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'pay-bill',
|
||||||
|
title: 'Pay bill',
|
||||||
|
type: TaskType.critical,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 18,
|
||||||
|
startMinute: 0,
|
||||||
|
endHour: 18,
|
||||||
|
endMinute: 10,
|
||||||
|
reward: RewardLevel.low,
|
||||||
|
difficulty: DifficultyLevel.medium,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'doctor-appointment',
|
||||||
|
title: 'Doctor appointment',
|
||||||
|
type: TaskType.inflexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 18,
|
||||||
|
startMinute: 30,
|
||||||
|
endHour: 19,
|
||||||
|
endMinute: 15,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.hard,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
_seedTask(
|
||||||
|
id: 'free-slot',
|
||||||
|
title: 'Free Slot',
|
||||||
|
type: TaskType.freeSlot,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
date: date,
|
||||||
|
startHour: 19,
|
||||||
|
startMinute: 15,
|
||||||
|
endHour: 20,
|
||||||
|
endMinute: 15,
|
||||||
|
reward: RewardLevel.notSet,
|
||||||
|
difficulty: DifficultyLevel.notSet,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Task _seedTask({
|
||||||
|
required String id,
|
||||||
|
required String title,
|
||||||
|
required TaskType type,
|
||||||
|
required TaskStatus status,
|
||||||
|
required CivilDate date,
|
||||||
|
required int startHour,
|
||||||
|
required int startMinute,
|
||||||
|
required int endHour,
|
||||||
|
required int endMinute,
|
||||||
|
required RewardLevel reward,
|
||||||
|
required DifficultyLevel difficulty,
|
||||||
|
required DateTime createdAt,
|
||||||
|
DateTime? completedAt,
|
||||||
|
}) {
|
||||||
|
final start = _instant(date, startHour, startMinute);
|
||||||
|
final end = _instant(date, endHour, endMinute);
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
projectId: DemoSchedulerComposition.projectId,
|
||||||
|
type: type,
|
||||||
|
status: status,
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
reward: reward,
|
||||||
|
difficulty: difficulty,
|
||||||
|
durationMinutes: end.difference(start).inMinutes,
|
||||||
|
scheduledStart: start,
|
||||||
|
scheduledEnd: end,
|
||||||
|
actualStart: status == TaskStatus.completed ? start : null,
|
||||||
|
actualEnd: status == TaskStatus.completed ? end : null,
|
||||||
|
completedAt: completedAt,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: completedAt ?? createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,9 @@ import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/scheduler_read_controller.dart';
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
||||||
|
part 'demo/demo_date_formatter.dart';
|
||||||
|
part 'demo/demo_seed_tasks.dart';
|
||||||
|
|
||||||
/// Wires the Flutter demo UI to in-memory scheduler application use cases.
|
/// Wires the Flutter demo UI to in-memory scheduler application use cases.
|
||||||
class DemoSchedulerComposition {
|
class DemoSchedulerComposition {
|
||||||
DemoSchedulerComposition._({
|
DemoSchedulerComposition._({
|
||||||
|
|
@ -46,7 +49,7 @@ class DemoSchedulerComposition {
|
||||||
final DateTime readAt;
|
final DateTime readAt;
|
||||||
|
|
||||||
/// Human-readable label for [date].
|
/// Human-readable label for [date].
|
||||||
String get dateLabel => formatDateLabel(date);
|
String get dateLabel => _formatDateLabel(date);
|
||||||
|
|
||||||
/// Creates a deterministic seeded composition for the demo app and tests.
|
/// Creates a deterministic seeded composition for the demo app and tests.
|
||||||
factory DemoSchedulerComposition.seeded({
|
factory DemoSchedulerComposition.seeded({
|
||||||
|
|
@ -178,182 +181,4 @@ class DemoSchedulerComposition {
|
||||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a civil date as a long month/day/year label.
|
|
||||||
static String formatDateLabel(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}';
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
|
|
||||||
return [
|
|
||||||
_task(
|
|
||||||
id: 'clean-coffee-maker',
|
|
||||||
title: 'Clean coffee maker',
|
|
||||||
type: TaskType.flexible,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 16,
|
|
||||||
startMinute: 15,
|
|
||||||
endHour: 16,
|
|
||||||
endMinute: 30,
|
|
||||||
reward: RewardLevel.medium,
|
|
||||||
difficulty: DifficultyLevel.easy,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'cleaned-kitchen-counter',
|
|
||||||
title: 'Cleaned kitchen counter',
|
|
||||||
type: TaskType.flexible,
|
|
||||||
status: TaskStatus.completed,
|
|
||||||
date: date,
|
|
||||||
startHour: 8,
|
|
||||||
startMinute: 15,
|
|
||||||
endHour: 8,
|
|
||||||
endMinute: 30,
|
|
||||||
reward: RewardLevel.medium,
|
|
||||||
difficulty: DifficultyLevel.medium,
|
|
||||||
completedAt: _instant(date, 8, 5),
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'sort-mail',
|
|
||||||
title: 'Sort mail',
|
|
||||||
type: TaskType.flexible,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 14,
|
|
||||||
startMinute: 40,
|
|
||||||
endHour: 15,
|
|
||||||
endMinute: 20,
|
|
||||||
reward: RewardLevel.low,
|
|
||||||
difficulty: DifficultyLevel.easy,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'start-laundry',
|
|
||||||
title: 'Start laundry',
|
|
||||||
type: TaskType.flexible,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 15,
|
|
||||||
startMinute: 0,
|
|
||||||
endHour: 15,
|
|
||||||
endMinute: 40,
|
|
||||||
reward: RewardLevel.medium,
|
|
||||||
difficulty: DifficultyLevel.medium,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'water-plants',
|
|
||||||
title: 'Water plants',
|
|
||||||
type: TaskType.flexible,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 15,
|
|
||||||
startMinute: 20,
|
|
||||||
endHour: 16,
|
|
||||||
endMinute: 0,
|
|
||||||
reward: RewardLevel.medium,
|
|
||||||
difficulty: DifficultyLevel.medium,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'pay-bill',
|
|
||||||
title: 'Pay bill',
|
|
||||||
type: TaskType.critical,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 18,
|
|
||||||
startMinute: 0,
|
|
||||||
endHour: 18,
|
|
||||||
endMinute: 10,
|
|
||||||
reward: RewardLevel.low,
|
|
||||||
difficulty: DifficultyLevel.medium,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'doctor-appointment',
|
|
||||||
title: 'Doctor appointment',
|
|
||||||
type: TaskType.inflexible,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 18,
|
|
||||||
startMinute: 30,
|
|
||||||
endHour: 19,
|
|
||||||
endMinute: 15,
|
|
||||||
reward: RewardLevel.medium,
|
|
||||||
difficulty: DifficultyLevel.hard,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
_task(
|
|
||||||
id: 'free-slot',
|
|
||||||
title: 'Free Slot',
|
|
||||||
type: TaskType.freeSlot,
|
|
||||||
status: TaskStatus.planned,
|
|
||||||
date: date,
|
|
||||||
startHour: 19,
|
|
||||||
startMinute: 15,
|
|
||||||
endHour: 20,
|
|
||||||
endMinute: 15,
|
|
||||||
reward: RewardLevel.notSet,
|
|
||||||
difficulty: DifficultyLevel.notSet,
|
|
||||||
createdAt: createdAt,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static Task _task({
|
|
||||||
required String id,
|
|
||||||
required String title,
|
|
||||||
required TaskType type,
|
|
||||||
required TaskStatus status,
|
|
||||||
required CivilDate date,
|
|
||||||
required int startHour,
|
|
||||||
required int startMinute,
|
|
||||||
required int endHour,
|
|
||||||
required int endMinute,
|
|
||||||
required RewardLevel reward,
|
|
||||||
required DifficultyLevel difficulty,
|
|
||||||
required DateTime createdAt,
|
|
||||||
DateTime? completedAt,
|
|
||||||
}) {
|
|
||||||
final start = _instant(date, startHour, startMinute);
|
|
||||||
final end = _instant(date, endHour, endMinute);
|
|
||||||
return Task(
|
|
||||||
id: id,
|
|
||||||
title: title,
|
|
||||||
projectId: projectId,
|
|
||||||
type: type,
|
|
||||||
status: status,
|
|
||||||
priority: PriorityLevel.medium,
|
|
||||||
reward: reward,
|
|
||||||
difficulty: difficulty,
|
|
||||||
durationMinutes: end.difference(start).inMinutes,
|
|
||||||
scheduledStart: start,
|
|
||||||
scheduledEnd: end,
|
|
||||||
actualStart: status == TaskStatus.completed ? start : null,
|
|
||||||
actualEnd: status == TaskStatus.completed ? end : null,
|
|
||||||
completedAt: completedAt,
|
|
||||||
createdAt: createdAt,
|
|
||||||
updatedAt: completedAt ?? createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime _instant(CivilDate date, int hour, [int minute = 0]) {
|
|
||||||
return DateTime.utc(date.year, date.month, date.day, hour, minute);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import '../widgets/timeline/timeline_view.dart';
|
||||||
import '../widgets/top_bar.dart';
|
import '../widgets/top_bar.dart';
|
||||||
import 'demo_scheduler_composition.dart';
|
import 'demo_scheduler_composition.dart';
|
||||||
|
|
||||||
|
part 'home/focus_flow_home.dart';
|
||||||
|
part 'home/focus_flow_home_state.dart';
|
||||||
|
part 'home/plan_issue.dart';
|
||||||
|
part 'home/today_screen_scaffold.dart';
|
||||||
|
part 'home/today_screen_scaffold_state.dart';
|
||||||
|
|
||||||
/// Root Material app for the FocusFlow desktop UI.
|
/// Root Material app for the FocusFlow desktop UI.
|
||||||
class FocusFlowApp extends StatelessWidget {
|
class FocusFlowApp extends StatelessWidget {
|
||||||
/// Creates a FocusFlow app from an application composition.
|
/// Creates a FocusFlow app from an application composition.
|
||||||
|
|
@ -34,199 +40,3 @@ class FocusFlowApp extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stateful home screen that owns the Today controller lifecycle.
|
|
||||||
class FocusFlowHome extends StatefulWidget {
|
|
||||||
/// Creates the FocusFlow home screen from an application composition.
|
|
||||||
const FocusFlowHome({required this.composition, super.key});
|
|
||||||
|
|
||||||
/// Factory and controller bundle used by the home screen.
|
|
||||||
final DemoSchedulerComposition composition;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<FocusFlowHome> createState() => _FocusFlowHomeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|
||||||
late final TodayScreenController controller;
|
|
||||||
late final SchedulerCommandController commandController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = widget.composition.createTodayScreenController();
|
|
||||||
commandController = widget.composition.createCommandController(
|
|
||||||
refreshReads: controller.load,
|
|
||||||
);
|
|
||||||
controller.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
commandController.dispose();
|
|
||||||
controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
|
||||||
if (!card.canToggleCompletion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (card.isCompleted) {
|
|
||||||
await commandController.uncompleteTask(taskId: card.id);
|
|
||||||
} else {
|
|
||||||
await commandController.completeTask(
|
|
||||||
taskId: card.id,
|
|
||||||
taskType: card.taskType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: FocusFlowTokens.appBackground,
|
|
||||||
body: AppShell(
|
|
||||||
sidebar: const Sidebar(),
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
return switch (controller.state) {
|
|
||||||
TodayScreenLoading() => const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
TodayScreenFailure(:final message) => _PlanIssue(
|
|
||||||
message: message,
|
|
||||||
),
|
|
||||||
TodayScreenEmpty() => _TodayScreenScaffold(
|
|
||||||
data: TodayScreenData.empty(
|
|
||||||
dateLabel: widget.composition.dateLabel,
|
|
||||||
),
|
|
||||||
selectedCard: controller.selectedCard,
|
|
||||||
onSelectCard: controller.selectCard,
|
|
||||||
onCompleteCard: _toggleCardCompletion,
|
|
||||||
onClearSelection: controller.clearSelection,
|
|
||||||
),
|
|
||||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
|
||||||
data: data,
|
|
||||||
selectedCard: controller.selectedCard,
|
|
||||||
onSelectCard: controller.selectCard,
|
|
||||||
onCompleteCard: _toggleCardCompletion,
|
|
||||||
onClearSelection: controller.clearSelection,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlanIssue extends StatelessWidget {
|
|
||||||
const _PlanIssue({required this.message});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Text(message, style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TodayScreenScaffold extends StatefulWidget {
|
|
||||||
const _TodayScreenScaffold({
|
|
||||||
required this.data,
|
|
||||||
required this.selectedCard,
|
|
||||||
required this.onSelectCard,
|
|
||||||
required this.onCompleteCard,
|
|
||||||
required this.onClearSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TodayScreenData data;
|
|
||||||
final TimelineCardModel? selectedCard;
|
|
||||||
final ValueChanged<TimelineCardModel> onSelectCard;
|
|
||||||
final Future<void> Function(TimelineCardModel card) onCompleteCard;
|
|
||||||
final VoidCallback onClearSelection;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|
||||||
String? _timelineScrollTargetCardId;
|
|
||||||
var _timelineScrollRequest = 0;
|
|
||||||
|
|
||||||
void _showUpcomingRequiredTask() {
|
|
||||||
final targetCardId = widget.data.requiredBanner?.timelineCardId;
|
|
||||||
if (targetCardId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_timelineScrollTargetCardId = targetCardId;
|
|
||||||
_timelineScrollRequest += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
FocusFlowTokens.mainGutter,
|
|
||||||
32,
|
|
||||||
FocusFlowTokens.mainGutter,
|
|
||||||
28,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
TopBar(dateLabel: widget.data.dateLabel),
|
|
||||||
if (widget.data.requiredBanner == null)
|
|
||||||
const SizedBox(height: 16)
|
|
||||||
else ...[
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
RequiredBanner(
|
|
||||||
model: widget.data.requiredBanner,
|
|
||||||
onShowUpcoming: _showUpcomingRequiredTask,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: TimelineView(
|
|
||||||
cards: widget.data.cards,
|
|
||||||
range: widget.data.timelineRange,
|
|
||||||
scrollTargetCardId: _timelineScrollTargetCardId,
|
|
||||||
scrollRequest: _timelineScrollRequest,
|
|
||||||
onCardSelected: widget.onSelectCard,
|
|
||||||
onCardCompleted: widget.onCompleteCard,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.selectedCard != null) ...[
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
key: const ValueKey('modal-click-away'),
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: widget.onClearSelection,
|
|
||||||
child: const ColoredBox(color: Colors.transparent),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: TaskSelectionModal(
|
|
||||||
card: widget.selectedCard!,
|
|
||||||
onClose: widget.onClearSelection,
|
|
||||||
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
|
||||||
? () => widget.onCompleteCard(widget.selectedCard!)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
13
apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart
Normal file
13
apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
part of '../focus_flow_app.dart';
|
||||||
|
|
||||||
|
/// Stateful home screen that owns the Today controller lifecycle.
|
||||||
|
class FocusFlowHome extends StatefulWidget {
|
||||||
|
/// Creates the FocusFlow home screen from an application composition.
|
||||||
|
const FocusFlowHome({required this.composition, super.key});
|
||||||
|
|
||||||
|
/// Factory and controller bundle used by the home screen.
|
||||||
|
final DemoSchedulerComposition composition;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FocusFlowHome> createState() => _FocusFlowHomeState();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
part of '../focus_flow_app.dart';
|
||||||
|
|
||||||
|
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
|
late final TodayScreenController controller;
|
||||||
|
late final SchedulerCommandController commandController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
controller = widget.composition.createTodayScreenController();
|
||||||
|
commandController = widget.composition.createCommandController(
|
||||||
|
refreshReads: controller.load,
|
||||||
|
);
|
||||||
|
controller.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
commandController.dispose();
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
||||||
|
if (!card.canToggleCompletion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (card.isCompleted) {
|
||||||
|
await commandController.uncompleteTask(taskId: card.id);
|
||||||
|
} else {
|
||||||
|
await commandController.completeTask(
|
||||||
|
taskId: card.id,
|
||||||
|
taskType: card.taskType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: FocusFlowTokens.appBackground,
|
||||||
|
body: AppShell(
|
||||||
|
sidebar: const Sidebar(),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return switch (controller.state) {
|
||||||
|
TodayScreenLoading() => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
TodayScreenFailure(:final message) => _PlanIssue(
|
||||||
|
message: message,
|
||||||
|
),
|
||||||
|
TodayScreenEmpty() => _TodayScreenScaffold(
|
||||||
|
data: TodayScreenData.empty(
|
||||||
|
dateLabel: widget.composition.dateLabel,
|
||||||
|
),
|
||||||
|
selectedCard: controller.selectedCard,
|
||||||
|
onSelectCard: controller.selectCard,
|
||||||
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onClearSelection: controller.clearSelection,
|
||||||
|
),
|
||||||
|
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||||
|
data: data,
|
||||||
|
selectedCard: controller.selectedCard,
|
||||||
|
onSelectCard: controller.selectCard,
|
||||||
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onClearSelection: controller.clearSelection,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/focus_flow_flutter/lib/app/home/plan_issue.dart
Normal file
14
apps/focus_flow_flutter/lib/app/home/plan_issue.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
part of '../focus_flow_app.dart';
|
||||||
|
|
||||||
|
class _PlanIssue extends StatelessWidget {
|
||||||
|
const _PlanIssue({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Text(message, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
part of '../focus_flow_app.dart';
|
||||||
|
|
||||||
|
class _TodayScreenScaffold extends StatefulWidget {
|
||||||
|
const _TodayScreenScaffold({
|
||||||
|
required this.data,
|
||||||
|
required this.selectedCard,
|
||||||
|
required this.onSelectCard,
|
||||||
|
required this.onCompleteCard,
|
||||||
|
required this.onClearSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TodayScreenData data;
|
||||||
|
final TimelineCardModel? selectedCard;
|
||||||
|
final ValueChanged<TimelineCardModel> onSelectCard;
|
||||||
|
final Future<void> Function(TimelineCardModel card) onCompleteCard;
|
||||||
|
final VoidCallback onClearSelection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
part of '../focus_flow_app.dart';
|
||||||
|
|
||||||
|
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
|
String? _timelineScrollTargetCardId;
|
||||||
|
var _timelineScrollRequest = 0;
|
||||||
|
|
||||||
|
void _showUpcomingRequiredTask() {
|
||||||
|
final targetCardId = widget.data.requiredBanner?.timelineCardId;
|
||||||
|
if (targetCardId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_timelineScrollTargetCardId = targetCardId;
|
||||||
|
_timelineScrollRequest += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
FocusFlowTokens.mainGutter,
|
||||||
|
32,
|
||||||
|
FocusFlowTokens.mainGutter,
|
||||||
|
28,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TopBar(dateLabel: widget.data.dateLabel),
|
||||||
|
if (widget.data.requiredBanner == null)
|
||||||
|
const SizedBox(height: 16)
|
||||||
|
else ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
RequiredBanner(
|
||||||
|
model: widget.data.requiredBanner,
|
||||||
|
onShowUpcoming: _showUpcomingRequiredTask,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: TimelineView(
|
||||||
|
cards: widget.data.cards,
|
||||||
|
range: widget.data.timelineRange,
|
||||||
|
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||||
|
scrollRequest: _timelineScrollRequest,
|
||||||
|
onCardSelected: widget.onSelectCard,
|
||||||
|
onCardCompleted: widget.onCompleteCard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.selectedCard != null) ...[
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const ValueKey('modal-click-away'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: widget.onClearSelection,
|
||||||
|
child: const ColoredBox(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: TaskSelectionModal(
|
||||||
|
card: widget.selectedCard!,
|
||||||
|
onClose: widget.onClearSelection,
|
||||||
|
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
||||||
|
? () => widget.onCompleteCard(widget.selectedCard!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
part of '../scheduler_command_controller.dart';
|
||||||
|
|
||||||
|
/// Builds an application operation context for a UI command operation.
|
||||||
|
typedef OperationContextFactory =
|
||||||
|
ApplicationOperationContext Function(String operationId, {DateTime? now});
|
||||||
|
|
||||||
|
/// Refreshes any reads that should reflect a completed command.
|
||||||
|
typedef ReadRefresh = Future<void> Function();
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
part of '../scheduler_command_controller.dart';
|
||||||
|
|
||||||
|
/// Runs scheduler write commands and exposes command state to widgets.
|
||||||
|
class SchedulerCommandController extends ChangeNotifier {
|
||||||
|
/// Creates a command controller from scheduler use cases and UI callbacks.
|
||||||
|
SchedulerCommandController({
|
||||||
|
required this.commands,
|
||||||
|
required this.contextFor,
|
||||||
|
required this.localDate,
|
||||||
|
required this.refreshReads,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : now = now ?? _utcNow;
|
||||||
|
|
||||||
|
/// Scheduler write use cases invoked by this controller.
|
||||||
|
final V1ApplicationCommandUseCases commands;
|
||||||
|
|
||||||
|
/// Factory for command-scoped application operation contexts.
|
||||||
|
final OperationContextFactory contextFor;
|
||||||
|
|
||||||
|
/// Local date commands should operate against.
|
||||||
|
final CivilDate localDate;
|
||||||
|
|
||||||
|
/// Refresh callback invoked after commands that mutate read state.
|
||||||
|
final ReadRefresh refreshReads;
|
||||||
|
|
||||||
|
/// Clock used by direct UI commands such as marking a task complete.
|
||||||
|
final DateTime Function() now;
|
||||||
|
var _sequence = 0;
|
||||||
|
SchedulerCommandState _state = const SchedulerCommandIdle();
|
||||||
|
|
||||||
|
/// Current command execution state.
|
||||||
|
SchedulerCommandState get state => _state;
|
||||||
|
|
||||||
|
/// Captures [title] as a backlog task.
|
||||||
|
Future<void> quickCaptureToBacklog(String title) async {
|
||||||
|
await _run(
|
||||||
|
label: 'Capturing task',
|
||||||
|
successMessage: 'Task captured',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.quickCaptureToBacklog(
|
||||||
|
context: contextFor(operationId),
|
||||||
|
title: title,
|
||||||
|
projectId: 'home',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules a backlog task into the next available slot.
|
||||||
|
Future<void> scheduleBacklogItem({
|
||||||
|
required String taskId,
|
||||||
|
required int durationMinutes,
|
||||||
|
required DateTime expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
await _run(
|
||||||
|
label: 'Scheduling task',
|
||||||
|
successMessage: 'Task scheduled',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.scheduleBacklogItemToNextAvailableSlot(
|
||||||
|
context: contextFor(operationId),
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes a planned flexible task from the Today view.
|
||||||
|
Future<void> completeFlexibleTask({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
await completeTask(
|
||||||
|
taskId: taskId,
|
||||||
|
taskType: TaskType.flexible,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes a planned task from the Today timeline.
|
||||||
|
Future<void> completeTask({
|
||||||
|
required String taskId,
|
||||||
|
required TaskType taskType,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final completedAt = now();
|
||||||
|
await _run(
|
||||||
|
label: 'Completing task',
|
||||||
|
successMessage: 'Task completed',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
final context = contextFor(operationId, now: completedAt);
|
||||||
|
return switch (taskType) {
|
||||||
|
TaskType.flexible => commands.completeFlexibleTask(
|
||||||
|
context: context,
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
),
|
||||||
|
TaskType.critical ||
|
||||||
|
TaskType.inflexible => commands.applyRequiredTaskAction(
|
||||||
|
context: context,
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
action: RequiredTaskAction.done,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
),
|
||||||
|
TaskType.locked ||
|
||||||
|
TaskType.surprise ||
|
||||||
|
TaskType.freeSlot => Future.value(
|
||||||
|
ApplicationResult.failure(
|
||||||
|
ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.validation,
|
||||||
|
entityId: taskId,
|
||||||
|
detailCode: 'unsupportedTaskType',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a completed task to its planned state.
|
||||||
|
Future<void> uncompleteTask({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final occurredAt = now();
|
||||||
|
await _run(
|
||||||
|
label: 'Reopening task',
|
||||||
|
successMessage: 'Task marked as not done',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.uncompleteTask(
|
||||||
|
context: contextFor(operationId, now: occurredAt),
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run({
|
||||||
|
required String label,
|
||||||
|
required String successMessage,
|
||||||
|
required bool refreshAfterSuccess,
|
||||||
|
required Future<ApplicationResult<ApplicationCommandResult>> Function(
|
||||||
|
String operationId,
|
||||||
|
)
|
||||||
|
action,
|
||||||
|
}) async {
|
||||||
|
final operationId = _nextOperationId();
|
||||||
|
_setState(SchedulerCommandRunning(label));
|
||||||
|
try {
|
||||||
|
final result = await action(operationId);
|
||||||
|
final failure = result.failure;
|
||||||
|
if (failure != null) {
|
||||||
|
_setState(SchedulerCommandFailure(failure: failure));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshAfterSuccess) {
|
||||||
|
await refreshReads();
|
||||||
|
}
|
||||||
|
_setState(SchedulerCommandSuccess(successMessage));
|
||||||
|
} on Object catch (error) {
|
||||||
|
_setState(SchedulerCommandFailure(error: error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _nextOperationId() {
|
||||||
|
_sequence += 1;
|
||||||
|
return 'ui-command-$_sequence';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(SchedulerCommandState next) {
|
||||||
|
_state = next;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
part of '../scheduler_command_controller.dart';
|
||||||
|
|
||||||
|
/// Base state for scheduler command execution in the UI.
|
||||||
|
sealed class SchedulerCommandState {
|
||||||
|
/// Creates a scheduler command state.
|
||||||
|
const SchedulerCommandState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that no scheduler command is currently running.
|
||||||
|
class SchedulerCommandIdle extends SchedulerCommandState {
|
||||||
|
/// Creates an idle command state.
|
||||||
|
const SchedulerCommandIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler command is currently running.
|
||||||
|
class SchedulerCommandRunning extends SchedulerCommandState {
|
||||||
|
/// Creates a running command state with a user-facing [label].
|
||||||
|
const SchedulerCommandRunning(this.label);
|
||||||
|
|
||||||
|
/// User-facing description of the command in progress.
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler command completed successfully.
|
||||||
|
class SchedulerCommandSuccess extends SchedulerCommandState {
|
||||||
|
/// Creates a success command state with a user-facing [message].
|
||||||
|
const SchedulerCommandSuccess(this.message);
|
||||||
|
|
||||||
|
/// User-facing success message.
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler command failed.
|
||||||
|
class SchedulerCommandFailure extends SchedulerCommandState {
|
||||||
|
/// Creates a failure state from an application [failure] or unexpected [error].
|
||||||
|
const SchedulerCommandFailure({this.failure, this.error});
|
||||||
|
|
||||||
|
/// Typed application failure returned by the scheduler core.
|
||||||
|
final ApplicationFailure? failure;
|
||||||
|
|
||||||
|
/// Unexpected error thrown while running the command.
|
||||||
|
final Object? error;
|
||||||
|
|
||||||
|
/// Stable label for display and diagnostics.
|
||||||
|
String get codeLabel {
|
||||||
|
final typed = failure;
|
||||||
|
if (typed != null) {
|
||||||
|
return typed.detailCode ?? typed.code.name;
|
||||||
|
}
|
||||||
|
return 'unexpected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,246 +4,6 @@ library;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
/// Builds an application operation context for a UI command operation.
|
part 'command/scheduler_command_callbacks.dart';
|
||||||
typedef OperationContextFactory =
|
part 'command/scheduler_command_controller_impl.dart';
|
||||||
ApplicationOperationContext Function(String operationId, {DateTime? now});
|
part 'command/scheduler_command_state.dart';
|
||||||
|
|
||||||
/// Refreshes any reads that should reflect a completed command.
|
|
||||||
typedef ReadRefresh = Future<void> Function();
|
|
||||||
|
|
||||||
/// Base state for scheduler command execution in the UI.
|
|
||||||
sealed class SchedulerCommandState {
|
|
||||||
/// Creates a scheduler command state.
|
|
||||||
const SchedulerCommandState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates that no scheduler command is currently running.
|
|
||||||
class SchedulerCommandIdle extends SchedulerCommandState {
|
|
||||||
/// Creates an idle command state.
|
|
||||||
const SchedulerCommandIdle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates that a scheduler command is currently running.
|
|
||||||
class SchedulerCommandRunning extends SchedulerCommandState {
|
|
||||||
/// Creates a running command state with a user-facing [label].
|
|
||||||
const SchedulerCommandRunning(this.label);
|
|
||||||
|
|
||||||
/// User-facing description of the command in progress.
|
|
||||||
final String label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates that a scheduler command completed successfully.
|
|
||||||
class SchedulerCommandSuccess extends SchedulerCommandState {
|
|
||||||
/// Creates a success command state with a user-facing [message].
|
|
||||||
const SchedulerCommandSuccess(this.message);
|
|
||||||
|
|
||||||
/// User-facing success message.
|
|
||||||
final String message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates that a scheduler command failed.
|
|
||||||
class SchedulerCommandFailure extends SchedulerCommandState {
|
|
||||||
/// Creates a failure state from an application [failure] or unexpected [error].
|
|
||||||
const SchedulerCommandFailure({this.failure, this.error});
|
|
||||||
|
|
||||||
/// Typed application failure returned by the scheduler core.
|
|
||||||
final ApplicationFailure? failure;
|
|
||||||
|
|
||||||
/// Unexpected error thrown while running the command.
|
|
||||||
final Object? error;
|
|
||||||
|
|
||||||
/// Stable label for display and diagnostics.
|
|
||||||
String get codeLabel {
|
|
||||||
final typed = failure;
|
|
||||||
if (typed != null) {
|
|
||||||
return typed.detailCode ?? typed.code.name;
|
|
||||||
}
|
|
||||||
return 'unexpected';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs scheduler write commands and exposes command state to widgets.
|
|
||||||
class SchedulerCommandController extends ChangeNotifier {
|
|
||||||
/// Creates a command controller from scheduler use cases and UI callbacks.
|
|
||||||
SchedulerCommandController({
|
|
||||||
required this.commands,
|
|
||||||
required this.contextFor,
|
|
||||||
required this.localDate,
|
|
||||||
required this.refreshReads,
|
|
||||||
DateTime Function()? now,
|
|
||||||
}) : now = now ?? _utcNow;
|
|
||||||
|
|
||||||
/// Scheduler write use cases invoked by this controller.
|
|
||||||
final V1ApplicationCommandUseCases commands;
|
|
||||||
|
|
||||||
/// Factory for command-scoped application operation contexts.
|
|
||||||
final OperationContextFactory contextFor;
|
|
||||||
|
|
||||||
/// Local date commands should operate against.
|
|
||||||
final CivilDate localDate;
|
|
||||||
|
|
||||||
/// Refresh callback invoked after commands that mutate read state.
|
|
||||||
final ReadRefresh refreshReads;
|
|
||||||
|
|
||||||
/// Clock used by direct UI commands such as marking a task complete.
|
|
||||||
final DateTime Function() now;
|
|
||||||
var _sequence = 0;
|
|
||||||
SchedulerCommandState _state = const SchedulerCommandIdle();
|
|
||||||
|
|
||||||
/// Current command execution state.
|
|
||||||
SchedulerCommandState get state => _state;
|
|
||||||
|
|
||||||
/// Captures [title] as a backlog task.
|
|
||||||
Future<void> quickCaptureToBacklog(String title) async {
|
|
||||||
await _run(
|
|
||||||
label: 'Capturing task',
|
|
||||||
successMessage: 'Task captured',
|
|
||||||
refreshAfterSuccess: true,
|
|
||||||
action: (operationId) {
|
|
||||||
return commands.quickCaptureToBacklog(
|
|
||||||
context: contextFor(operationId),
|
|
||||||
title: title,
|
|
||||||
projectId: 'home',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedules a backlog task into the next available slot.
|
|
||||||
Future<void> scheduleBacklogItem({
|
|
||||||
required String taskId,
|
|
||||||
required int durationMinutes,
|
|
||||||
required DateTime expectedUpdatedAt,
|
|
||||||
}) async {
|
|
||||||
await _run(
|
|
||||||
label: 'Scheduling task',
|
|
||||||
successMessage: 'Task scheduled',
|
|
||||||
refreshAfterSuccess: true,
|
|
||||||
action: (operationId) {
|
|
||||||
return commands.scheduleBacklogItemToNextAvailableSlot(
|
|
||||||
context: contextFor(operationId),
|
|
||||||
localDate: localDate,
|
|
||||||
taskId: taskId,
|
|
||||||
durationMinutes: durationMinutes,
|
|
||||||
expectedUpdatedAt: expectedUpdatedAt,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Completes a planned flexible task from the Today view.
|
|
||||||
Future<void> completeFlexibleTask({
|
|
||||||
required String taskId,
|
|
||||||
DateTime? expectedUpdatedAt,
|
|
||||||
}) async {
|
|
||||||
await completeTask(
|
|
||||||
taskId: taskId,
|
|
||||||
taskType: TaskType.flexible,
|
|
||||||
expectedUpdatedAt: expectedUpdatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Completes a planned task from the Today timeline.
|
|
||||||
Future<void> completeTask({
|
|
||||||
required String taskId,
|
|
||||||
required TaskType taskType,
|
|
||||||
DateTime? expectedUpdatedAt,
|
|
||||||
}) async {
|
|
||||||
final completedAt = now();
|
|
||||||
await _run(
|
|
||||||
label: 'Completing task',
|
|
||||||
successMessage: 'Task completed',
|
|
||||||
refreshAfterSuccess: true,
|
|
||||||
action: (operationId) {
|
|
||||||
final context = contextFor(operationId, now: completedAt);
|
|
||||||
return switch (taskType) {
|
|
||||||
TaskType.flexible => commands.completeFlexibleTask(
|
|
||||||
context: context,
|
|
||||||
localDate: localDate,
|
|
||||||
taskId: taskId,
|
|
||||||
expectedUpdatedAt: expectedUpdatedAt,
|
|
||||||
),
|
|
||||||
TaskType.critical ||
|
|
||||||
TaskType.inflexible => commands.applyRequiredTaskAction(
|
|
||||||
context: context,
|
|
||||||
localDate: localDate,
|
|
||||||
taskId: taskId,
|
|
||||||
action: RequiredTaskAction.done,
|
|
||||||
expectedUpdatedAt: expectedUpdatedAt,
|
|
||||||
),
|
|
||||||
TaskType.locked ||
|
|
||||||
TaskType.surprise ||
|
|
||||||
TaskType.freeSlot => Future.value(
|
|
||||||
ApplicationResult.failure(
|
|
||||||
ApplicationFailure(
|
|
||||||
code: ApplicationFailureCode.validation,
|
|
||||||
entityId: taskId,
|
|
||||||
detailCode: 'unsupportedTaskType',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a completed task to its planned state.
|
|
||||||
Future<void> uncompleteTask({
|
|
||||||
required String taskId,
|
|
||||||
DateTime? expectedUpdatedAt,
|
|
||||||
}) async {
|
|
||||||
final occurredAt = now();
|
|
||||||
await _run(
|
|
||||||
label: 'Reopening task',
|
|
||||||
successMessage: 'Task marked as not done',
|
|
||||||
refreshAfterSuccess: true,
|
|
||||||
action: (operationId) {
|
|
||||||
return commands.uncompleteTask(
|
|
||||||
context: contextFor(operationId, now: occurredAt),
|
|
||||||
localDate: localDate,
|
|
||||||
taskId: taskId,
|
|
||||||
expectedUpdatedAt: expectedUpdatedAt,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _run({
|
|
||||||
required String label,
|
|
||||||
required String successMessage,
|
|
||||||
required bool refreshAfterSuccess,
|
|
||||||
required Future<ApplicationResult<ApplicationCommandResult>> Function(
|
|
||||||
String operationId,
|
|
||||||
)
|
|
||||||
action,
|
|
||||||
}) async {
|
|
||||||
final operationId = _nextOperationId();
|
|
||||||
_setState(SchedulerCommandRunning(label));
|
|
||||||
try {
|
|
||||||
final result = await action(operationId);
|
|
||||||
final failure = result.failure;
|
|
||||||
if (failure != null) {
|
|
||||||
_setState(SchedulerCommandFailure(failure: failure));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (refreshAfterSuccess) {
|
|
||||||
await refreshReads();
|
|
||||||
}
|
|
||||||
_setState(SchedulerCommandSuccess(successMessage));
|
|
||||||
} on Object catch (error) {
|
|
||||||
_setState(SchedulerCommandFailure(error: error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _nextOperationId() {
|
|
||||||
_sequence += 1;
|
|
||||||
return 'ui-command-$_sequence';
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setState(SchedulerCommandState next) {
|
|
||||||
_state = next;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime _utcNow() => DateTime.now().toUtc();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
part of '../today_screen_models.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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
part of '../today_screen_models.dart';
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
part of '../../today_screen_models.dart';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
part of '../today_screen_models.dart';
|
||||||
|
|
||||||
|
/// 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
part of '../today_screen_models.dart';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
part of '../today_screen_models.dart';
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
part of '../today_screen_models.dart';
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
@ -3,340 +3,10 @@ library;
|
||||||
|
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
/// Presentation model for the next-required-task banner.
|
part 'today/required_banner_model.dart';
|
||||||
class RequiredBannerModel {
|
part 'today/task_visual_kind.dart';
|
||||||
/// Creates a required banner model.
|
part 'today/time/time_string_formatter.dart';
|
||||||
const RequiredBannerModel({
|
part 'today/timeline_card_model.dart';
|
||||||
required this.timelineCardId,
|
part 'today/timeline_item_presentation_mapper.dart';
|
||||||
required this.title,
|
part 'today/timeline_range_model.dart';
|
||||||
required this.timeText,
|
part 'today/today_screen_data.dart';
|
||||||
});
|
|
||||||
|
|
||||||
/// 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';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
const _ActionButton({required this.icon, required this.label});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
FocusFlowTokens.smallButtonRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icon(icon),
|
||||||
|
label: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: FocusFlowTokens.modalButtonSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
class _HeaderMetadata extends StatelessWidget {
|
||||||
|
const _HeaderMetadata({required this.card});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const metadataStyle = TextStyle(
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
fontSize: 17,
|
||||||
|
);
|
||||||
|
if (!card.isCompleted) {
|
||||||
|
return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle);
|
||||||
|
}
|
||||||
|
final completedTime = card.completedTimeText;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
completedTime == null || completedTime.isEmpty
|
||||||
|
? 'Completed'
|
||||||
|
: 'Completed - $completedTime',
|
||||||
|
style: metadataStyle,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'Planned: ${card.timeText}',
|
||||||
|
style: metadataStyle.copyWith(fontSize: 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
class _InfoTile extends StatelessWidget {
|
||||||
|
const _InfoTile({required this.child, required this.label});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 58,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
Color _accentFor(TaskVisualKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen,
|
||||||
|
TaskVisualKind.required => FocusFlowTokens.requiredPink,
|
||||||
|
TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue,
|
||||||
|
TaskVisualKind.freeSlot => FocusFlowTokens.restPurple,
|
||||||
|
TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
class _ModalStatusButton extends StatelessWidget {
|
||||||
|
const _ModalStatusButton({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ring = Container(
|
||||||
|
key: const ValueKey('task-modal-status'),
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: card.isCompleted ? color : Colors.transparent,
|
||||||
|
border: Border.all(color: color, width: 4),
|
||||||
|
),
|
||||||
|
child: card.isCompleted
|
||||||
|
? const Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: FocusFlowTokens.appBackground,
|
||||||
|
size: 26,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (onPressed == null) {
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
return Tooltip(
|
||||||
|
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: card.isCompleted
|
||||||
|
? 'Mark ${card.title} not done'
|
||||||
|
: 'Mark ${card.title} complete',
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onPressed,
|
||||||
|
child: ring,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,12 @@ import '../theme/focus_flow_tokens.dart';
|
||||||
import 'timeline/difficulty_bars.dart';
|
import 'timeline/difficulty_bars.dart';
|
||||||
import 'timeline/reward_icon.dart';
|
import 'timeline/reward_icon.dart';
|
||||||
|
|
||||||
|
part 'task_selection/action_button.dart';
|
||||||
|
part 'task_selection/header_metadata.dart';
|
||||||
|
part 'task_selection/info_tile.dart';
|
||||||
|
part 'task_selection/modal_accent.dart';
|
||||||
|
part 'task_selection/modal_status_button.dart';
|
||||||
|
|
||||||
/// Modal shown when a selectable timeline task card is opened.
|
/// Modal shown when a selectable timeline task card is opened.
|
||||||
class TaskSelectionModal extends StatelessWidget {
|
class TaskSelectionModal extends StatelessWidget {
|
||||||
/// Creates a task selection modal for [card].
|
/// Creates a task selection modal for [card].
|
||||||
|
|
@ -131,168 +137,3 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ModalStatusButton extends StatelessWidget {
|
|
||||||
const _ModalStatusButton({
|
|
||||||
required this.card,
|
|
||||||
required this.color,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final Color color;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final ring = Container(
|
|
||||||
key: const ValueKey('task-modal-status'),
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
|
||||||
border: Border.all(color: color, width: 4),
|
|
||||||
),
|
|
||||||
child: card.isCompleted
|
|
||||||
? const Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: FocusFlowTokens.appBackground,
|
|
||||||
size: 26,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
if (onPressed == null) {
|
|
||||||
return ring;
|
|
||||||
}
|
|
||||||
return Tooltip(
|
|
||||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
|
||||||
child: Semantics(
|
|
||||||
button: true,
|
|
||||||
label: card.isCompleted
|
|
||||||
? 'Mark ${card.title} not done'
|
|
||||||
: 'Mark ${card.title} complete',
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: onPressed,
|
|
||||||
child: ring,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HeaderMetadata extends StatelessWidget {
|
|
||||||
const _HeaderMetadata({required this.card});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
const metadataStyle = TextStyle(
|
|
||||||
color: FocusFlowTokens.textMuted,
|
|
||||||
fontSize: 17,
|
|
||||||
);
|
|
||||||
if (!card.isCompleted) {
|
|
||||||
return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle);
|
|
||||||
}
|
|
||||||
final completedTime = card.completedTimeText;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
completedTime == null || completedTime.isEmpty
|
|
||||||
? 'Completed'
|
|
||||||
: 'Completed - $completedTime',
|
|
||||||
style: metadataStyle,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
'Planned: ${card.timeText}',
|
|
||||||
style: metadataStyle.copyWith(fontSize: 15),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoTile extends StatelessWidget {
|
|
||||||
const _InfoTile({required this.child, required this.label});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: 58,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
|
|
||||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ActionButton extends StatelessWidget {
|
|
||||||
const _ActionButton({required this.icon, required this.label});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return OutlinedButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
|
||||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
FocusFlowTokens.smallButtonRadius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: Icon(icon),
|
|
||||||
label: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: FocusFlowTokens.modalButtonSize,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _accentFor(TaskVisualKind kind) {
|
|
||||||
return switch (kind) {
|
|
||||||
TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen,
|
|
||||||
TaskVisualKind.required => FocusFlowTokens.requiredPink,
|
|
||||||
TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue,
|
|
||||||
TaskVisualKind.freeSlot => FocusFlowTokens.restPurple,
|
|
||||||
TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
part of '../timeline_axis.dart';
|
||||||
|
|
||||||
|
/// Background grid aligned to timeline tick marks.
|
||||||
|
class TimelineGrid extends StatelessWidget {
|
||||||
|
/// Creates a timeline grid for [geometry].
|
||||||
|
const TimelineGrid({required this.geometry, this.topInset = 12, super.key});
|
||||||
|
|
||||||
|
/// Geometry used to position grid lines.
|
||||||
|
final TimelineGeometry geometry;
|
||||||
|
|
||||||
|
/// Vertical inset before the first timeline tick.
|
||||||
|
final double topInset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
part of '../timeline_axis.dart';
|
||||||
|
|
||||||
|
class _TimelineGridPainter extends CustomPainter {
|
||||||
|
const _TimelineGridPainter({required this.geometry, required this.topInset});
|
||||||
|
|
||||||
|
final TimelineGeometry geometry;
|
||||||
|
final double topInset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = FocusFlowTokens.gridLine
|
||||||
|
..strokeWidth = 1;
|
||||||
|
for (final tick in geometry.ticks()) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(0, tick.y + topInset),
|
||||||
|
Offset(size.width, tick.y + topInset),
|
||||||
|
paint
|
||||||
|
..color = tick.major
|
||||||
|
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
|
||||||
|
: FocusFlowTokens.gridLine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
|
||||||
|
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _CardColors {
|
||||||
|
const _CardColors({
|
||||||
|
required this.accent,
|
||||||
|
required this.background,
|
||||||
|
required this.reward,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color accent;
|
||||||
|
final Color background;
|
||||||
|
final Color reward;
|
||||||
|
|
||||||
|
factory _CardColors.forKind(TaskVisualKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
TaskVisualKind.flexible => const _CardColors(
|
||||||
|
accent: FocusFlowTokens.flexibleGreen,
|
||||||
|
background: Color(0xCC021A0C),
|
||||||
|
reward: FocusFlowTokens.flexibleGreen,
|
||||||
|
),
|
||||||
|
TaskVisualKind.required => const _CardColors(
|
||||||
|
accent: FocusFlowTokens.requiredPink,
|
||||||
|
background: Color(0xCC230814),
|
||||||
|
reward: FocusFlowTokens.requiredPink,
|
||||||
|
),
|
||||||
|
TaskVisualKind.appointment => const _CardColors(
|
||||||
|
accent: FocusFlowTokens.appointmentBlue,
|
||||||
|
background: Color(0xCC031429),
|
||||||
|
reward: FocusFlowTokens.appointmentBlue,
|
||||||
|
),
|
||||||
|
TaskVisualKind.freeSlot => const _CardColors(
|
||||||
|
accent: FocusFlowTokens.restPurple,
|
||||||
|
background: Color(0xCC210A33),
|
||||||
|
reward: FocusFlowTokens.restPurple,
|
||||||
|
),
|
||||||
|
TaskVisualKind.completedSurprise => const _CardColors(
|
||||||
|
accent: FocusFlowTokens.completedGray,
|
||||||
|
background: Color(0xAA1C1D25),
|
||||||
|
reward: FocusFlowTokens.restPurple,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _CardMetrics {
|
||||||
|
const _CardMetrics({required this.scale, required this.height});
|
||||||
|
|
||||||
|
static const _referenceWidth = 640.0;
|
||||||
|
static const _referenceHeight = 88.0;
|
||||||
|
static const _freeSlotReferenceHeight = 108.0;
|
||||||
|
static const _compactHeightThreshold = 56.0;
|
||||||
|
static const _minimumScale = 0.16;
|
||||||
|
|
||||||
|
factory _CardMetrics.fromConstraints(
|
||||||
|
BoxConstraints constraints, {
|
||||||
|
required TaskVisualKind kind,
|
||||||
|
}) {
|
||||||
|
final width = constraints.hasBoundedWidth
|
||||||
|
? constraints.maxWidth
|
||||||
|
: _referenceWidth;
|
||||||
|
final height = constraints.hasBoundedHeight
|
||||||
|
? constraints.maxHeight
|
||||||
|
: _referenceHeight;
|
||||||
|
final widthScale = width / _referenceWidth;
|
||||||
|
final heightScale =
|
||||||
|
height /
|
||||||
|
(kind == TaskVisualKind.freeSlot
|
||||||
|
? _freeSlotReferenceHeight
|
||||||
|
: _referenceHeight);
|
||||||
|
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
|
||||||
|
return _CardMetrics(scale: scale.toDouble(), height: height);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double scale;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
bool get isCompact => height < _compactHeightThreshold;
|
||||||
|
|
||||||
|
double get cardRadius =>
|
||||||
|
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
|
||||||
|
double get borderWidth => math.max(0.75, 1.2 * scale);
|
||||||
|
double get shadowBlur => isCompact ? 0 : 18 * scale;
|
||||||
|
double get shadowSpread => isCompact ? 0 : scale;
|
||||||
|
double get statusRingSize {
|
||||||
|
final availableHeight = math.max(8, height - padding.vertical);
|
||||||
|
return math.min(24, availableHeight * 0.78).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
|
||||||
|
double get statusIconSize => statusRingSize * 0.58;
|
||||||
|
double get statusGap => isCompact ? 7 : 22 * scale;
|
||||||
|
double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
|
||||||
|
double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
|
||||||
|
double get actionGap => math.max(3, 5 * scale);
|
||||||
|
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
||||||
|
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
|
||||||
|
double get indicatorGap => math.max(3, 5 * scale);
|
||||||
|
double get rewardIconSize => 14;
|
||||||
|
double get titleSize => 10;
|
||||||
|
double get metaSize => 7;
|
||||||
|
double get titleMetaGap => 4 * scale;
|
||||||
|
double get freeSlotTimeGap => 10 * scale;
|
||||||
|
double get compactTextGap => math.max(3, 8 * scale);
|
||||||
|
double get indicatorWidth =>
|
||||||
|
rewardIconSize + indicatorGap + difficultySize.width;
|
||||||
|
double get expandedTrailingReserve =>
|
||||||
|
indicatorWidth + math.max(8, 12 * scale);
|
||||||
|
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
||||||
|
Size get difficultySize => Size(26, 14);
|
||||||
|
EdgeInsets get padding {
|
||||||
|
if (isCompact) {
|
||||||
|
return EdgeInsets.symmetric(
|
||||||
|
horizontal: math.max(4, 8 * scale),
|
||||||
|
vertical: math.max(1, 3 * scale),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _CardText extends StatelessWidget {
|
||||||
|
const _CardText({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final Color color;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final titleStyle = TextStyle(
|
||||||
|
color: card.isCompleted
|
||||||
|
? FocusFlowTokens.completedText
|
||||||
|
: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: metrics.titleSize,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
|
||||||
|
decorationColor: FocusFlowTokens.completedText,
|
||||||
|
decorationThickness: 2 * metrics.scale,
|
||||||
|
);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final textBlock = Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
card.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: titleStyle,
|
||||||
|
),
|
||||||
|
SizedBox(height: metrics.titleMetaGap),
|
||||||
|
Text(
|
||||||
|
card.subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: card.isCompleted ? FocusFlowTokens.completedText : color,
|
||||||
|
fontSize: metrics.metaSize,
|
||||||
|
fontStyle: card.visualKind == TaskVisualKind.freeSlot
|
||||||
|
? FontStyle.italic
|
||||||
|
: FontStyle.normal,
|
||||||
|
fontWeight: card.visualKind == TaskVisualKind.flexible
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (card.visualKind == TaskVisualKind.freeSlot) ...[
|
||||||
|
SizedBox(height: metrics.freeSlotTimeGap),
|
||||||
|
Text(
|
||||||
|
card.timeText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: metrics.metaSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||||
|
return textBlock;
|
||||||
|
}
|
||||||
|
return FittedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: SizedBox(width: constraints.maxWidth, child: textBlock),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _CompactCardText extends StatelessWidget {
|
||||||
|
const _CompactCardText({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
required this.onStatusPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final Color color;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final metaText = _compactMetaText(card);
|
||||||
|
final titleStyle = TextStyle(
|
||||||
|
color: card.isCompleted
|
||||||
|
? FocusFlowTokens.completedText
|
||||||
|
: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: metrics.titleSize,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
|
||||||
|
decorationColor: FocusFlowTokens.completedText,
|
||||||
|
decorationThickness: math.max(1, 2 * metrics.scale),
|
||||||
|
);
|
||||||
|
final metaStyle = TextStyle(
|
||||||
|
color: card.isCompleted ? FocusFlowTokens.completedText : color,
|
||||||
|
fontSize: metrics.metaSize,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final line = Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
||||||
|
_StatusRing(
|
||||||
|
card: card,
|
||||||
|
color: color,
|
||||||
|
metrics: metrics,
|
||||||
|
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.statusGap),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: Text(
|
||||||
|
card.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: titleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.compactTextGap),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: Text(
|
||||||
|
metaText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.compactTrailingReserve),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return FittedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: SizedBox(width: constraints.maxWidth, child: line),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _compactMetaText(TimelineCardModel card) {
|
||||||
|
if (card.visualKind == TaskVisualKind.freeSlot &&
|
||||||
|
card.timeText.isNotEmpty) {
|
||||||
|
return card.timeText;
|
||||||
|
}
|
||||||
|
if (card.subtitle.isNotEmpty) {
|
||||||
|
return card.subtitle;
|
||||||
|
}
|
||||||
|
return card.timeText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _ExpandedCardContent extends StatelessWidget {
|
||||||
|
const _ExpandedCardContent({
|
||||||
|
required this.card,
|
||||||
|
required this.colors,
|
||||||
|
required this.metrics,
|
||||||
|
required this.onStatusPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final _CardColors colors;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
||||||
|
_StatusRing(
|
||||||
|
card: card,
|
||||||
|
color: colors.accent,
|
||||||
|
metrics: metrics,
|
||||||
|
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.statusGap),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
||||||
|
child: _CardText(
|
||||||
|
card: card,
|
||||||
|
color: colors.accent,
|
||||||
|
metrics: metrics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _NoOpIcon extends StatelessWidget {
|
||||||
|
const _NoOpIcon({
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: 'No-op action',
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: metrics.actionButtonSize,
|
||||||
|
child: Icon(icon, color: color, size: metrics.actionIconSize),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _QuickActions extends StatelessWidget {
|
||||||
|
const _QuickActions({
|
||||||
|
required this.color,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.metrics,
|
||||||
|
required this.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExcludeSemantics(
|
||||||
|
excluding: !visible,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !visible,
|
||||||
|
child: ClipRect(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
width: visible ? metrics.actionsWidth : 0,
|
||||||
|
height: metrics.actionButtonSize,
|
||||||
|
decoration: BoxDecoration(color: backgroundColor),
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: OverflowBox(
|
||||||
|
minWidth: metrics.actionsWidth,
|
||||||
|
maxWidth: metrics.actionsWidth,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 90),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_NoOpIcon(
|
||||||
|
icon: Icons.check,
|
||||||
|
color: color,
|
||||||
|
metrics: metrics,
|
||||||
|
),
|
||||||
|
_NoOpIcon(
|
||||||
|
icon: Icons.arrow_forward,
|
||||||
|
color: color,
|
||||||
|
metrics: metrics,
|
||||||
|
),
|
||||||
|
_NoOpIcon(
|
||||||
|
icon: Icons.calendar_today,
|
||||||
|
color: color,
|
||||||
|
metrics: metrics,
|
||||||
|
),
|
||||||
|
_NoOpIcon(
|
||||||
|
icon: Icons.wb_sunny_outlined,
|
||||||
|
color: color,
|
||||||
|
metrics: metrics,
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.actionGap),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _StatusRing extends StatelessWidget {
|
||||||
|
const _StatusRing({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final Color color;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final border = Border.all(color: color, width: metrics.statusBorderWidth);
|
||||||
|
final ring = Container(
|
||||||
|
key: ValueKey('${card.id}-status'),
|
||||||
|
width: metrics.statusRingSize,
|
||||||
|
height: metrics.statusRingSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: card.isCompleted ? color : Colors.transparent,
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
child: card.isCompleted
|
||||||
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: FocusFlowTokens.appBackground,
|
||||||
|
size: metrics.statusIconSize,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (onPressed == null) {
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
return Tooltip(
|
||||||
|
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: card.isCompleted
|
||||||
|
? 'Mark ${card.title} not done'
|
||||||
|
: 'Mark ${card.title} complete',
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onPressed,
|
||||||
|
child: ring,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final card = widget.card;
|
||||||
|
final colors = _CardColors.forKind(card.visualKind);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final metrics = _CardMetrics.fromConstraints(
|
||||||
|
constraints,
|
||||||
|
kind: card.visualKind,
|
||||||
|
);
|
||||||
|
final content = SizedBox.expand(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: metrics.isCompact
|
||||||
|
? _CompactCardText(
|
||||||
|
card: card,
|
||||||
|
color: colors.accent,
|
||||||
|
metrics: metrics,
|
||||||
|
onStatusPressed: widget.onStatusPressed,
|
||||||
|
)
|
||||||
|
: _ExpandedCardContent(
|
||||||
|
card: card,
|
||||||
|
colors: colors,
|
||||||
|
metrics: metrics,
|
||||||
|
onStatusPressed: widget.onStatusPressed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: metrics.indicatorTop,
|
||||||
|
right: 0,
|
||||||
|
child: _TrailingControls(
|
||||||
|
card: card,
|
||||||
|
colors: colors,
|
||||||
|
metrics: metrics,
|
||||||
|
actionsVisible: card.showsQuickActions && _isHovered,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
key: ValueKey(card.id),
|
||||||
|
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: metrics.padding,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.background,
|
||||||
|
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: colors.accent,
|
||||||
|
width: metrics.borderWidth,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.accent.withValues(alpha: 0.12),
|
||||||
|
blurRadius: metrics.shadowBlur,
|
||||||
|
spreadRadius: metrics.shadowSpread,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
|
class _TrailingControls extends StatelessWidget {
|
||||||
|
const _TrailingControls({
|
||||||
|
required this.card,
|
||||||
|
required this.colors,
|
||||||
|
required this.metrics,
|
||||||
|
required this.actionsVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final _CardColors colors;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
final bool actionsVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (card.showsQuickActions)
|
||||||
|
_QuickActions(
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.background.withValues(alpha: 1),
|
||||||
|
metrics: metrics,
|
||||||
|
visible: actionsVisible,
|
||||||
|
),
|
||||||
|
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
||||||
|
SizedBox(width: metrics.indicatorGap),
|
||||||
|
DifficultyBars(
|
||||||
|
difficulty: card.difficultyIconToken,
|
||||||
|
color: colors.reward,
|
||||||
|
size: metrics.difficultySize,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,17 @@ import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'difficulty_bars.dart';
|
import 'difficulty_bars.dart';
|
||||||
import 'reward_icon.dart';
|
import 'reward_icon.dart';
|
||||||
|
|
||||||
|
part 'task_card/card_colors.dart';
|
||||||
|
part 'task_card/card_metrics.dart';
|
||||||
|
part 'task_card/card_text.dart';
|
||||||
|
part 'task_card/compact_card_text.dart';
|
||||||
|
part 'task_card/expanded_card_content.dart';
|
||||||
|
part 'task_card/no_op_icon.dart';
|
||||||
|
part 'task_card/quick_actions.dart';
|
||||||
|
part 'task_card/status_ring.dart';
|
||||||
|
part 'task_card/task_timeline_card_state.dart';
|
||||||
|
part 'task_card/trailing_controls.dart';
|
||||||
|
|
||||||
/// Interactive card that renders a scheduled task on the compact timeline.
|
/// Interactive card that renders a scheduled task on the compact timeline.
|
||||||
class TaskTimelineCard extends StatefulWidget {
|
class TaskTimelineCard extends StatefulWidget {
|
||||||
/// Creates a task timeline card.
|
/// Creates a task timeline card.
|
||||||
|
|
@ -32,613 +43,3 @@ class TaskTimelineCard extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
|
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|
||||||
bool _isHovered = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final card = widget.card;
|
|
||||||
final colors = _CardColors.forKind(card.visualKind);
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final metrics = _CardMetrics.fromConstraints(
|
|
||||||
constraints,
|
|
||||||
kind: card.visualKind,
|
|
||||||
);
|
|
||||||
final content = SizedBox.expand(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: metrics.isCompact
|
|
||||||
? _CompactCardText(
|
|
||||||
card: card,
|
|
||||||
color: colors.accent,
|
|
||||||
metrics: metrics,
|
|
||||||
onStatusPressed: widget.onStatusPressed,
|
|
||||||
)
|
|
||||||
: _ExpandedCardContent(
|
|
||||||
card: card,
|
|
||||||
colors: colors,
|
|
||||||
metrics: metrics,
|
|
||||||
onStatusPressed: widget.onStatusPressed,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: metrics.indicatorTop,
|
|
||||||
right: 0,
|
|
||||||
child: _TrailingControls(
|
|
||||||
card: card,
|
|
||||||
colors: colors,
|
|
||||||
metrics: metrics,
|
|
||||||
actionsVisible: card.showsQuickActions && _isHovered,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) {
|
|
||||||
setState(() {
|
|
||||||
_isHovered = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onExit: (_) {
|
|
||||||
setState(() {
|
|
||||||
_isHovered = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
key: ValueKey(card.id),
|
|
||||||
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
|
||||||
onTap: widget.onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: metrics.padding,
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.background,
|
|
||||||
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
|
||||||
border: Border.all(
|
|
||||||
color: colors.accent,
|
|
||||||
width: metrics.borderWidth,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colors.accent.withValues(alpha: 0.12),
|
|
||||||
blurRadius: metrics.shadowBlur,
|
|
||||||
spreadRadius: metrics.shadowSpread,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: content,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CardMetrics {
|
|
||||||
const _CardMetrics({required this.scale, required this.height});
|
|
||||||
|
|
||||||
static const _referenceWidth = 640.0;
|
|
||||||
static const _referenceHeight = 88.0;
|
|
||||||
static const _freeSlotReferenceHeight = 108.0;
|
|
||||||
static const _compactHeightThreshold = 56.0;
|
|
||||||
static const _minimumScale = 0.16;
|
|
||||||
|
|
||||||
factory _CardMetrics.fromConstraints(
|
|
||||||
BoxConstraints constraints, {
|
|
||||||
required TaskVisualKind kind,
|
|
||||||
}) {
|
|
||||||
final width = constraints.hasBoundedWidth
|
|
||||||
? constraints.maxWidth
|
|
||||||
: _referenceWidth;
|
|
||||||
final height = constraints.hasBoundedHeight
|
|
||||||
? constraints.maxHeight
|
|
||||||
: _referenceHeight;
|
|
||||||
final widthScale = width / _referenceWidth;
|
|
||||||
final heightScale =
|
|
||||||
height /
|
|
||||||
(kind == TaskVisualKind.freeSlot
|
|
||||||
? _freeSlotReferenceHeight
|
|
||||||
: _referenceHeight);
|
|
||||||
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
|
|
||||||
return _CardMetrics(scale: scale.toDouble(), height: height);
|
|
||||||
}
|
|
||||||
|
|
||||||
final double scale;
|
|
||||||
final double height;
|
|
||||||
|
|
||||||
bool get isCompact => height < _compactHeightThreshold;
|
|
||||||
|
|
||||||
double get cardRadius =>
|
|
||||||
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
|
|
||||||
double get borderWidth => math.max(0.75, 1.2 * scale);
|
|
||||||
double get shadowBlur => isCompact ? 0 : 18 * scale;
|
|
||||||
double get shadowSpread => isCompact ? 0 : scale;
|
|
||||||
double get statusRingSize {
|
|
||||||
final availableHeight = math.max(8, height - padding.vertical);
|
|
||||||
return math.min(24, availableHeight * 0.78).toDouble();
|
|
||||||
}
|
|
||||||
|
|
||||||
double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
|
|
||||||
double get statusIconSize => statusRingSize * 0.58;
|
|
||||||
double get statusGap => isCompact ? 7 : 22 * scale;
|
|
||||||
double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
|
|
||||||
double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
|
|
||||||
double get actionGap => math.max(3, 5 * scale);
|
|
||||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
|
||||||
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
|
|
||||||
double get indicatorGap => math.max(3, 5 * scale);
|
|
||||||
double get rewardIconSize => 14;
|
|
||||||
double get titleSize => 10;
|
|
||||||
double get metaSize => 7;
|
|
||||||
double get titleMetaGap => 4 * scale;
|
|
||||||
double get freeSlotTimeGap => 10 * scale;
|
|
||||||
double get compactTextGap => math.max(3, 8 * scale);
|
|
||||||
double get indicatorWidth =>
|
|
||||||
rewardIconSize + indicatorGap + difficultySize.width;
|
|
||||||
double get expandedTrailingReserve =>
|
|
||||||
indicatorWidth + math.max(8, 12 * scale);
|
|
||||||
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
|
||||||
Size get difficultySize => Size(26, 14);
|
|
||||||
EdgeInsets get padding {
|
|
||||||
if (isCompact) {
|
|
||||||
return EdgeInsets.symmetric(
|
|
||||||
horizontal: math.max(4, 8 * scale),
|
|
||||||
vertical: math.max(1, 3 * scale),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExpandedCardContent extends StatelessWidget {
|
|
||||||
const _ExpandedCardContent({
|
|
||||||
required this.card,
|
|
||||||
required this.colors,
|
|
||||||
required this.metrics,
|
|
||||||
required this.onStatusPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final _CardColors colors;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
final VoidCallback? onStatusPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
|
||||||
_StatusRing(
|
|
||||||
card: card,
|
|
||||||
color: colors.accent,
|
|
||||||
metrics: metrics,
|
|
||||||
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
|
||||||
),
|
|
||||||
SizedBox(width: metrics.statusGap),
|
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
|
||||||
child: _CardText(
|
|
||||||
card: card,
|
|
||||||
color: colors.accent,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusRing extends StatelessWidget {
|
|
||||||
const _StatusRing({
|
|
||||||
required this.card,
|
|
||||||
required this.color,
|
|
||||||
required this.metrics,
|
|
||||||
this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final Color color;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final border = Border.all(color: color, width: metrics.statusBorderWidth);
|
|
||||||
final ring = Container(
|
|
||||||
key: ValueKey('${card.id}-status'),
|
|
||||||
width: metrics.statusRingSize,
|
|
||||||
height: metrics.statusRingSize,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
|
||||||
border: border,
|
|
||||||
),
|
|
||||||
child: card.isCompleted
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: FocusFlowTokens.appBackground,
|
|
||||||
size: metrics.statusIconSize,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
if (onPressed == null) {
|
|
||||||
return ring;
|
|
||||||
}
|
|
||||||
return Tooltip(
|
|
||||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
|
||||||
child: Semantics(
|
|
||||||
button: true,
|
|
||||||
label: card.isCompleted
|
|
||||||
? 'Mark ${card.title} not done'
|
|
||||||
: 'Mark ${card.title} complete',
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: onPressed,
|
|
||||||
child: ring,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CardText extends StatelessWidget {
|
|
||||||
const _CardText({
|
|
||||||
required this.card,
|
|
||||||
required this.color,
|
|
||||||
required this.metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final Color color;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final titleStyle = TextStyle(
|
|
||||||
color: card.isCompleted
|
|
||||||
? FocusFlowTokens.completedText
|
|
||||||
: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: metrics.titleSize,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
|
|
||||||
decorationColor: FocusFlowTokens.completedText,
|
|
||||||
decorationThickness: 2 * metrics.scale,
|
|
||||||
);
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final textBlock = Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
card.title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: titleStyle,
|
|
||||||
),
|
|
||||||
SizedBox(height: metrics.titleMetaGap),
|
|
||||||
Text(
|
|
||||||
card.subtitle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: card.isCompleted ? FocusFlowTokens.completedText : color,
|
|
||||||
fontSize: metrics.metaSize,
|
|
||||||
fontStyle: card.visualKind == TaskVisualKind.freeSlot
|
|
||||||
? FontStyle.italic
|
|
||||||
: FontStyle.normal,
|
|
||||||
fontWeight: card.visualKind == TaskVisualKind.flexible
|
|
||||||
? FontWeight.w700
|
|
||||||
: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (card.visualKind == TaskVisualKind.freeSlot) ...[
|
|
||||||
SizedBox(height: metrics.freeSlotTimeGap),
|
|
||||||
Text(
|
|
||||||
card.timeText,
|
|
||||||
style: TextStyle(
|
|
||||||
color: color,
|
|
||||||
fontSize: metrics.metaSize,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
|
||||||
return textBlock;
|
|
||||||
}
|
|
||||||
return FittedBox(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: SizedBox(width: constraints.maxWidth, child: textBlock),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompactCardText extends StatelessWidget {
|
|
||||||
const _CompactCardText({
|
|
||||||
required this.card,
|
|
||||||
required this.color,
|
|
||||||
required this.metrics,
|
|
||||||
required this.onStatusPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final Color color;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
final VoidCallback? onStatusPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final metaText = _compactMetaText(card);
|
|
||||||
final titleStyle = TextStyle(
|
|
||||||
color: card.isCompleted
|
|
||||||
? FocusFlowTokens.completedText
|
|
||||||
: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: metrics.titleSize,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
|
|
||||||
decorationColor: FocusFlowTokens.completedText,
|
|
||||||
decorationThickness: math.max(1, 2 * metrics.scale),
|
|
||||||
);
|
|
||||||
final metaStyle = TextStyle(
|
|
||||||
color: card.isCompleted ? FocusFlowTokens.completedText : color,
|
|
||||||
fontSize: metrics.metaSize,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
);
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final line = Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
|
||||||
_StatusRing(
|
|
||||||
card: card,
|
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
|
||||||
),
|
|
||||||
SizedBox(width: metrics.statusGap),
|
|
||||||
],
|
|
||||||
Flexible(
|
|
||||||
flex: 2,
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: Text(
|
|
||||||
card.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: titleStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: metrics.compactTextGap),
|
|
||||||
Flexible(
|
|
||||||
flex: 1,
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: Text(
|
|
||||||
metaText,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: metaStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: metrics.compactTrailingReserve),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
return FittedBox(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: SizedBox(width: constraints.maxWidth, child: line),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _compactMetaText(TimelineCardModel card) {
|
|
||||||
if (card.visualKind == TaskVisualKind.freeSlot &&
|
|
||||||
card.timeText.isNotEmpty) {
|
|
||||||
return card.timeText;
|
|
||||||
}
|
|
||||||
if (card.subtitle.isNotEmpty) {
|
|
||||||
return card.subtitle;
|
|
||||||
}
|
|
||||||
return card.timeText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TrailingControls extends StatelessWidget {
|
|
||||||
const _TrailingControls({
|
|
||||||
required this.card,
|
|
||||||
required this.colors,
|
|
||||||
required this.metrics,
|
|
||||||
required this.actionsVisible,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final _CardColors colors;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
final bool actionsVisible;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (card.showsQuickActions)
|
|
||||||
_QuickActions(
|
|
||||||
color: colors.accent,
|
|
||||||
backgroundColor: colors.background.withValues(alpha: 1),
|
|
||||||
metrics: metrics,
|
|
||||||
visible: actionsVisible,
|
|
||||||
),
|
|
||||||
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
|
||||||
SizedBox(width: metrics.indicatorGap),
|
|
||||||
DifficultyBars(
|
|
||||||
difficulty: card.difficultyIconToken,
|
|
||||||
color: colors.reward,
|
|
||||||
size: metrics.difficultySize,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuickActions extends StatelessWidget {
|
|
||||||
const _QuickActions({
|
|
||||||
required this.color,
|
|
||||||
required this.backgroundColor,
|
|
||||||
required this.metrics,
|
|
||||||
required this.visible,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Color color;
|
|
||||||
final Color backgroundColor;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
final bool visible;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ExcludeSemantics(
|
|
||||||
excluding: !visible,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !visible,
|
|
||||||
child: ClipRect(
|
|
||||||
child: AnimatedContainer(
|
|
||||||
width: visible ? metrics.actionsWidth : 0,
|
|
||||||
height: metrics.actionButtonSize,
|
|
||||||
decoration: BoxDecoration(color: backgroundColor),
|
|
||||||
duration: const Duration(milliseconds: 120),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
child: OverflowBox(
|
|
||||||
minWidth: metrics.actionsWidth,
|
|
||||||
maxWidth: metrics.actionsWidth,
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
duration: const Duration(milliseconds: 90),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.check,
|
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.arrow_forward,
|
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.calendar_today,
|
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.wb_sunny_outlined,
|
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
SizedBox(width: metrics.actionGap),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NoOpIcon extends StatelessWidget {
|
|
||||||
const _NoOpIcon({
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
required this.metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
final _CardMetrics metrics;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Tooltip(
|
|
||||||
message: 'No-op action',
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {},
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
child: SizedBox.square(
|
|
||||||
dimension: metrics.actionButtonSize,
|
|
||||||
child: Icon(icon, color: color, size: metrics.actionIconSize),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CardColors {
|
|
||||||
const _CardColors({
|
|
||||||
required this.accent,
|
|
||||||
required this.background,
|
|
||||||
required this.reward,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Color accent;
|
|
||||||
final Color background;
|
|
||||||
final Color reward;
|
|
||||||
|
|
||||||
factory _CardColors.forKind(TaskVisualKind kind) {
|
|
||||||
return switch (kind) {
|
|
||||||
TaskVisualKind.flexible => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.flexibleGreen,
|
|
||||||
background: Color(0xCC021A0C),
|
|
||||||
reward: FocusFlowTokens.flexibleGreen,
|
|
||||||
),
|
|
||||||
TaskVisualKind.required => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.requiredPink,
|
|
||||||
background: Color(0xCC230814),
|
|
||||||
reward: FocusFlowTokens.requiredPink,
|
|
||||||
),
|
|
||||||
TaskVisualKind.appointment => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.appointmentBlue,
|
|
||||||
background: Color(0xCC031429),
|
|
||||||
reward: FocusFlowTokens.appointmentBlue,
|
|
||||||
),
|
|
||||||
TaskVisualKind.freeSlot => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.restPurple,
|
|
||||||
background: Color(0xCC210A33),
|
|
||||||
reward: FocusFlowTokens.restPurple,
|
|
||||||
),
|
|
||||||
TaskVisualKind.completedSurprise => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.completedGray,
|
|
||||||
background: Color(0xAA1C1D25),
|
|
||||||
reward: FocusFlowTokens.restPurple,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import 'package:flutter/material.dart';
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'timeline_geometry.dart';
|
import 'timeline_geometry.dart';
|
||||||
|
|
||||||
|
part 'axis/timeline_grid.dart';
|
||||||
|
part 'axis/timeline_grid_painter.dart';
|
||||||
|
|
||||||
/// Time labels and rail shown beside the compact timeline.
|
/// Time labels and rail shown beside the compact timeline.
|
||||||
class TimelineAxis extends StatelessWidget {
|
class TimelineAxis extends StatelessWidget {
|
||||||
/// Creates a timeline axis for [geometry].
|
/// Creates a timeline axis for [geometry].
|
||||||
|
|
@ -68,52 +71,3 @@ class TimelineAxis extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background grid aligned to timeline tick marks.
|
|
||||||
class TimelineGrid extends StatelessWidget {
|
|
||||||
/// Creates a timeline grid for [geometry].
|
|
||||||
const TimelineGrid({required this.geometry, this.topInset = 12, super.key});
|
|
||||||
|
|
||||||
/// Geometry used to position grid lines.
|
|
||||||
final TimelineGeometry geometry;
|
|
||||||
|
|
||||||
/// Vertical inset before the first timeline tick.
|
|
||||||
final double topInset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CustomPaint(
|
|
||||||
painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
|
|
||||||
size: Size.infinite,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimelineGridPainter extends CustomPainter {
|
|
||||||
const _TimelineGridPainter({required this.geometry, required this.topInset});
|
|
||||||
|
|
||||||
final TimelineGeometry geometry;
|
|
||||||
final double topInset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final paint = Paint()
|
|
||||||
..color = FocusFlowTokens.gridLine
|
|
||||||
..strokeWidth = 1;
|
|
||||||
for (final tick in geometry.ticks()) {
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(0, tick.y + topInset),
|
|
||||||
Offset(size.width, tick.y + topInset),
|
|
||||||
paint
|
|
||||||
..color = tick.major
|
|
||||||
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
|
|
||||||
: FocusFlowTokens.gridLine,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
|
|
||||||
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import 'task_timeline_card.dart';
|
||||||
import 'timeline_axis.dart';
|
import 'timeline_axis.dart';
|
||||||
import 'timeline_geometry.dart';
|
import 'timeline_geometry.dart';
|
||||||
|
|
||||||
|
part 'timeline_view/timeline_card_placement.dart';
|
||||||
|
part 'timeline_view/timeline_scroll_behavior.dart';
|
||||||
|
part 'timeline_view/timeline_view_state.dart';
|
||||||
|
|
||||||
/// Scrollable compact timeline view for Today cards.
|
/// Scrollable compact timeline view for Today cards.
|
||||||
class TimelineView extends StatefulWidget {
|
class TimelineView extends StatefulWidget {
|
||||||
/// Creates a timeline view.
|
/// Creates a timeline view.
|
||||||
|
|
@ -48,374 +52,3 @@ class TimelineView extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
State<TimelineView> createState() => _TimelineViewState();
|
State<TimelineView> createState() => _TimelineViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TimelineViewState extends State<TimelineView> {
|
|
||||||
static const _topInset = 12.0;
|
|
||||||
|
|
||||||
final _scrollController = ScrollController();
|
|
||||||
bool _didSetInitialScroll = false;
|
|
||||||
var _handledScrollRequest = 0;
|
|
||||||
late TimelineGeometry _geometry;
|
|
||||||
late double _contentHeight;
|
|
||||||
late List<_TimelineCardPlacement> _placements;
|
|
||||||
late int _cardsLayoutHash;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_updateLayoutCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant TimelineView oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
final rangeChanged =
|
|
||||||
oldWidget.range.startMinutes != widget.range.startMinutes ||
|
|
||||||
oldWidget.range.endMinutes != widget.range.endMinutes;
|
|
||||||
if (rangeChanged) {
|
|
||||||
_didSetInitialScroll = false;
|
|
||||||
}
|
|
||||||
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
|
|
||||||
if (rangeChanged || layoutChanged) {
|
|
||||||
_updateLayoutCache();
|
|
||||||
} else if (!identical(oldWidget.cards, widget.cards)) {
|
|
||||||
_refreshPlacementCards();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
|
||||||
_scheduleTargetScroll(_geometry);
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _TimelineScrollBehavior(),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
controller: _scrollController,
|
|
||||||
child: SizedBox(
|
|
||||||
height: _contentHeight,
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
RepaintBoundary(
|
|
||||||
child: TimelineAxis(
|
|
||||||
geometry: _geometry,
|
|
||||||
topInset: _topInset,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, cardConstraints) {
|
|
||||||
final trackWidth = cardConstraints.maxWidth;
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: TimelineGrid(
|
|
||||||
geometry: _geometry,
|
|
||||||
topInset: _topInset,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final placement in _placements)
|
|
||||||
Positioned(
|
|
||||||
top: placement.top + _topInset,
|
|
||||||
left: placement.left(trackWidth),
|
|
||||||
width: placement.width(trackWidth),
|
|
||||||
height: placement.height,
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: TaskTimelineCard(
|
|
||||||
card: placement.card,
|
|
||||||
onTap: () =>
|
|
||||||
widget.onCardSelected(placement.card),
|
|
||||||
onStatusPressed:
|
|
||||||
widget.onCardCompleted == null
|
|
||||||
? null
|
|
||||||
: () => widget.onCardCompleted!(
|
|
||||||
placement.card,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateLayoutCache() {
|
|
||||||
_geometry = TimelineGeometry(
|
|
||||||
startMinutes: widget.range.startMinutes,
|
|
||||||
endMinutes: widget.range.endMinutes,
|
|
||||||
pixelsPerMinute: 2.45,
|
|
||||||
);
|
|
||||||
_contentHeight = _geometry.totalHeight + _topInset + 24;
|
|
||||||
_placements = _TimelineCardPlacement.pack(
|
|
||||||
widget.cards,
|
|
||||||
geometry: _geometry,
|
|
||||||
);
|
|
||||||
_cardsLayoutHash = _layoutHashFor(widget.cards);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _layoutHashFor(List<TimelineCardModel> cards) {
|
|
||||||
return Object.hashAll(
|
|
||||||
cards.map(
|
|
||||||
(card) => Object.hash(card.id, card.startMinutes, card.endMinutes),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refreshPlacementCards() {
|
|
||||||
final cardsById = {for (final card in widget.cards) card.id: card};
|
|
||||||
_placements = [
|
|
||||||
for (final placement in _placements)
|
|
||||||
placement.withCard(cardsById[placement.card.id] ?? placement.card),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleInitialScroll(
|
|
||||||
TimelineGeometry geometry,
|
|
||||||
double viewportHeight,
|
|
||||||
) {
|
|
||||||
if (_didSetInitialScroll || !viewportHeight.isFinite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted || !_scrollController.hasClients) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final now = widget.now?.call() ?? DateTime.now();
|
|
||||||
final currentMinute = now.hour * 60 + now.minute;
|
|
||||||
final clampedMinute = currentMinute
|
|
||||||
.clamp(geometry.startMinutes, geometry.endMinutes)
|
|
||||||
.toInt();
|
|
||||||
final centeredOffset =
|
|
||||||
geometry.yForMinutesSinceMidnight(clampedMinute) +
|
|
||||||
_topInset -
|
|
||||||
viewportHeight / 2;
|
|
||||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
||||||
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
|
||||||
_scrollController.jumpTo(offset);
|
|
||||||
_didSetInitialScroll = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
|
||||||
final targetCardId = widget.scrollTargetCardId;
|
|
||||||
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted || !_scrollController.hasClients) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
TimelineCardModel? targetCard;
|
|
||||||
for (final card in widget.cards) {
|
|
||||||
if (card.id == targetCardId) {
|
|
||||||
targetCard = card;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targetCard == null) {
|
|
||||||
_handledScrollRequest = widget.scrollRequest;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final targetOffset =
|
|
||||||
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
|
|
||||||
_topInset -
|
|
||||||
24;
|
|
||||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
||||||
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
|
||||||
_scrollController.jumpTo(offset);
|
|
||||||
_handledScrollRequest = widget.scrollRequest;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimelineScrollBehavior extends MaterialScrollBehavior {
|
|
||||||
const _TimelineScrollBehavior();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<PointerDeviceKind> get dragDevices {
|
|
||||||
return {...super.dragDevices, PointerDeviceKind.mouse};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimelineCardPlacement {
|
|
||||||
const _TimelineCardPlacement({
|
|
||||||
required this.card,
|
|
||||||
required this.lane,
|
|
||||||
required this.laneCount,
|
|
||||||
required this.top,
|
|
||||||
required this.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const _laneGap = 8.0;
|
|
||||||
|
|
||||||
final TimelineCardModel card;
|
|
||||||
final int lane;
|
|
||||||
final int laneCount;
|
|
||||||
final double top;
|
|
||||||
final double height;
|
|
||||||
|
|
||||||
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
|
|
||||||
if (identical(card, nextCard)) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
return _TimelineCardPlacement(
|
|
||||||
card: nextCard,
|
|
||||||
lane: lane,
|
|
||||||
laneCount: laneCount,
|
|
||||||
top: top,
|
|
||||||
height: height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<_TimelineCardPlacement> pack(
|
|
||||||
List<TimelineCardModel> cards, {
|
|
||||||
required TimelineGeometry geometry,
|
|
||||||
}) {
|
|
||||||
if (cards.isEmpty) {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
final sorted = [...cards]
|
|
||||||
..sort((a, b) {
|
|
||||||
final startComparison = a.startMinutes.compareTo(b.startMinutes);
|
|
||||||
if (startComparison != 0) {
|
|
||||||
return startComparison;
|
|
||||||
}
|
|
||||||
final endComparison = a.endMinutes.compareTo(b.endMinutes);
|
|
||||||
if (endComparison != 0) {
|
|
||||||
return endComparison;
|
|
||||||
}
|
|
||||||
return a.id.compareTo(b.id);
|
|
||||||
});
|
|
||||||
final placements = <_TimelineCardPlacement>[];
|
|
||||||
var groupStart = 0;
|
|
||||||
var groupEnd = _visualEnd(sorted.first, geometry);
|
|
||||||
for (var index = 1; index < sorted.length; index += 1) {
|
|
||||||
final card = sorted[index];
|
|
||||||
final cardStart = _visualStart(card, geometry);
|
|
||||||
final cardEnd = _visualEnd(card, geometry);
|
|
||||||
if (cardStart < groupEnd) {
|
|
||||||
if (cardEnd > groupEnd) {
|
|
||||||
groupEnd = cardEnd;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
placements.addAll(
|
|
||||||
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
|
|
||||||
);
|
|
||||||
groupStart = index;
|
|
||||||
groupEnd = cardEnd;
|
|
||||||
}
|
|
||||||
placements.addAll(
|
|
||||||
_packGroup(sorted.sublist(groupStart), geometry: geometry),
|
|
||||||
);
|
|
||||||
return placements;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<_TimelineCardPlacement> _packGroup(
|
|
||||||
List<TimelineCardModel> group, {
|
|
||||||
required TimelineGeometry geometry,
|
|
||||||
}) {
|
|
||||||
final laneEnds = <double>[];
|
|
||||||
final laneByCard = <TimelineCardModel, int>{};
|
|
||||||
for (final card in group) {
|
|
||||||
final cardStart = _visualStart(card, geometry);
|
|
||||||
final cardEnd = _visualEnd(card, geometry);
|
|
||||||
var assignedLane = -1;
|
|
||||||
for (var lane = 0; lane < laneEnds.length; lane += 1) {
|
|
||||||
if (laneEnds[lane] <= cardStart) {
|
|
||||||
assignedLane = lane;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (assignedLane == -1) {
|
|
||||||
assignedLane = laneEnds.length;
|
|
||||||
laneEnds.add(cardEnd);
|
|
||||||
} else {
|
|
||||||
laneEnds[assignedLane] = cardEnd;
|
|
||||||
}
|
|
||||||
laneByCard[card] = assignedLane;
|
|
||||||
}
|
|
||||||
final laneCount = laneEnds.length;
|
|
||||||
return [
|
|
||||||
for (final card in group)
|
|
||||||
_TimelineCardPlacement(
|
|
||||||
card: card,
|
|
||||||
lane: laneByCard[card]!,
|
|
||||||
laneCount: laneCount,
|
|
||||||
top: _visualStart(card, geometry),
|
|
||||||
height: geometry.heightForDuration(
|
|
||||||
card.endMinutes - card.startMinutes,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _visualStart(
|
|
||||||
TimelineCardModel card,
|
|
||||||
TimelineGeometry geometry,
|
|
||||||
) {
|
|
||||||
return geometry.yForMinutesSinceMidnight(card.startMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
|
|
||||||
final duration = card.endMinutes - card.startMinutes;
|
|
||||||
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
double left(double trackWidth) {
|
|
||||||
final laneWidth = _laneWidth(trackWidth);
|
|
||||||
final laneGap = _laneGapForWidth(trackWidth);
|
|
||||||
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
|
|
||||||
}
|
|
||||||
|
|
||||||
double width(double trackWidth) {
|
|
||||||
return _laneWidth(trackWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
double _availableWidth(double trackWidth) {
|
|
||||||
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
|
|
||||||
if (availableWidth < 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return availableWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _laneWidth(double trackWidth) {
|
|
||||||
final availableWidth = _availableWidth(trackWidth);
|
|
||||||
final laneGap = _laneGapForWidth(trackWidth);
|
|
||||||
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _laneGapForWidth(double trackWidth) {
|
|
||||||
if (laneCount == 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
final availableWidth = _availableWidth(trackWidth);
|
|
||||||
final totalGap = _laneGap * (laneCount - 1);
|
|
||||||
if (availableWidth <= totalGap) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return _laneGap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
part of '../timeline_view.dart';
|
||||||
|
|
||||||
|
class _TimelineCardPlacement {
|
||||||
|
const _TimelineCardPlacement({
|
||||||
|
required this.card,
|
||||||
|
required this.lane,
|
||||||
|
required this.laneCount,
|
||||||
|
required this.top,
|
||||||
|
required this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _laneGap = 8.0;
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final int lane;
|
||||||
|
final int laneCount;
|
||||||
|
final double top;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
|
||||||
|
if (identical(card, nextCard)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return _TimelineCardPlacement(
|
||||||
|
card: nextCard,
|
||||||
|
lane: lane,
|
||||||
|
laneCount: laneCount,
|
||||||
|
top: top,
|
||||||
|
height: height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_TimelineCardPlacement> pack(
|
||||||
|
List<TimelineCardModel> cards, {
|
||||||
|
required TimelineGeometry geometry,
|
||||||
|
}) {
|
||||||
|
if (cards.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final sorted = [...cards]
|
||||||
|
..sort((a, b) {
|
||||||
|
final startComparison = a.startMinutes.compareTo(b.startMinutes);
|
||||||
|
if (startComparison != 0) {
|
||||||
|
return startComparison;
|
||||||
|
}
|
||||||
|
final endComparison = a.endMinutes.compareTo(b.endMinutes);
|
||||||
|
if (endComparison != 0) {
|
||||||
|
return endComparison;
|
||||||
|
}
|
||||||
|
return a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
|
final placements = <_TimelineCardPlacement>[];
|
||||||
|
var groupStart = 0;
|
||||||
|
var groupEnd = _visualEnd(sorted.first, geometry);
|
||||||
|
for (var index = 1; index < sorted.length; index += 1) {
|
||||||
|
final card = sorted[index];
|
||||||
|
final cardStart = _visualStart(card, geometry);
|
||||||
|
final cardEnd = _visualEnd(card, geometry);
|
||||||
|
if (cardStart < groupEnd) {
|
||||||
|
if (cardEnd > groupEnd) {
|
||||||
|
groupEnd = cardEnd;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placements.addAll(
|
||||||
|
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
|
||||||
|
);
|
||||||
|
groupStart = index;
|
||||||
|
groupEnd = cardEnd;
|
||||||
|
}
|
||||||
|
placements.addAll(
|
||||||
|
_packGroup(sorted.sublist(groupStart), geometry: geometry),
|
||||||
|
);
|
||||||
|
return placements;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_TimelineCardPlacement> _packGroup(
|
||||||
|
List<TimelineCardModel> group, {
|
||||||
|
required TimelineGeometry geometry,
|
||||||
|
}) {
|
||||||
|
final laneEnds = <double>[];
|
||||||
|
final laneByCard = <TimelineCardModel, int>{};
|
||||||
|
for (final card in group) {
|
||||||
|
final cardStart = _visualStart(card, geometry);
|
||||||
|
final cardEnd = _visualEnd(card, geometry);
|
||||||
|
var assignedLane = -1;
|
||||||
|
for (var lane = 0; lane < laneEnds.length; lane += 1) {
|
||||||
|
if (laneEnds[lane] <= cardStart) {
|
||||||
|
assignedLane = lane;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assignedLane == -1) {
|
||||||
|
assignedLane = laneEnds.length;
|
||||||
|
laneEnds.add(cardEnd);
|
||||||
|
} else {
|
||||||
|
laneEnds[assignedLane] = cardEnd;
|
||||||
|
}
|
||||||
|
laneByCard[card] = assignedLane;
|
||||||
|
}
|
||||||
|
final laneCount = laneEnds.length;
|
||||||
|
return [
|
||||||
|
for (final card in group)
|
||||||
|
_TimelineCardPlacement(
|
||||||
|
card: card,
|
||||||
|
lane: laneByCard[card]!,
|
||||||
|
laneCount: laneCount,
|
||||||
|
top: _visualStart(card, geometry),
|
||||||
|
height: geometry.heightForDuration(
|
||||||
|
card.endMinutes - card.startMinutes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _visualStart(
|
||||||
|
TimelineCardModel card,
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
) {
|
||||||
|
return geometry.yForMinutesSinceMidnight(card.startMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
|
||||||
|
final duration = card.endMinutes - card.startMinutes;
|
||||||
|
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
double left(double trackWidth) {
|
||||||
|
final laneWidth = _laneWidth(trackWidth);
|
||||||
|
final laneGap = _laneGapForWidth(trackWidth);
|
||||||
|
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
double width(double trackWidth) {
|
||||||
|
return _laneWidth(trackWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _availableWidth(double trackWidth) {
|
||||||
|
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
|
||||||
|
if (availableWidth < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return availableWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _laneWidth(double trackWidth) {
|
||||||
|
final availableWidth = _availableWidth(trackWidth);
|
||||||
|
final laneGap = _laneGapForWidth(trackWidth);
|
||||||
|
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _laneGapForWidth(double trackWidth) {
|
||||||
|
if (laneCount == 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final availableWidth = _availableWidth(trackWidth);
|
||||||
|
final totalGap = _laneGap * (laneCount - 1);
|
||||||
|
if (availableWidth <= totalGap) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return _laneGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
part of '../timeline_view.dart';
|
||||||
|
|
||||||
|
class _TimelineScrollBehavior extends MaterialScrollBehavior {
|
||||||
|
const _TimelineScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<PointerDeviceKind> get dragDevices {
|
||||||
|
return {...super.dragDevices, PointerDeviceKind.mouse};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
part of '../timeline_view.dart';
|
||||||
|
|
||||||
|
class _TimelineViewState extends State<TimelineView> {
|
||||||
|
static const _topInset = 12.0;
|
||||||
|
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
bool _didSetInitialScroll = false;
|
||||||
|
var _handledScrollRequest = 0;
|
||||||
|
late TimelineGeometry _geometry;
|
||||||
|
late double _contentHeight;
|
||||||
|
late List<_TimelineCardPlacement> _placements;
|
||||||
|
late int _cardsLayoutHash;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_updateLayoutCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant TimelineView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final rangeChanged =
|
||||||
|
oldWidget.range.startMinutes != widget.range.startMinutes ||
|
||||||
|
oldWidget.range.endMinutes != widget.range.endMinutes;
|
||||||
|
if (rangeChanged) {
|
||||||
|
_didSetInitialScroll = false;
|
||||||
|
}
|
||||||
|
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
|
||||||
|
if (rangeChanged || layoutChanged) {
|
||||||
|
_updateLayoutCache();
|
||||||
|
} else if (!identical(oldWidget.cards, widget.cards)) {
|
||||||
|
_refreshPlacementCards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
||||||
|
_scheduleTargetScroll(_geometry);
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _TimelineScrollBehavior(),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
child: SizedBox(
|
||||||
|
height: _contentHeight,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
|
child: TimelineAxis(
|
||||||
|
geometry: _geometry,
|
||||||
|
topInset: _topInset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, cardConstraints) {
|
||||||
|
final trackWidth = cardConstraints.maxWidth;
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: TimelineGrid(
|
||||||
|
geometry: _geometry,
|
||||||
|
topInset: _topInset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final placement in _placements)
|
||||||
|
Positioned(
|
||||||
|
top: placement.top + _topInset,
|
||||||
|
left: placement.left(trackWidth),
|
||||||
|
width: placement.width(trackWidth),
|
||||||
|
height: placement.height,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: TaskTimelineCard(
|
||||||
|
card: placement.card,
|
||||||
|
onTap: () =>
|
||||||
|
widget.onCardSelected(placement.card),
|
||||||
|
onStatusPressed:
|
||||||
|
widget.onCardCompleted == null
|
||||||
|
? null
|
||||||
|
: () => widget.onCardCompleted!(
|
||||||
|
placement.card,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateLayoutCache() {
|
||||||
|
_geometry = TimelineGeometry(
|
||||||
|
startMinutes: widget.range.startMinutes,
|
||||||
|
endMinutes: widget.range.endMinutes,
|
||||||
|
pixelsPerMinute: 2.45,
|
||||||
|
);
|
||||||
|
_contentHeight = _geometry.totalHeight + _topInset + 24;
|
||||||
|
_placements = _TimelineCardPlacement.pack(
|
||||||
|
widget.cards,
|
||||||
|
geometry: _geometry,
|
||||||
|
);
|
||||||
|
_cardsLayoutHash = _layoutHashFor(widget.cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _layoutHashFor(List<TimelineCardModel> cards) {
|
||||||
|
return Object.hashAll(
|
||||||
|
cards.map(
|
||||||
|
(card) => Object.hash(card.id, card.startMinutes, card.endMinutes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshPlacementCards() {
|
||||||
|
final cardsById = {for (final card in widget.cards) card.id: card};
|
||||||
|
_placements = [
|
||||||
|
for (final placement in _placements)
|
||||||
|
placement.withCard(cardsById[placement.card.id] ?? placement.card),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleInitialScroll(
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
double viewportHeight,
|
||||||
|
) {
|
||||||
|
if (_didSetInitialScroll || !viewportHeight.isFinite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
|
final currentMinute = now.hour * 60 + now.minute;
|
||||||
|
final clampedMinute = currentMinute
|
||||||
|
.clamp(geometry.startMinutes, geometry.endMinutes)
|
||||||
|
.toInt();
|
||||||
|
final centeredOffset =
|
||||||
|
geometry.yForMinutesSinceMidnight(clampedMinute) +
|
||||||
|
_topInset -
|
||||||
|
viewportHeight / 2;
|
||||||
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||||
|
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
||||||
|
_scrollController.jumpTo(offset);
|
||||||
|
_didSetInitialScroll = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||||
|
final targetCardId = widget.scrollTargetCardId;
|
||||||
|
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TimelineCardModel? targetCard;
|
||||||
|
for (final card in widget.cards) {
|
||||||
|
if (card.id == targetCardId) {
|
||||||
|
targetCard = card;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetCard == null) {
|
||||||
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final targetOffset =
|
||||||
|
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
|
||||||
|
_topInset -
|
||||||
|
24;
|
||||||
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||||
|
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
||||||
|
_scrollController.jumpTo(offset);
|
||||||
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,11 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
part 'top_bar/top_bar_icon_button.dart';
|
||||||
|
part 'top_bar/top_bar_metrics.dart';
|
||||||
|
part 'top_bar/top_bar_mode_toggle.dart';
|
||||||
|
part 'top_bar/top_bar_settings_button.dart';
|
||||||
|
|
||||||
/// Header bar for the compact Today view.
|
/// Header bar for the compact Today view.
|
||||||
class TopBar extends StatelessWidget {
|
class TopBar extends StatelessWidget {
|
||||||
/// Creates a top bar with a display-ready [dateLabel].
|
/// Creates a top bar with a display-ready [dateLabel].
|
||||||
|
|
@ -79,177 +84,3 @@ class TopBar extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TopBarMetrics {
|
|
||||||
const _TopBarMetrics(this.scale);
|
|
||||||
|
|
||||||
static const referenceHeight = 48.0;
|
|
||||||
static const _referenceWidth = 900.0;
|
|
||||||
static const _minimumScale = 0.44;
|
|
||||||
|
|
||||||
factory _TopBarMetrics.fromWidth(double width) {
|
|
||||||
final safeWidth = width.isFinite ? width : _referenceWidth;
|
|
||||||
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
|
|
||||||
return _TopBarMetrics(scale.toDouble());
|
|
||||||
}
|
|
||||||
|
|
||||||
final double scale;
|
|
||||||
|
|
||||||
double get titleWidth => 112 * scale;
|
|
||||||
double get titleSize => FocusFlowTokens.pageTitleSize * scale;
|
|
||||||
double get titleGap => 30 * scale;
|
|
||||||
double get controlHeight => referenceHeight * scale;
|
|
||||||
double get iconButtonSize => referenceHeight * scale;
|
|
||||||
double get iconSize => 24 * scale;
|
|
||||||
double get dateWidth => 144 * scale;
|
|
||||||
double get dateSize => 17 * scale;
|
|
||||||
double get modeWidth => 220 * scale;
|
|
||||||
double get modeHeight => referenceHeight * scale;
|
|
||||||
double get modeLabelSize => 17 * scale;
|
|
||||||
double get settingsGap => 28 * scale;
|
|
||||||
double get settingsWidth => 136 * scale;
|
|
||||||
double get settingsHeight => referenceHeight * scale;
|
|
||||||
double get settingsIconSize => 20 * scale;
|
|
||||||
double get settingsTextSize => 15 * scale;
|
|
||||||
double get settingsTextGap => 8 * scale;
|
|
||||||
double get borderRadius => FocusFlowTokens.smallButtonRadius * scale;
|
|
||||||
double get borderWidth => scale < 0.7 ? 0.8 : 1;
|
|
||||||
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsButton extends StatelessWidget {
|
|
||||||
const _SettingsButton({required this.metrics});
|
|
||||||
|
|
||||||
final _TopBarMetrics metrics;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return OutlinedButton(
|
|
||||||
onPressed: () {},
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
|
||||||
side: BorderSide(
|
|
||||||
color: FocusFlowTokens.subtleBorder,
|
|
||||||
width: metrics.borderWidth,
|
|
||||||
),
|
|
||||||
fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight),
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
padding: metrics.buttonPadding,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize),
|
|
||||||
SizedBox(width: metrics.settingsTextGap),
|
|
||||||
Text(
|
|
||||||
'Settings',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: metrics.settingsTextSize,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IconButton extends StatelessWidget {
|
|
||||||
const _IconButton({
|
|
||||||
required this.icon,
|
|
||||||
required this.metrics,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final _TopBarMetrics metrics;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return IconButton(
|
|
||||||
onPressed: onPressed,
|
|
||||||
constraints: BoxConstraints.tightFor(
|
|
||||||
width: metrics.iconButtonSize,
|
|
||||||
height: metrics.iconButtonSize,
|
|
||||||
),
|
|
||||||
color: FocusFlowTokens.textPrimary,
|
|
||||||
iconSize: metrics.iconSize,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: Icon(icon),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ModeToggle extends StatelessWidget {
|
|
||||||
const _ModeToggle({required this.metrics});
|
|
||||||
|
|
||||||
final _TopBarMetrics metrics;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: metrics.modeHeight,
|
|
||||||
width: metrics.modeWidth,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
|
||||||
border: Border.all(
|
|
||||||
color: FocusFlowTokens.subtleBorder,
|
|
||||||
width: metrics.borderWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: FocusFlowTokens.glassPanelStrong,
|
|
||||||
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
|
||||||
border: Border.all(
|
|
||||||
color: FocusFlowTokens.navBorder,
|
|
||||||
width: metrics.borderWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
'Compact',
|
|
||||||
style: TextStyle(fontSize: metrics.modeLabelSize),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
'Normal',
|
|
||||||
style: TextStyle(
|
|
||||||
color: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: metrics.modeLabelSize,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
part of '../top_bar.dart';
|
||||||
|
|
||||||
|
class _IconButton extends StatelessWidget {
|
||||||
|
const _IconButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.metrics,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final _TopBarMetrics metrics;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
constraints: BoxConstraints.tightFor(
|
||||||
|
width: metrics.iconButtonSize,
|
||||||
|
height: metrics.iconButtonSize,
|
||||||
|
),
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
iconSize: metrics.iconSize,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: Icon(icon),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
part of '../top_bar.dart';
|
||||||
|
|
||||||
|
class _TopBarMetrics {
|
||||||
|
const _TopBarMetrics(this.scale);
|
||||||
|
|
||||||
|
static const referenceHeight = 48.0;
|
||||||
|
static const _referenceWidth = 900.0;
|
||||||
|
static const _minimumScale = 0.44;
|
||||||
|
|
||||||
|
factory _TopBarMetrics.fromWidth(double width) {
|
||||||
|
final safeWidth = width.isFinite ? width : _referenceWidth;
|
||||||
|
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
|
||||||
|
return _TopBarMetrics(scale.toDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
double get titleWidth => 112 * scale;
|
||||||
|
double get titleSize => FocusFlowTokens.pageTitleSize * scale;
|
||||||
|
double get titleGap => 30 * scale;
|
||||||
|
double get controlHeight => referenceHeight * scale;
|
||||||
|
double get iconButtonSize => referenceHeight * scale;
|
||||||
|
double get iconSize => 24 * scale;
|
||||||
|
double get dateWidth => 144 * scale;
|
||||||
|
double get dateSize => 17 * scale;
|
||||||
|
double get modeWidth => 220 * scale;
|
||||||
|
double get modeHeight => referenceHeight * scale;
|
||||||
|
double get modeLabelSize => 17 * scale;
|
||||||
|
double get settingsGap => 28 * scale;
|
||||||
|
double get settingsWidth => 136 * scale;
|
||||||
|
double get settingsHeight => referenceHeight * scale;
|
||||||
|
double get settingsIconSize => 20 * scale;
|
||||||
|
double get settingsTextSize => 15 * scale;
|
||||||
|
double get settingsTextGap => 8 * scale;
|
||||||
|
double get borderRadius => FocusFlowTokens.smallButtonRadius * scale;
|
||||||
|
double get borderWidth => scale < 0.7 ? 0.8 : 1;
|
||||||
|
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
part of '../top_bar.dart';
|
||||||
|
|
||||||
|
class _ModeToggle extends StatelessWidget {
|
||||||
|
const _ModeToggle({required this.metrics});
|
||||||
|
|
||||||
|
final _TopBarMetrics metrics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: metrics.modeHeight,
|
||||||
|
width: metrics.modeWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: FocusFlowTokens.subtleBorder,
|
||||||
|
width: metrics.borderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.glassPanelStrong,
|
||||||
|
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: FocusFlowTokens.navBorder,
|
||||||
|
width: metrics.borderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
'Compact',
|
||||||
|
style: TextStyle(fontSize: metrics.modeLabelSize),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
'Normal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: metrics.modeLabelSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
part of '../top_bar.dart';
|
||||||
|
|
||||||
|
class _SettingsButton extends StatelessWidget {
|
||||||
|
const _SettingsButton({required this.metrics});
|
||||||
|
|
||||||
|
final _TopBarMetrics metrics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: BorderSide(
|
||||||
|
color: FocusFlowTokens.subtleBorder,
|
||||||
|
width: metrics.borderWidth,
|
||||||
|
),
|
||||||
|
fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
padding: metrics.buttonPadding,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(metrics.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize),
|
||||||
|
SizedBox(width: metrics.settingsTextGap),
|
||||||
|
Text(
|
||||||
|
'Settings',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: metrics.settingsTextSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue