focus-flow/apps/focus_flow_flutter/lib/app/focus_flow_app.dart

232 lines
7.1 KiB
Dart

/// Composes the FocusFlow Flutter application around Focus Flow App.
library;
import 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart';
import '../theme/focus_flow_tokens.dart';
import '../widgets/app_shell.dart';
import '../widgets/required_banner.dart';
import '../widgets/sidebar.dart';
import '../widgets/task_selection_modal.dart';
import '../widgets/timeline/timeline_view.dart';
import '../widgets/top_bar.dart';
import 'demo_scheduler_composition.dart';
/// Root Material app for the FocusFlow desktop UI.
class FocusFlowApp extends StatelessWidget {
/// Creates a FocusFlow app from an application composition.
const FocusFlowApp({required this.composition, super.key});
/// Factory and controller bundle used by the app tree.
final DemoSchedulerComposition composition;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FocusFlow',
debugShowCheckedModeBanner: false,
theme: FocusFlowTheme.dark(),
home: FocusFlowHome(composition: composition),
);
}
}
/// 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,
),
),
],
],
);
}
}