mirror of
https://github.com/Iconica-Development/flutter_timetable.git
synced 2025-05-18 19:43:43 +02:00
392 lines
14 KiB
Dart
392 lines
14 KiB
Dart
// SPDX-FileCopyrightText: 2022 Iconica
|
|
//
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter_timetable/src/block_service.dart';
|
|
import 'package:flutter_timetable/src/models/table_theme.dart';
|
|
import 'package:flutter_timetable/src/models/time_block.dart';
|
|
import 'package:flutter_timetable/src/widgets/block.dart';
|
|
import 'package:flutter_timetable/src/widgets/table.dart' as table;
|
|
|
|
class Timetable extends StatefulWidget {
|
|
/// [Timetable] widget that displays a timetable with [TimeBlock]s.
|
|
/// The timetable automatically scrolls to the first item.
|
|
/// A [TableTheme] can be provided to customize the look of the timetable.
|
|
/// [mergeBlocks] and [combineBlocks] can be used to combine blocks
|
|
/// and merge columns of blocks when possible.
|
|
const Timetable({
|
|
this.tableDirection = Axis.vertical,
|
|
this.timeBlocks = const [],
|
|
this.size,
|
|
this.initialScrollTime,
|
|
this.scrollController,
|
|
this.scrollPhysics,
|
|
this.hoursOffset = 0,
|
|
this.startHour = 0,
|
|
this.endHour = 24,
|
|
this.blockDimension = 50,
|
|
this.blockColor = Colors.blue,
|
|
this.hourDimension = 80,
|
|
this.theme = const TableTheme(),
|
|
this.mergeBlocks = false,
|
|
this.combineBlocks = true,
|
|
this.onOverScroll,
|
|
this.onUnderScroll,
|
|
this.scrollTriggerOffset = 120,
|
|
this.scrollJumpToOffset = 115,
|
|
super.key,
|
|
}) : assert(
|
|
scrollTriggerOffset > scrollJumpToOffset,
|
|
'ScrollTriggerOffset cannot be smaller'
|
|
' then the scrollJumpToOffset.');
|
|
|
|
/// The Axis in which the table is layed out.
|
|
final Axis tableDirection;
|
|
|
|
/// The [Size] of the timetable.
|
|
final Size? size;
|
|
|
|
/// Hour at which the timetable starts.
|
|
final int startHour;
|
|
|
|
/// Hour at which the timetable ends.
|
|
final int endHour;
|
|
|
|
/// The time offset to increase all hour labels with
|
|
/// this is used to make the timetable start at a different time and go past midnight.
|
|
final int hoursOffset;
|
|
|
|
/// The time blocks that will be displayed in the timetable.
|
|
final List<TimeBlock> timeBlocks;
|
|
|
|
/// The dimension in pixels of the block if there is no child
|
|
/// for the direction that the table expands in
|
|
final double blockDimension;
|
|
|
|
/// The color of the block if there is no child
|
|
final Color blockColor;
|
|
|
|
/// The dimension in pixels of one hour in the timetable.
|
|
final double hourDimension;
|
|
|
|
/// The theme of the timetable.
|
|
final TableTheme theme;
|
|
|
|
/// The initial time to scroll to if there are no timeblocks. If nothing is provided it will scroll to the current time or to the first block if there is one.
|
|
final TimeOfDay? initialScrollTime;
|
|
|
|
/// The scroll controller to control the scrolling of the timetable.
|
|
final ScrollController? scrollController;
|
|
|
|
/// The scroll physics used for the SinglechildScrollView.
|
|
final ScrollPhysics? scrollPhysics;
|
|
|
|
/// Whether or not to merge blocks in 1 column that fit below eachother.
|
|
final bool mergeBlocks;
|
|
|
|
/// Whether or not to collapse blocks in 1 column if they have the same id.
|
|
/// If blocks have the same id and time they will be combined into one block.
|
|
final bool combineBlocks;
|
|
|
|
/// The offset which trigger the jump to either the previous or next page. Can't be lower then [scrollJumpToOffset].
|
|
final double scrollTriggerOffset;
|
|
|
|
/// When the jump is triggered this offset will be jumped outside of the min or max offset. Can't be higher then [scrollTriggerOffset].
|
|
final double scrollJumpToOffset;
|
|
|
|
final void Function()? onUnderScroll;
|
|
final void Function()? onOverScroll;
|
|
|
|
@override
|
|
State<Timetable> createState() => _TimetableState();
|
|
}
|
|
|
|
class _TimetableState extends State<Timetable> {
|
|
late ScrollController _scrollController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_scrollController =
|
|
widget.scrollController ?? ScrollController(initialScrollOffset: 0);
|
|
if (widget.timeBlocks.isNotEmpty) {
|
|
_scrollToFirstBlock();
|
|
} else {
|
|
_scrollToInitialTime();
|
|
}
|
|
|
|
if (widget.onUnderScroll != null && widget.onOverScroll != null) {
|
|
_scrollController.addListener(() {
|
|
if (_scrollController.offset -
|
|
_scrollController.position.maxScrollExtent >
|
|
widget.scrollTriggerOffset) {
|
|
if (widget.onOverScroll != null) {
|
|
_scrollController.jumpTo(
|
|
_scrollController.position.minScrollExtent -
|
|
widget.scrollJumpToOffset);
|
|
|
|
widget.onOverScroll?.call();
|
|
}
|
|
} else if (_scrollController.position.minScrollExtent -
|
|
_scrollController.offset >
|
|
widget.scrollTriggerOffset) {
|
|
if (widget.onUnderScroll != null) {
|
|
_scrollController.jumpTo(
|
|
_scrollController.position.maxScrollExtent +
|
|
widget.scrollJumpToOffset);
|
|
|
|
widget.onUnderScroll?.call();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (widget.scrollController == null) {
|
|
_scrollController.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
List<TimeBlock> blocks;
|
|
if (widget.combineBlocks) {
|
|
blocks = combineBlocksWithId(widget.timeBlocks);
|
|
} else {
|
|
blocks = widget.timeBlocks;
|
|
}
|
|
var linePadding = _calculateTableTextSize().width;
|
|
return SizedBox(
|
|
width: widget.size?.width,
|
|
height: widget.size?.height,
|
|
child: SingleChildScrollView(
|
|
physics: widget.scrollPhysics ?? const BouncingScrollPhysics(),
|
|
controller: _scrollController,
|
|
scrollDirection: widget.tableDirection,
|
|
child: Stack(
|
|
alignment: Alignment.topLeft,
|
|
children: [
|
|
table.Table(
|
|
hoursOffset: widget.hoursOffset,
|
|
tableDirection: widget.tableDirection,
|
|
startHour: widget.startHour,
|
|
endHour: widget.endHour,
|
|
hourDimension: widget.hourDimension,
|
|
tableOffset: _calculateTableStart(widget.tableDirection).width,
|
|
theme: widget.theme,
|
|
size: widget.size,
|
|
),
|
|
Container(
|
|
margin: EdgeInsets.only(
|
|
top: _calculateTableStart(widget.tableDirection).height,
|
|
left: _calculateTableStart(widget.tableDirection).width,
|
|
),
|
|
child: SingleChildScrollView(
|
|
physics: widget.scrollPhysics ?? const BouncingScrollPhysics(),
|
|
scrollDirection: widget.tableDirection == Axis.horizontal
|
|
? Axis.vertical
|
|
: Axis.horizontal,
|
|
child: (widget.tableDirection == Axis.horizontal)
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: widget.theme.tableTextOffset),
|
|
if (widget.mergeBlocks || widget.combineBlocks) ...[
|
|
for (var orderedBlocks in (widget.mergeBlocks)
|
|
? mergeBlocksInColumns(blocks)
|
|
: groupBlocksById(blocks)) ...[
|
|
Stack(
|
|
children: [
|
|
for (var block in orderedBlocks) ...[
|
|
_showBlock(block),
|
|
],
|
|
],
|
|
),
|
|
SizedBox(
|
|
height: widget.theme.blockPaddingBetween,
|
|
),
|
|
],
|
|
] else ...[
|
|
for (var block in blocks) ...[
|
|
_showBlock(block, linePadding: linePadding),
|
|
SizedBox(
|
|
height: widget.theme.blockPaddingBetween,
|
|
),
|
|
],
|
|
],
|
|
// emtpy block at the end
|
|
SizedBox(
|
|
height: max(
|
|
widget.theme.tablePaddingEnd -
|
|
widget.theme.blockPaddingBetween,
|
|
0,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (widget.mergeBlocks || widget.combineBlocks) ...[
|
|
for (var orderedBlocks in (widget.mergeBlocks)
|
|
? mergeBlocksInColumns(blocks)
|
|
: groupBlocksById(blocks)) ...[
|
|
Stack(
|
|
children: [
|
|
for (var block in orderedBlocks) ...[
|
|
_showBlock(block),
|
|
],
|
|
],
|
|
),
|
|
SizedBox(
|
|
width: widget.theme.blockPaddingBetween,
|
|
),
|
|
],
|
|
] else ...[
|
|
for (var block in blocks) ...[
|
|
_showBlock(block),
|
|
],
|
|
],
|
|
SizedBox(
|
|
width: max(
|
|
widget.theme.tablePaddingEnd -
|
|
widget.theme.blockPaddingBetween,
|
|
0,
|
|
),
|
|
height: widget.hourDimension *
|
|
(widget.endHour -
|
|
widget.startHour +
|
|
0.5), // empty halfhour at the end
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Size _calculateTableStart(Axis axis) => Size(
|
|
(axis == Axis.horizontal)
|
|
? _calculateTableTextSize().width / 2
|
|
: _calculateTableTextSize().width +
|
|
widget.theme.tablePaddingStart +
|
|
widget.theme.tableTextOffset,
|
|
(axis == Axis.vertical)
|
|
? _calculateTableTextSize().height / 2
|
|
: _calculateTableTextSize().height,
|
|
);
|
|
|
|
Widget _showBlock(TimeBlock block, {double linePadding = 0}) => Block(
|
|
blockDirection: widget.tableDirection,
|
|
linePadding: linePadding,
|
|
start: block.start,
|
|
end: block.end,
|
|
startHour: widget.startHour,
|
|
hourDimension: widget.hourDimension,
|
|
blockDimension: widget.blockDimension,
|
|
blockColor: widget.blockColor,
|
|
child: block.child,
|
|
);
|
|
|
|
void _scrollToFirstBlock() {
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
var earliestStart = widget.timeBlocks.map((block) => block.start).reduce(
|
|
(a, b) =>
|
|
a.hour < b.hour || (a.hour == b.hour && a.minute < b.minute)
|
|
? a
|
|
: b,
|
|
);
|
|
var initialOffset =
|
|
(widget.hourDimension * (widget.endHour - widget.startHour)) *
|
|
((earliestStart.hour - widget.startHour) /
|
|
(widget.endHour - widget.startHour)) +
|
|
_calculateTableTextSize().width / 2;
|
|
_scrollController.jumpTo(
|
|
initialOffset,
|
|
);
|
|
// TODO(freek): stop the controller from resetting
|
|
});
|
|
}
|
|
|
|
void _scrollToInitialTime() {
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
var startingTime = widget.initialScrollTime ?? TimeOfDay.now();
|
|
var initialOffset =
|
|
(widget.hourDimension * (widget.endHour - widget.startHour)) *
|
|
((startingTime.hour - widget.startHour) /
|
|
(widget.endHour - widget.startHour)) +
|
|
_calculateTableTextSize().width / 2;
|
|
_scrollController.jumpTo(
|
|
initialOffset,
|
|
);
|
|
});
|
|
}
|
|
|
|
Size _calculateTableTextSize() => (TextPainter(
|
|
text: TextSpan(
|
|
text: '22:22',
|
|
style:
|
|
widget.theme.timeStyle ?? Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
maxLines: 1,
|
|
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
|
textDirection: TextDirection.ltr,
|
|
)..layout())
|
|
.size;
|
|
|
|
double calculateTableHeight() {
|
|
var sum = 0.0;
|
|
if (widget.mergeBlocks || widget.combineBlocks) {
|
|
for (var orderedBlocks in (widget.mergeBlocks)
|
|
? mergeBlocksInColumns(widget.timeBlocks)
|
|
: groupBlocksById(widget.timeBlocks)) {
|
|
// check if any orderedBlock collides with another orderedBlock
|
|
if (orderedBlocks.map((block) => block.id).toSet().isNotEmpty &&
|
|
orderedBlocks.any(
|
|
(block) => orderedBlocks.any(
|
|
(block2) => block != block2 && block.collidesWith(block2),
|
|
),
|
|
)) {
|
|
// the sum is the combination of all the blocks
|
|
sum += orderedBlocks
|
|
.map((block) => block.childDimension ?? widget.blockDimension)
|
|
.reduce((a, b) => a + b);
|
|
} else {
|
|
sum +=
|
|
// get the highest block within the group
|
|
orderedBlocks
|
|
.map(
|
|
(block) => max(
|
|
block.childDimension ?? 0,
|
|
widget.blockDimension,
|
|
),
|
|
)
|
|
.reduce(max) +
|
|
widget.theme.blockPaddingBetween;
|
|
}
|
|
}
|
|
} else {
|
|
// sum of all the widget heights
|
|
sum = widget.timeBlocks
|
|
.map((block) => block.childDimension ?? widget.blockDimension)
|
|
.reduce((a, b) => a + b);
|
|
sum += widget.timeBlocks.length * widget.theme.blockPaddingBetween;
|
|
}
|
|
return sum;
|
|
}
|
|
}
|