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/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.
|
||||
class DemoSchedulerComposition {
|
||||
DemoSchedulerComposition._({
|
||||
|
|
@ -46,7 +49,7 @@ class DemoSchedulerComposition {
|
|||
final DateTime readAt;
|
||||
|
||||
/// 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.
|
||||
factory DemoSchedulerComposition.seeded({
|
||||
|
|
@ -178,182 +181,4 @@ class DemoSchedulerComposition {
|
|||
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 '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.
|
||||
class FocusFlowApp extends StatelessWidget {
|
||||
/// 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:scheduler_core/scheduler_core.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();
|
||||
|
||||
/// 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();
|
||||
}
|
||||
part 'command/scheduler_command_callbacks.dart';
|
||||
part 'command/scheduler_command_controller_impl.dart';
|
||||
part 'command/scheduler_command_state.dart';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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';
|
||||
}
|
||||
part 'today/required_banner_model.dart';
|
||||
part 'today/task_visual_kind.dart';
|
||||
part 'today/time/time_string_formatter.dart';
|
||||
part 'today/timeline_card_model.dart';
|
||||
part 'today/timeline_item_presentation_mapper.dart';
|
||||
part 'today/timeline_range_model.dart';
|
||||
part 'today/today_screen_data.dart';
|
||||
|
|
|
|||
|
|
@ -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/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.
|
||||
class TaskSelectionModal extends StatelessWidget {
|
||||
/// 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 '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.
|
||||
class TaskTimelineCard extends StatefulWidget {
|
||||
/// Creates a task timeline card.
|
||||
|
|
@ -32,613 +43,3 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
@override
|
||||
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 'timeline_geometry.dart';
|
||||
|
||||
part 'axis/timeline_grid.dart';
|
||||
part 'axis/timeline_grid_painter.dart';
|
||||
|
||||
/// Time labels and rail shown beside the compact timeline.
|
||||
class TimelineAxis extends StatelessWidget {
|
||||
/// 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_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.
|
||||
class TimelineView extends StatefulWidget {
|
||||
/// Creates a timeline view.
|
||||
|
|
@ -48,374 +52,3 @@ class TimelineView extends StatefulWidget {
|
|||
@override
|
||||
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';
|
||||
|
||||
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.
|
||||
class TopBar extends StatelessWidget {
|
||||
/// 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