diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 73e7bcc..e7980d7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,46 +4,20 @@ on:
push:
pull_request:
schedule:
- - cron: "0 4,11 * * *"
+ - cron: '0 4 * * *'
env:
JAVA_VERSION: 12.x
- FLUTTER_CHANNEL: stable
- FLUTTER_VERSION: 1.17.x
+ FLUTTER_CHANNEL: beta
+ FLUTTER_VERSION: 2.x
jobs:
- install:
- name: Install Flutter & dependencies
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v1
-
- - name: Install Java
- uses: actions/setup-java@v1
- with:
- java-version: ${{ env.JAVA_VERSION }}
- - name: Install Flutter
- uses: subosito/flutter-action@v1
- with:
- channel: ${{ env.FLUTTER_CHANNEL }}
-
- - name: Install dependencies
- run: flutter pub get
-
- - name: Persist current state
- uses: actions/upload-artifact@v1
- with:
- name: source
- path: .
-
test:
name: Run tests
- needs: install
runs-on: ubuntu-latest
steps:
- - name: Install Java
- uses: actions/setup-java@v1
+ - uses: actions/checkout@v2
+ - uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}
- name: Install Flutter
@@ -51,24 +25,16 @@ jobs:
with:
channel: ${{ env.FLUTTER_CHANNEL }}
- - name: Checkout source
- uses: actions/download-artifact@v2
- with:
- name: source
-
- - name: Install dependencies
- run: flutter pub get
+ - run: flutter pub get
- - name: Run tests
- run: flutter test
+ - run: flutter test
lint:
name: Lint
- needs: install
runs-on: ubuntu-latest
steps:
- - name: Install Java
- uses: actions/setup-java@v1
+ - uses: actions/checkout@v2
+ - uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}
- name: Install Flutter
@@ -76,23 +42,16 @@ jobs:
with:
channel: ${{ env.FLUTTER_CHANNEL }}
- - name: Checkout source
- uses: actions/download-artifact@v2
- with:
- name: source
-
- - name: Install dependencies
- run: flutter pub get
+ - run: flutter pub get
- name: Run linter
run: flutter analyze > flutter_analyze_report.txt
continue-on-error: true
- - name: Install ruby
- uses: actions/setup-ruby@v1
+ - uses: actions/setup-ruby@v1
if: github.event_name == 'pull_request'
with:
- ruby-version: "2.6"
+ ruby-version: '2.6'
- name: Install ruby gems
run: |
gem install bundler
@@ -103,35 +62,27 @@ jobs:
if: github.event_name == 'pull_request'
with:
danger_file: Dangerfile
- danger_id: "danger-pr"
+ danger_id: 'danger-pr'
env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.BOT_TOKEN }}
build-example:
name: Build example
- needs: install
runs-on: ubuntu-latest
steps:
- - name: Install Java
- uses: actions/setup-java@v1
+ - uses: actions/checkout@v2
+ - uses: actions/setup-java@v1
with:
- java-version: "12.x"
+ java-version: '12.x'
- name: Install Flutter
uses: subosito/flutter-action@v1
with:
- channel: "stable"
-
- - name: Checkout source
- uses: actions/download-artifact@v2
- with:
- name: source
+ channel: ${{ env.FLUTTER_CHANNEL }}
- - name: Install dependencies
- run: flutter pub get
+ - run: flutter pub get
working-directory: example
- - name: Build APK
- run: flutter build apk
+ - run: flutter build apk
working-directory: example
- name: Upload APK as artifact
diff --git a/.github/workflows/unicorn.yml b/.github/workflows/unicorn.yml
deleted file mode 100644
index 2aeba52..0000000
--- a/.github/workflows/unicorn.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-name: Unicorn
-
-on:
- pull_request:
- types:
- - opened
- - edited
- - reopened
- - synchronize
-
-jobs:
- unicorn:
- name: 🦄 Unicorn
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: JonasWanke/unicorn@v0.1
- with:
- repo-token: "${{ secrets.UNICORN_TOKEN }}"
diff --git a/.unicorn.yml b/.unicorn.yml
deleted file mode 100644
index c7d416c..0000000
--- a/.unicorn.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-unicornVersion: "0.1.0"
-name: "timetable"
-description: "📅 Customizable, animated calendar widget including day, week and month views"
-homepage: "https://github.com/JonasWanke/timetable"
-license: "apache-2.0"
-version: "0.2.7"
-categorization:
- component:
- values:
- - name: "timetable"
- description: "The actual timetable package"
- paths:
- - "lib/**"
- - name: "example"
- description: "Example app"
- paths:
- - "example/**"
- labels:
- color: "c2e0c6"
- prefix: "C: "
- descriptionPrefix: "Component: "
- name: "component"
- priority:
- values:
- - name: "1"
- description: "1 (Lowest)"
- - name: "2"
- description: "2 (Low)"
- - name: "3"
- description: "3 (Medium)"
- - name: "4"
- description: "4 (High)"
- - name: "5"
- description: "5 (Highest)"
- labels:
- color: "e5b5ff"
- prefix: "P: "
- descriptionPrefix: "Priority: "
- name: "priority"
- type:
- values:
- - name: "feat"
- description: ":tada: New Features"
- versionBump: null
- - name: "change"
- description: "⚡ Changes"
- versionBump: null
- - name: "fix"
- description: ":bug: Bug Fixes"
- versionBump: null
- - name: "docs"
- description: ":scroll: Documentation updates"
- versionBump: null
- - name: "refactor"
- description: ":building_construction: Refactoring"
- versionBump: null
- - name: "build"
- description: ":package: Build & CI"
- versionBump: null
- labels:
- color: "c5def5"
- prefix: "T: "
- descriptionPrefix: "Type: "
- name: "type"
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a15db9..f1086d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
-## [Unreleased](https://github.com/JonasWanke/timetable/compare/v0.2.7...master)
-
-
-## [0.2.7](https://github.com/JonasWanke/timetable/compare/v0.2.6...v0.2.7) · 2020-09-02
+## 0.2.7 · 2020-09-02
### 🎉 New Features
- add `TimetableThemeData.minimumHourZoom` & `.maximumHourZoom`, closes: [#40](https://github.com/JonasWanke/timetable/issues/40) & [#45](https://github.com/JonasWanke/timetable/issues/45)
@@ -31,7 +28,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- update dartx to `^0.5.0`
-## [0.2.6](https://github.com/JonasWanke/timetable/compare/v0.2.5...v0.2.6) · 2020-07-12
+## 0.2.6 · 2020-07-12
### 🎉 New Features
- add custom builders for date header and leading area of the header (usually a week indicator) ([#28](https://github.com/JonasWanke/timetable/pull/28)), closes: [#27](https://github.com/JonasWanke/timetable/issues/27). Thanks to [@TatsuUkraine](https://github.com/TatsuUkraine)!
@@ -41,7 +38,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Expand part-day events to fill empty columns ([#30](https://github.com/JonasWanke/timetable/pull/30)), closes: [#29](https://github.com/JonasWanke/timetable/issues/29)
-## [0.2.5](https://github.com/JonasWanke/timetable/compare/v0.2.4...v0.2.5) · 2020-07-06
+## 0.2.5 · 2020-07-06
### 📜 Documentation updates
- add Localization section to the README
@@ -50,7 +47,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- update dartx to `^0.4.0`
-## [0.2.4](https://github.com/JonasWanke/timetable/compare/v0.2.3...v0.2.4) · 2020-06-25
+## 0.2.4 · 2020-06-25
### 🎉 New Features
- `Timetable.onEventBackgroundTap`: called when tapping the background, e.g. for creating an event ([#20](https://github.com/JonasWanke/timetable/pull/20)), closes: [#18](https://github.com/JonasWanke/timetable/issues/18). Thanks to [@raLaaaa](https://github.com/raLaaaa)!
@@ -60,13 +57,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- improve streaming `EventProvider` documentation ([e63bfb4](https://github.com/JonasWanke/timetable/commit/e63bfb4f974ce5319fd6f6bb12ebb561d8c5143c)), fixes: [#19](https://github.com/JonasWanke/timetable/issues/19)
-## [0.2.3](https://github.com/JonasWanke/timetable/compare/v0.2.2...v0.2.3) · 2020-06-15
+## 0.2.3 · 2020-06-15
### 🎉 New Features
- Customizable date/weekday format with `TimetableThemeData.weekDayIndicatorPattern`, `.dateIndicatorPattern` & temporary `.totalDateIndicatorHeight` ([#16](https://github.com/JonasWanke/timetable/pull/16)), closes: [#15](https://github.com/JonasWanke/timetable/issues/15)
-## [0.2.2](https://github.com/JonasWanke/timetable/compare/v0.2.1...v0.2.2) · 2020-05-30
+## 0.2.2 · 2020-05-30
### 🎉 New Features
- optional `onTap`-parameter for `BasicEventWidget` & `BasicAllDayEventWidget` ([#12](https://github.com/JonasWanke/timetable/pull/12)), closes: [#11](https://github.com/JonasWanke/timetable/issues/11)
@@ -75,7 +72,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- specify minimum Dart version (v2.7.0) in `pubspec.yaml`
-## [0.2.1](https://github.com/JonasWanke/timetable/compare/v0.2.0...v0.2.1) · 2020-05-19
+## 0.2.1 · 2020-05-19
### 🎉 New Features
- All-day events (shown at the top) ([#8](https://github.com/JonasWanke/timetable/pull/8)), closes: [#5](https://github.com/JonasWanke/timetable/issues/5)
@@ -86,7 +83,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **example:** upload generated APK as artifact
-## [0.2.0](https://github.com/JonasWanke/timetable/compare/v0.1.3...v0.2.0) · 2020-05-08
+## 0.2.0 · 2020-05-08
### ⚠ BREAKING CHANGES
- fix week scroll alignment ([#6](https://github.com/JonasWanke/timetable/pull/6))
@@ -96,13 +93,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- support Flutter v1.17.0 ([#4](https://github.com/JonasWanke/timetable/pull/4))
-## [0.1.3](https://github.com/JonasWanke/timetable/compare/v0.1.2...v0.1.3) · 2020-05-06
+## 0.1.3 · 2020-05-06
### 🐛 Bug Fixes
- fix time zooming & add testing ([#3](https://github.com/JonasWanke/timetable/pull/3))
-## [0.1.2](https://github.com/JonasWanke/timetable/compare/v0.1.1...v0.1.2) · 2020-05-05
+## 0.1.2 · 2020-05-05
### 🎉 New Features
- add `TimetableController.initialTimeRange`, closes: [#1](https://github.com/JonasWanke/timetable/issues/1)
@@ -111,7 +108,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- fix week alignment with `WeekVisibleRange`, closes: [#2](https://github.com/JonasWanke/timetable/issues/2)
-## [0.1.1](https://github.com/JonasWanke/timetable/compare/v0.1.0...v0.1.1) · 2020-04-02
+## 0.1.1 · 2020-04-02
### 📜 Documentation updates
- fix broken links in README
diff --git a/README.md b/README.md
index 56f8397..6d1e8a7 100644
--- a/README.md
+++ b/README.md
@@ -1,200 +1,266 @@
-📅 Customizable, animated calendar widget including day & week views.
+📅 Customizable, animated calendar widget including day, week, and month views.
+| Navigation | Animation |
+| :---------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: |
+| ![](https://github.com/JonasWanke/timetable/raw/master/doc/demo-navigation.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/demo-animation.webp?raw=true) |
-| Event positioning demo | Dark mode & custom range |
-| :--------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------: |
-| ![Screenshot of timetable](https://github.com/JonasWanke/timetable/raw/master/doc/demo.gif?raw=true) | ![Screenshot of timetable in dark mode with only three visible days](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-3day-dark.jpg?raw=true) |
+| Callbacks | Changing the [`VisibleDateRange`] |
+| :--------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------: |
+| ![](https://github.com/JonasWanke/timetable/raw/master/doc/demo-callbacks.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/demo-visibleDateRange.webp?raw=true) |
+* [Available Layouts](#available-layouts)
+* [Getting started](#getting-started)
+ * [0. General Information](#0-general-information)
+ * [1. Define your `Event`s](#1-define-your-events)
+ * [2. Create a `DateController` (optional)](#2-create-a-datecontroller-optional)
+ * [3. Create a `TimeController` (optional)](#3-create-a-timecontroller-optional)
+ * [4. Create your Timetable](#4-create-your-timetable)
+* [Theming](#theming)
+* [Advanced Features](#advanced-features)
+ * [Drag and Drop](#drag-and-drop)
+ * [Time Overlays](#time-overlays)
-- [Getting started](#getting-started)
- - [1. Initialize time_machine](#1-initialize-time_machine)
- - [2. Define your `Event`s](#2-define-your-events)
- - [3. Create an `EventProvider`](#3-create-an-eventprovider)
- - [4. Create a `TimetableController`](#4-create-a-timetablecontroller)
- - [5. Create your `Timetable`](#5-create-your-timetable)
-- [Theming](#theming)
-- [Features & Coming soon](#features--coming-soon)
+## Available Layouts
-## Getting started
+| [`MultiDateTimetable`] | [`RecurringMultiDateTimetable`]
(doesn't scroll horizontally) | [`CompactMonthTimetable`] |
+| :-----------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------: |
+| ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-MultiDateTimetable-light.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-RecurringMultiDateTimetable-light.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-CompactMonthTimetable-light.webp?raw=true) |
-### 1. Initialize [time_machine]
+Of course, dark mode is supported out of the box:
-This package uses [time_machine] for handling date and time, which you first have to initialize.
+| [`MultiDateTimetable`] | [`RecurringMultiDateTimetable`] | [`CompactMonthTimetable`] |
+| :----------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------: |
+| ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-MultiDateTimetable-dark.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-RecurringMultiDateTimetable-dark.webp?raw=true) | ![](https://github.com/JonasWanke/timetable/raw/master/doc/screenshot-CompactMonthTimetable-dark.webp?raw=true) |
-Add this to your `pubspec.yaml`:
-```yaml
-flutter:
- assets:
- - packages/time_machine/data/cultures/cultures.bin
- - packages/time_machine/data/tzdb/tzdb.bin
-```
+## Getting started
-Modify your `main.dart`'s `main()`:
-```dart
-import 'package:flutter/services.dart';
-import 'package:time_machine/time_machine.dart';
+### 0. General Information
-void main() async {
- // Call these two functions before `runApp()`.
- WidgetsFlutterBinding.ensureInitialized();
- await TimeMachine.initialize({'rootBundle': rootBundle});
+Timetable doesn't care about any time-zone related stuff.
+All supplied `DateTime`s must have `isUtc` set to `true`, but the actual time zone is then ignored when displaying events.
- runApp(MyApp());
-}
-```
-Source: https://pub.dev/packages/time_machine#flutter-specific-notes
+Some date/time-related parameters also have special suffixes:
+* `date`: A `DateTime` with a time of zero.
+* `month`: A `DateTime` with a time of zero and a day of one.
+* `timeOfDay`: A `Duration` between zero and 24 hours.
+* `dayOfWeek`: An `int` between one and seven ([`DateTime.monday`](https://api.flutter.dev/flutter/dart-core/DateTime/monday-constant.html) through [`DateTime.sunday`](https://api.flutter.dev/flutter/dart-core/DateTime/sunday-constant.html)).
-### 2. Define your [`Event`]s
+Timetable currently offers localizations for English and German.
+Even if you're just supporting English in your app, you have to add Timetable's localization delegate to your `MaterialApp`/`CupertinoApp`/`WidgetsApp`:
-Events are provided as instances of [`Event`]. To get you started, there's the subclass [`BasicEvent`], which you can instantiate directly. If you want to be more specific, you can also implement your own class extending [`Event`].
+```dart
+MaterialApp(
+ localizationsDelegates: [
+ TimetableLocalizationsDelegate(),
+ // Other delegates, e.g., `GlobalMaterialLocalizations.delegate`
+ ],
+ // ...
+);
+```
-> **Note:** Most classes of timetable accept a type-parameter `E extends Event`. Please set it to your chosen [`Event`]-subclass (e.g. [`BasicEvent`]) to avoid runtime exceptions.
+### 1. Define your [`Event`]s
-In addition, you also need a [`Widget`] to display your events. When using [`BasicEvent`], this can simply be [`BasicEventWidget`].
+Events are provided as instances of [`Event`].
+To get you started, there's the subclass [`BasicEvent`], which you can instantiate directly.
+If you want to be more specific, you can also implement your own class extending [`Event`].
+> **Note:** Most of Timetable's classes accept a type-parameter `E extends Event`.
+> Please set it to your chosen [`Event`]-subclass (e.g. [`BasicEvent`]) to avoid runtime exceptions.
-### 3. Create an [`EventProvider`]
+In addition, you also need a `Widget` to display your events.
+When using [`BasicEvent`], this can simply be [`BasicEventWidget`].
-As the name suggests, you use [`EventProvider`] to provide [`Event`]s to timetable. There are currently two [`EventProvider`]s to choose from:
+### 2. Create a [`DateController`] (optional)
-- [`EventProvider.list(List events)`][`EventProvider.list`]: If you have a non-changing list of events.
-- [`EventProvider.simpleStream(Stream> eventStream)`][`EventProvider.simpleStream`]: If you have a limited, changing list of events.
-- [`EventProvider.stream({StreamedEventGetter eventGetter})`][`EventProvider.stream`]: If your events can change or you have many events and only want to load the relevant subset.
+Similar to a [`ScrollController`] or a [`TabController`], a [`DateController`] is responsible for interacting with Timetable's widgets and managing their state.
+As the name suggests, you can use a [`DateController`] to access the currently visible dates, and also animate or jump to different days.
+And by supplying a [`VisibleDateRange`], you can also customize how many days are visible at once and whether they, e.g., snap to weeks.
```dart
-final myEventProvider = EventProvider.list([
- BasicEvent(
- id: 0,
- title: 'My Event',
- color: Colors.blue,
- start: LocalDate.today().at(LocalTime(13, 0, 0)),
- end: LocalDate.today().at(LocalTime(15, 0, 0)),
- ),
-]);
+final myDateController = DateController(
+ // All parameters are optional and displayed with their default value.
+ initialDate: DateTimeTimetable.today(),
+ visibleRange: VisibleDateRange.week(startOfWeek: DateTime.monday),
+);
```
-For trying out the behavior of changing events, you can create a `StreamController>` and `add()` different lists of events, e.g. in `Future.delayed()`:
+> Don't forget to [`dispose`][`DateController.dispose`] your controller, e.g., in [`State.dispose`]!
-```dart
-final eventController = StreamController>()..add([]);
-final provider = EventProvider.simpleStream(eventController.stream);
-Future.delayed(Duration(seconds: 5), () => eventController.add(/* some events */));
+Here are some of the available [`VisibleDateRange`]s:
-// Don't forget to close the stream controller when you're done, e.g. in `dispose`:
-eventController.close();
-```
+* [`VisibleDateRange.days`]: displays `visibleDayCount` consecutive days, snapping to every `swipeRange` days (aligned to `alignmentDate`) in the range from `minDate` to `maxDate`
+* [`VisibleDateRange.week`]: displays and snaps to whole weeks with a customizable `startOfWeek` in the range from `minDate` to `maxDate`
+* [`VisibleDateRange.weekAligned`]: displays `visibleDayCount` consecutive days while snapping to whole weeks with a customizable `firstDay` in the range from `minDate` to `maxDate` – can be used, e.g., to display a five-day workweek
-> See the [example][example/main.dart] for more [`EventProvider`] samples!
+### 3. Create a [`TimeController`] (optional)
-
-### 4. Create a [`TimetableController`]
-
-Similar to a [`ScrollController`] or a [`TabController`], a [`TimetableController`] is responsible for interacting with a [`Timetable`] and managing its state. You can instantiate it with your [`EventProvider`]:
+Similar to the [`DateController`] above, a [`TimeController`] is also responsible for interacting with Timetable's widgets and managing their state.
+More specifically, it controls the visible time range and zoom factor in a [`MultiDateTimetable`] or [`RecurringMultiDateTimetable`].
+You can also programmatically change those and, e.g., animate out to reveal the full day.
```dart
-final myController = TimetableController(
- eventProvider: myEventProvider,
- // Optional parameters with their default values:
- initialTimeRange: InitialTimeRange.range(
- startTime: LocalTime(8, 0, 0),
- endTime: LocalTime(20, 0, 0),
- ),
- initialDate: LocalDate.today(),
- visibleRange: VisibleRange.week(),
- firstDayOfWeek: DayOfWeek.monday,
+final myTimeController = TimeController(
+ // All parameters are optional. By default, the whole day is revealed
+ // initially and you can zoom in to view just a single minute.
+ minDuration: 15.minutes,
+ initialRange: TimeRange(9.hours, 17.hours),
+ maxRange: TimeRange(0.hours, 24.hours),
);
```
-> Don't forget to [`dispose`][`TimetableController.dispose`] your controller, e.g. in [`State.dispose`]!
+> This example uses some of [supercharged]'s extension methods on `int` to create a [`Duration`] more concisely.
+> Don't forget to [`dispose`][`TimeController.dispose`] your controller, e.g., in [`State.dispose`]!
-### 5. Create your [`Timetable`]
+### 4. Create your Timetable widget
-Using your [`TimetableController`], you can now create a [`Timetable`] widget:
+The configuration for Timetable's widgets is provided via inherited widgets.
+You can use a [`TimetableConfig`] to provide all at once:
```dart
-Timetable(
- controller: myController,
- eventBuilder: (event) => BasicEventWidget(event),
+TimetableConfig(
+ // Required:
+ dateController: _dateController,
+ timeController: _timeController,
+ eventBuilder: (context, event) => BasicEventWidget(event),
+ child: MultiDateTimetable(),
+ // Optional:
+ eventProvider: (date) => someListOfEvents,
allDayEventBuilder: (context, event, info) =>
BasicAllDayEventWidget(event, info: info),
+ callbacks: TimetableCallbacks(
+ // onWeekTap, onDateTap, onDateBackgroundTap, onDateTimeBackgroundTap
+ ),
+ theme: TimetableThemeData(
+ context,
+ // startOfWeek: DateTime.monday,
+ // See the "Theming" section below for more options.
+ ),
)
```
And you're done 🎉
-
## Theming
-For a full list of visual properties that can be tweaked, see [`TimetableThemeData`].
+Timetable already supports light and dark themes out of the box, adapting to the ambient `ThemeData`.
+You can, however, customize the styles of almost all components by providing a custom [`TimetableThemeData`].
-To apply a theme, specify it in the [`Timetable`] constructor:
+To apply your own theme, specify it in the [`TimetableConfig`] (or directly in a [`TimetableTheme`]):
```dart
-Timetable(
- controller: /* ... */,
+TimetableConfig(
theme: TimetableThemeData(
- primaryColor: Colors.teal,
- partDayEventMinimumDuration: Period(minutes: 30),
- // ...and many more!
+ context,
+ startOfWeek: DateTime.monday,
+ dateDividersStyle: DateDividersStyle(
+ context,
+ color: Colors.blue.withOpacity(.3),
+ width: 2,
+ ),
+ dateHeaderStyleProvider: (date) =>
+ DateHeaderStyle(context, date, tooltip: 'My custom tooltip'),
+ nowIndicatorStyle: NowIndicatorStyle(
+ context,
+ lineColor: Colors.green,
+ shape: TriangleNowIndicatorShape(color: Colors.green),
+ ),
+ // See the "Theming" section below for more.
),
-),
+ // Other properties...
+)
```
+> [`TimetableThemeData`] and all component styles provide two constructors each:
+>
+> * The default constructor takes a `BuildContext` and sometimes a day or month, using information from the ambient theme and locale to generate default values.
+> You can still override all options via optional, named parameters.
+> * The named `raw` constructor is usually `const` and has required parameters for all options.
+
+## Advanced Features
+
+### Drag and Drop
-## Localization
+
-[time_machine] is used internally for date & time formatting. By default, it uses `en_US` as its locale (managed by the [`Culture`] class) and doesn't know about Flutter's locale. To change the locale, set [`Culture.current`] after the call to [`TimeMachine.initialize`]:
+You can easily make events inside the content area of [`MultiDateTimetable`] or [`RecurringMultiDateTimetable`] draggable by wrapping them in a [`PartDayDraggableEvent`]:
```dart
-// Supported cultures: https://github.com/Dana-Ferguson/time_machine/tree/master/lib/data/cultures
-Culture.current = await Cultures.getCulture('de');
+PartDayDraggableEvent(
+ // The user started dragging this event.
+ onDragStart: () {},
+ // The event was dragged to the given [DateTime].
+ onDragUpdate: (dateTime) {},
+ // The user finished dragging the event and landed on the given [DateTime].
+ onDragEnd: (dateTime) {},
+ child: MyEventWidget(),
+ // By default, the child is displayed with a reduced opacity when it's
+ // dragged. But, of course, you can customize this:
+ childWhileDragging: OptionalChildWhileDragging(),
+)
```
-To automatically react to locale changes of the app, see [Dana-Ferguson/time_machine#28].
-
-> **Note:** A better solution for Localization is already planned.
+Timetable doesn't automatically show a moving feedback widget at the current pointer position.
+Instead, you can customize this and, e.g., snap to multiples of 15 minutes.
+Have a look at the included example app where we implemented exactly that by displaying the drag feedback as a time overlay.
+### Time Overlays
-## Features & Coming soon
+
-- [x] Smartly arrange overlapping events
-- [x] Zooming
-- [x] Selectable [`VisibleRange`]s
-- [x] Display all-day events at the top
-- [x] Theming
-- [ ] Animate between different [`VisibleRange`]s: see [#17]
-- [ ] Month-view, Agenda-view: see [#17]
-- [x] Listener when tapping the background (e.g. for creating an event)
-- [ ] Support for event resizing
+In addition to displaying events, [`MultiDateTimetable`] and [`RecurringMultiDateTimetable`] can display overlays for time ranges on every day.
+In the screenshot above, a light gray overlay is displayed on weekdays before 8 a.m. and after 8 p.m., and over the full day for weekends.
+Time overlays are provided similarly to events: Just add a timeOverlayProvider to your [`TimetableConfig`] (or use a [`DefaultTimeOverlayProvider`] directly).
+```dart
+TimetableConfig(
+ timeOverlayProvider: (context, date) => [
+ TimeOverlay(
+ start: 0.hours,
+ end: 8.hours,
+ widget: ColoredBox(color: Colors.black12),
+ position: TimeOverlayPosition.behindEvents, // the default, alternatively `inFrontOfEvents`
+ ),
+ TimeOverlay(
+ start: 20.hours,
+ end: 24.hours,
+ widget: ColoredBox(color: Colors.black12),
+ ),
+ ],
+ // Other properties...
+)
+```
+The provider is just a function that receives a date and returns a list of [`TimeOverlay`] for that date.
+The example above therefore draws a light gray background before 8 a.m. and after 8 p.m. on every day.
[example/main.dart]: https://github.com/JonasWanke/timetable/blob/master/example/lib/main.dart
-[`TabController`]: https://api.flutter.dev/flutter/material/TabController-class.html
+[`Duration`]: https://api.flutter.dev/flutter/dart-core/Duration-class.html
[`ScrollController`]: https://api.flutter.dev/flutter/widgets/ScrollController-class.html
[`State.dispose`]: https://api.flutter.dev/flutter/widgets/State/dispose.html
-[`Widget`]: https://api.flutter.dev/flutter/widgets/Widget-class.html
+[`TabController`]: https://api.flutter.dev/flutter/material/TabController-class.html
[`BasicEvent`]: https://pub.dev/documentation/timetable/latest/timetable/BasicEvent-class.html
[`BasicEventWidget`]: https://pub.dev/documentation/timetable/latest/timetable/BasicEventWidget-class.html
+[`CompactMonthTimetable`]: https://pub.dev/documentation/timetable/latest/timetable/CompactMonthTimetable-class.html
+[`DateController`]: https://pub.dev/documentation/timetable/latest/timetable/DateController-class.html
+[`DateController.dispose`]: https://pub.dev/documentation/timetable/latest/timetable/DateController/dispose.html
+[`DefaultTimeOverlayProvider`]: https://pub.dev/documentation/timetable/latest/timetable/DefaultTimeOverlayProvider-class.html
[`Event`]: https://pub.dev/documentation/timetable/latest/timetable/Event-class.html
-[`EventBuilder`]: https://pub.dev/documentation/timetable/latest/timetable/EventBuilder-class.html
-[`EventProvider`]: https://pub.dev/documentation/timetable/latest/timetable/EventProvider-class.html
-[`EventProvider.list`]: https://pub.dev/documentation/timetable/latest/timetable/EventProvider/EventProvider.list.html
-[`EventProvider.simpleStream`]: https://pub.dev/documentation/timetable/latest/timetable/EventProvider/EventProvider.simpleStream.html
-[`EventProvider.stream`]: https://pub.dev/documentation/timetable/latest/timetable/EventProvider/EventProvider.stream.html
-[`Timetable`]: https://pub.dev/documentation/timetable/latest/timetable/Timetable-class.html
-[`TimetableController`]: https://pub.dev/documentation/timetable/latest/timetable/TimetableController-class.html
-[`TimetableController.dispose`]: https://pub.dev/documentation/timetable/latest/timetable/TimetableController/dispose.html
+[`MultiDateTimetable`]: https://pub.dev/documentation/timetable/latest/timetable/MultiDateTimetable-class.html
+[`PartDayDraggableEvent`]: https://pub.dev/documentation/timetable/latest/timetable/PartDayDraggableEvent-class.html
+[`RecurringMultiDateTimetable`]: https://pub.dev/documentation/timetable/latest/timetable/RecurringMultiDateTimetable-class.html
+[`TimeController`]: https://pub.dev/documentation/timetable/latest/timetable/TimeController-class.html
+[`TimeController.dispose`]: https://pub.dev/documentation/timetable/latest/timetable/TimeController/dispose.html
+[`TimeOverlay`]: https://pub.dev/documentation/timetable/latest/timetable/TimeOverlay-class.html
+[`TimetableConfig`]: https://pub.dev/documentation/timetable/latest/timetable/TimetableConfig-class.html
+[`TimetableTheme`]: https://pub.dev/documentation/timetable/latest/timetable/TimetableTheme-class.html
[`TimetableThemeData`]: https://pub.dev/documentation/timetable/latest/timetable/TimetableThemeData-class.html
-[`VisibleRange`]: https://pub.dev/documentation/timetable/latest/timetable/VisibleRange-class.html
-[#17]: https://github.com/JonasWanke/timetable/issues/17
-
-[time_machine]: https://pub.dev/packages/time_machine
-[`Culture`]: https://pub.dev/documentation/time_machine/latest/time_machine/Culture-class.html
-[`Culture.current`]: https://pub.dev/documentation/time_machine/latest/time_machine/Culture/current.html
-[`TimeMachine.initialize`]: https://pub.dev/documentation/time_machine/latest/time_machine/TimeMachine/initialize.html
-[Dana-Ferguson/time_machine#28]: https://github.com/Dana-Ferguson/time_machine/issues/28
+[`VisibleDateRange`]: https://pub.dev/documentation/timetable/latest/timetable/VisibleDateRange-class.html
+[`VisibleDateRange.days`]: https://pub.dev/documentation/timetable/latest/timetable/VisibleDateRange/days.html
+[`VisibleDateRange.week`]: https://pub.dev/documentation/timetable/latest/timetable/VisibleDateRange/week.html
+[`VisibleDateRange.weekAligned`]: https://pub.dev/documentation/timetable/latest/timetable/VisibleDateRange/foo.html
+
+[supercharged]: https://pub.dev/packages/supercharged
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 7ebf19f..baf7fbd 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,138 +1,51 @@
-include: package:pedantic/analysis_options.yaml
+include: package:extra_pedantic/analysis_options.yaml
analyzer:
+ strong-mode:
+ implicit-casts: false
+ implicit-dynamic: false
exclude:
- build/**
- lib/**.g.dart
+ - lib/**.freezed.dart
+ - lib/generated/**
- local/**
- language:
- strict-inference: true
- strict-raw-types: true
linter:
rules:
- - always_declare_return_types
- - always_put_control_body_on_new_line
- - always_require_non_null_named_parameters
- - annotate_overrides
- - avoid_bool_literals_in_conditional_expressions
- - avoid_catching_errors
- - avoid_classes_with_only_static_members
- - avoid_double_and_int_checks
- - avoid_empty_else
- - avoid_field_initializers_in_const_classes
- - avoid_function_literals_in_foreach_calls
- - avoid_implementing_value_types
- - avoid_init_to_null
- - avoid_null_checks_in_equality_operators
- - avoid_positional_boolean_parameters
- - avoid_print
- - avoid_private_typedef_functions
- - avoid_relative_lib_imports
- - avoid_return_types_on_setters
- - avoid_returning_null
- - avoid_returning_null_for_future
- - avoid_returning_null_for_void
- - avoid_returning_this
- - avoid_setters_without_getters
- - avoid_shadowing_type_parameters
- - avoid_single_cascade_in_expression_statements
- - avoid_slow_async_io
- - avoid_types_as_parameter_names
- - avoid_types_on_closure_parameters
- - avoid_unused_constructor_parameters
- - await_only_futures
- - camel_case_types
- - cancel_subscriptions
- - cascade_invocations
- - close_sinks
- - comment_references
- - constant_identifier_names
- - curly_braces_in_flow_control_structures
- - directives_ordering
- - empty_catches
- - empty_constructor_bodies
- - empty_statements
- - file_names
- - flutter_style_todos
- - hash_and_equals
- - implementation_imports
- - invariant_booleans
- - iterable_contains_unrelated_type
- - join_return_with_assignment
- - library_names
- - library_prefixes
- - list_remove_unrelated_type
- - literal_only_boolean_expressions
- - no_duplicate_case_values
- - non_constant_identifier_names
- - null_closures
- - one_member_abstracts
- - only_throw_errors
- - overridden_fields
- - package_api_docs
- - package_names
- - package_prefixed_library_names
- - parameter_assignments
- - prefer_adjacent_string_concatenation
- - prefer_asserts_in_initializer_lists
- - prefer_collection_literals
- - prefer_conditional_assignment
- - prefer_const_constructors_in_immutables
- - prefer_const_declarations
- - prefer_constructors_over_static_methods
- - prefer_contains
- - prefer_equal_for_default_values
- - prefer_final_fields
- - prefer_final_in_for_each
- - prefer_final_locals
- - prefer_for_elements_to_map_fromIterable
- - prefer_foreach
- - prefer_function_declarations_over_variables
- - prefer_generic_function_type_aliases
- - prefer_if_elements_to_conditional_expressions
- - prefer_if_null_operators
- - prefer_initializing_formals
- - prefer_inlined_adds
- - prefer_int_literals
- - prefer_interpolation_to_compose_strings
- - prefer_is_empty
- - prefer_is_not_empty
- - prefer_iterable_whereType
- - prefer_mixin
- - prefer_null_aware_operators
- - prefer_single_quotes
- - prefer_spread_collections
- - prefer_typing_uninitialized_variables
- - prefer_void_to_null
- - provide_deprecation_message
- - recursive_getters
- - slash_for_doc_comments
- - sort_child_properties_last
- - sort_constructors_first
- - sort_pub_dependencies
- - sort_unnamed_constructors_first
- - test_types_in_equals
- - type_annotate_public_apis
- - type_init_formals
- - unawaited_futures
- - unnecessary_await_in_return
- - unnecessary_brace_in_string_interps
- - unnecessary_const
- - unnecessary_getters_setters
- - unnecessary_lambdas
- - unnecessary_new
- - unnecessary_null_aware_assignments
- - unnecessary_null_in_if_null_operators
- - unnecessary_overrides
- - unnecessary_parenthesis
- - unnecessary_statements
- - unnecessary_this
- - unrelated_type_equality_checks
- - use_full_hex_values_for_flutter_colors
- - use_function_type_syntax_for_parameters
- - use_rethrow_when_possible
- - use_setters_to_change_properties
- - use_string_buffers
- - valid_regexps
- - void_checks
+ always_put_required_named_parameters_first: false
+ avoid_bool_literals_in_conditional_expressions: true
+ avoid_classes_with_only_static_members: true
+ avoid_field_initializers_in_const_classes: true
+ avoid_function_literals_in_foreach_calls: true
+ avoid_positional_boolean_parameters: true
+ avoid_print: true
+ avoid_returning_null: false
+ avoid_setters_without_getters: true
+ avoid_types_on_closure_parameters: true
+ camel_case_extensions: true
+ camel_case_types: true
+ comment_references: true
+ constant_identifier_names: true
+ curly_braces_in_flow_control_structures: false
+ flutter_style_todos: true
+ literal_only_boolean_expressions: false
+ non_constant_identifier_names: true
+ omit_local_variable_types: true
+ one_member_abstracts: true
+ overridden_fields: true
+ parameter_assignments: false
+ prefer_const_constructors: false
+ prefer_constructors_over_static_methods: true
+ prefer_function_declarations_over_variables: true
+ prefer_int_literals: true
+ prefer_interpolation_to_compose_strings: true
+ prefer_mixin: true
+ prefer_single_quotes: true
+ sort_constructors_first: true
+ type_annotate_public_apis: false
+ unnecessary_brace_in_string_interps: true
+ unnecessary_lambdas: true
+ unnecessary_statements: true
+ unnecessary_this: true
+ use_setters_to_change_properties: true
diff --git a/doc/demo-animation.webp b/doc/demo-animation.webp
new file mode 100644
index 0000000..94285ba
Binary files /dev/null and b/doc/demo-animation.webp differ
diff --git a/doc/demo-callbacks.webp b/doc/demo-callbacks.webp
new file mode 100644
index 0000000..bf2f4f9
Binary files /dev/null and b/doc/demo-callbacks.webp differ
diff --git a/doc/demo-dragAndDrop.webp b/doc/demo-dragAndDrop.webp
new file mode 100644
index 0000000..4a7fedb
Binary files /dev/null and b/doc/demo-dragAndDrop.webp differ
diff --git a/doc/demo-navigation.webp b/doc/demo-navigation.webp
new file mode 100644
index 0000000..7a8a80f
Binary files /dev/null and b/doc/demo-navigation.webp differ
diff --git a/doc/demo-visibleDateRange.webp b/doc/demo-visibleDateRange.webp
new file mode 100644
index 0000000..987377e
Binary files /dev/null and b/doc/demo-visibleDateRange.webp differ
diff --git a/doc/demo.gif b/doc/demo.gif
deleted file mode 100644
index 93405b3..0000000
Binary files a/doc/demo.gif and /dev/null differ
diff --git a/doc/screenshot-3day-dark.jpg b/doc/screenshot-3day-dark.jpg
deleted file mode 100644
index 39f0faa..0000000
Binary files a/doc/screenshot-3day-dark.jpg and /dev/null differ
diff --git a/doc/screenshot-CompactMonthTimetable-dark.png b/doc/screenshot-CompactMonthTimetable-dark.png
new file mode 100644
index 0000000..ff7b65d
Binary files /dev/null and b/doc/screenshot-CompactMonthTimetable-dark.png differ
diff --git a/doc/screenshot-CompactMonthTimetable-dark.webp b/doc/screenshot-CompactMonthTimetable-dark.webp
new file mode 100644
index 0000000..7a2f1f6
Binary files /dev/null and b/doc/screenshot-CompactMonthTimetable-dark.webp differ
diff --git a/doc/screenshot-CompactMonthTimetable-light.png b/doc/screenshot-CompactMonthTimetable-light.png
new file mode 100644
index 0000000..95a57e5
Binary files /dev/null and b/doc/screenshot-CompactMonthTimetable-light.png differ
diff --git a/doc/screenshot-CompactMonthTimetable-light.webp b/doc/screenshot-CompactMonthTimetable-light.webp
new file mode 100644
index 0000000..cae7461
Binary files /dev/null and b/doc/screenshot-CompactMonthTimetable-light.webp differ
diff --git a/doc/screenshot-MultiDateTimetable-dark.png b/doc/screenshot-MultiDateTimetable-dark.png
new file mode 100644
index 0000000..1c21881
Binary files /dev/null and b/doc/screenshot-MultiDateTimetable-dark.png differ
diff --git a/doc/screenshot-MultiDateTimetable-dark.webp b/doc/screenshot-MultiDateTimetable-dark.webp
new file mode 100644
index 0000000..f218d48
Binary files /dev/null and b/doc/screenshot-MultiDateTimetable-dark.webp differ
diff --git a/doc/screenshot-MultiDateTimetable-light.png b/doc/screenshot-MultiDateTimetable-light.png
new file mode 100644
index 0000000..fb14c09
Binary files /dev/null and b/doc/screenshot-MultiDateTimetable-light.png differ
diff --git a/doc/screenshot-MultiDateTimetable-light.webp b/doc/screenshot-MultiDateTimetable-light.webp
new file mode 100644
index 0000000..ab90d54
Binary files /dev/null and b/doc/screenshot-MultiDateTimetable-light.webp differ
diff --git a/doc/screenshot-RecurringMultiDateTimetable-dark.webp b/doc/screenshot-RecurringMultiDateTimetable-dark.webp
new file mode 100644
index 0000000..c79f9e2
Binary files /dev/null and b/doc/screenshot-RecurringMultiDateTimetable-dark.webp differ
diff --git a/doc/screenshot-RecurringMultiDateTimetable-light.png b/doc/screenshot-RecurringMultiDateTimetable-light.png
new file mode 100644
index 0000000..a5cb38a
Binary files /dev/null and b/doc/screenshot-RecurringMultiDateTimetable-light.png differ
diff --git a/doc/screenshot-RecurringMultiDateTimetable-light.webp b/doc/screenshot-RecurringMultiDateTimetable-light.webp
new file mode 100644
index 0000000..d63660e
Binary files /dev/null and b/doc/screenshot-RecurringMultiDateTimetable-light.webp differ
diff --git a/doc/screenshot-timeOverlays.webp b/doc/screenshot-timeOverlays.webp
new file mode 100644
index 0000000..09ea9c9
Binary files /dev/null and b/doc/screenshot-timeOverlays.webp differ
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 3e6043d..d20c81d 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,6 +1,8 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:time_machine/time_machine.dart';
+import 'package:supercharged/supercharged.dart';
import 'package:timetable/timetable.dart';
// ignore: unused_import
@@ -8,10 +10,7 @@ import 'positioning_demo.dart';
import 'utils.dart';
void main() async {
- setTargetPlatformForDesktop();
-
- WidgetsFlutterBinding.ensureInitialized();
- await TimeMachine.initialize({'rootBundle': rootBundle});
+ initDebugOverlay();
runApp(ExampleApp(child: TimetableExample()));
}
@@ -20,103 +19,232 @@ class TimetableExample extends StatefulWidget {
_TimetableExampleState createState() => _TimetableExampleState();
}
-class _TimetableExampleState extends State {
- final GlobalKey _scaffoldKey = GlobalKey();
- TimetableController _controller;
-
- @override
- void initState() {
- super.initState();
-
- _controller = TimetableController(
- // A basic EventProvider containing a single event:
- // eventProvider: EventProvider.list([
- // BasicEvent(
- // id: 0,
- // title: 'My Event',
- // color: Colors.blue,
- // start: LocalDate.today().at(LocalTime(13, 0, 0)),
- // end: LocalDate.today().at(LocalTime(15, 0, 0)),
- // ),
- // ]),
-
- // For a demo of overlapping events, use this one instead:
- eventProvider: positioningDemoEventProvider,
-
- // Or even this short example using a Stream:
- // eventProvider: EventProvider.stream(
- // eventGetter: (range) => Stream.periodic(
- // Duration(milliseconds: 16),
- // (i) {
- // final start =
- // LocalDate.today().atMidnight() + Period(minutes: i * 2);
- // return [
- // BasicEvent(
- // id: 0,
- // title: 'Event',
- // color: Colors.blue,
- // start: start,
- // end: start + Period(hours: 5),
- // ),
- // ];
- // },
- // ),
- // ),
-
- // Other (optional) parameters:
- initialTimeRange: InitialTimeRange.range(
- startTime: LocalTime(8, 0, 0),
- endTime: LocalTime(20, 0, 0),
- ),
- initialDate: LocalDate.today(),
- visibleRange: VisibleRange.days(3),
- firstDayOfWeek: DayOfWeek.monday,
- );
+class _TimetableExampleState extends State
+ with TickerProviderStateMixin {
+ var _visibleDateRange = PredefinedVisibleDateRange.week;
+ void _updateVisibleDateRange(PredefinedVisibleDateRange newValue) {
+ setState(() {
+ _visibleDateRange = newValue;
+ _dateController.visibleRange = newValue.visibleDateRange;
+ });
}
+ bool get _isRecurringLayout =>
+ _visibleDateRange == PredefinedVisibleDateRange.fixed;
+
+ late final _dateController = DateController(
+ // All parameters are optional.
+ // initialDate: DateTimeTimetable.today(),
+ visibleRange: _visibleDateRange.visibleDateRange,
+ );
+
+ final _timeController = TimeController(
+ // All parameters are optional.
+ // initialRange: TimeRange(8.hours, 20.hours),
+ maxRange: TimeRange(0.hours, 24.hours),
+ );
+
+ final _draggedEvents = [];
+
@override
void dispose() {
- _controller.dispose();
+ _timeController.dispose();
+ _dateController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
- return Scaffold(
- key: _scaffoldKey,
- appBar: AppBar(
- title: Text('Timetable example'),
- actions: [
- IconButton(
- icon: Icon(Icons.today),
- onPressed: () => _controller.animateToToday(),
- tooltip: 'Jump to today',
- ),
- ],
+ return TimetableConfig(
+ // Required:
+ dateController: _dateController,
+ timeController: _timeController,
+ eventBuilder: (context, event) => _buildPartDayEvent(event),
+ child: Column(children: [
+ _buildAppBar(),
+ Expanded(
+ child: _isRecurringLayout
+ ? RecurringMultiDateTimetable()
+ : MultiDateTimetable(),
+ ),
+ ]),
+ // Optional:
+ eventProvider: eventProviderFromFixedList(positioningDemoEvents),
+ allDayEventBuilder: (context, event, info) => BasicAllDayEventWidget(
+ event,
+ info: info,
+ onTap: () => _showSnackBar('All-day event $event tapped'),
),
- body: Timetable(
- controller: _controller,
- onEventBackgroundTap: (start, isAllDay) {
- _showSnackBar('Background tapped $start is all day event $isAllDay');
- },
- eventBuilder: (event) {
- return BasicEventWidget(
- event,
- onTap: () => _showSnackBar('Part-day event $event tapped'),
+ timeOverlayProvider: mergeTimeOverlayProviders([
+ positioningDemoOverlayProvider,
+ (context, date) => _draggedEvents
+ .map((it) =>
+ it.toTimeOverlay(date: date, widget: BasicEventWidget(it)))
+ .whereNotNull()
+ .toList(),
+ ]),
+ callbacks: TimetableCallbacks(
+ onWeekTap: (week) {
+ _showSnackBar('Tapped on week $week.');
+ _updateVisibleDateRange(PredefinedVisibleDateRange.week);
+ _dateController.animateTo(
+ week.getDayOfWeek(DateTime.monday),
+ vsync: this,
);
},
- allDayEventBuilder: (context, event, info) => BasicAllDayEventWidget(
- event,
- info: info,
- onTap: () => _showSnackBar('All-day event $event tapped'),
- ),
+ onDateTap: (date) {
+ _showSnackBar('Tapped on date $date.');
+ _dateController.animateTo(date, vsync: this);
+ },
+ onDateBackgroundTap: (date) =>
+ _showSnackBar('Tapped on date background at $date.'),
+ onDateTimeBackgroundTap: (dateTime) =>
+ _showSnackBar('Tapped on date-time background at $dateTime.'),
+ ),
+ theme: TimetableThemeData(
+ context,
+ // startOfWeek: DateTime.monday,
+ // dateDividersStyle: DateDividersStyle(
+ // context,
+ // color: Colors.blue.withOpacity(.3),
+ // width: 2,
+ // ),
+ // nowIndicatorStyle: NowIndicatorStyle(
+ // context,
+ // lineColor: Colors.green,
+ // shape: TriangleNowIndicatorShape(color: Colors.green),
+ // ),
),
);
}
- void _showSnackBar(String content) {
- _scaffoldKey.currentState.showSnackBar(SnackBar(
- content: Text(content),
- ));
+ Widget _buildPartDayEvent(BasicEvent event) {
+ final roundedTo = 15.minutes;
+
+ return PartDayDraggableEvent(
+ onDragStart: () => setState(() {
+ _draggedEvents.add(event.copyWith(showOnTop: true));
+ }),
+ onDragUpdate: (dateTime) => setState(() {
+ dateTime = dateTime.roundTimeToMultipleOf(roundedTo);
+ final index = _draggedEvents.indexWhere((it) => it.id == event.id);
+ final oldEvent = _draggedEvents[index];
+ _draggedEvents[index] = oldEvent.copyWith(
+ start: dateTime,
+ end: dateTime + oldEvent.duration,
+ );
+ }),
+ onDragEnd: (dateTime) {
+ dateTime = (dateTime ?? event.start).roundTimeToMultipleOf(roundedTo);
+ setState(() => _draggedEvents.removeWhere((it) => it.id == event.id));
+ _showSnackBar('Dragged event to $dateTime.');
+ },
+ child: BasicEventWidget(
+ event,
+ onTap: () => _showSnackBar('Part-day event $event tapped'),
+ ),
+ );
+ }
+
+ Widget _buildAppBar() {
+ final colorScheme = context.theme.colorScheme;
+ Widget child = AppBar(
+ elevation: 0,
+ titleTextStyle: TextStyle(color: colorScheme.onSurface),
+ iconTheme: IconThemeData(color: colorScheme.onSurface),
+ systemOverlayStyle: SystemUiOverlayStyle.light,
+ backgroundColor: Colors.transparent,
+ title: _isRecurringLayout
+ ? null
+ : MonthIndicator.forController(_dateController),
+ actions: [
+ IconButton(
+ icon: Icon(Icons.today),
+ onPressed: () {
+ _dateController.animateToToday(vsync: this);
+ _timeController.animateToShowFullDay(vsync: this);
+ },
+ tooltip: 'Go to today',
+ ),
+ SizedBox(width: 8),
+ DropdownButton(
+ onChanged: (visibleRange) => _updateVisibleDateRange(visibleRange!),
+ value: _visibleDateRange,
+ items: [
+ for (final visibleRange in PredefinedVisibleDateRange.values)
+ DropdownMenuItem(
+ value: visibleRange,
+ child: Text(visibleRange.title),
+ ),
+ ],
+ ),
+ SizedBox(width: 16),
+ ],
+ );
+
+ if (!_isRecurringLayout) {
+ child = Column(children: [
+ child,
+ Padding(
+ padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Builder(builder: (context) {
+ return DefaultTimetableCallbacks(
+ callbacks: DefaultTimetableCallbacks.of(context)!.copyWith(
+ onDateTap: (date) {
+ _showSnackBar('Tapped on date $date.');
+ _updateVisibleDateRange(PredefinedVisibleDateRange.day);
+ _dateController.animateTo(date, vsync: this);
+ },
+ ),
+ child: CompactMonthTimetable(),
+ );
+ }),
+ ),
+ ]);
+ }
+
+ return Material(color: colorScheme.surface, elevation: 4, child: child);
+ }
+
+ void _showSnackBar(String content) =>
+ context.scaffoldMessenger.showSnackBar(SnackBar(content: Text(content)));
+}
+
+enum PredefinedVisibleDateRange { day, threeDays, workWeek, week, fixed }
+
+extension on PredefinedVisibleDateRange {
+ VisibleDateRange get visibleDateRange {
+ switch (this) {
+ case PredefinedVisibleDateRange.day:
+ return VisibleDateRange.days(1);
+ case PredefinedVisibleDateRange.threeDays:
+ return VisibleDateRange.days(3);
+ case PredefinedVisibleDateRange.workWeek:
+ return VisibleDateRange.weekAligned(5);
+ case PredefinedVisibleDateRange.week:
+ return VisibleDateRange.week();
+ case PredefinedVisibleDateRange.fixed:
+ return VisibleDateRange.fixed(
+ DateTimeTimetable.today(),
+ DateTime.daysPerWeek,
+ );
+ }
+ }
+
+ String get title {
+ switch (this) {
+ case PredefinedVisibleDateRange.day:
+ return 'Day';
+ case PredefinedVisibleDateRange.threeDays:
+ return '3 Days';
+ case PredefinedVisibleDateRange.workWeek:
+ return 'Work Week';
+ case PredefinedVisibleDateRange.week:
+ return 'Week';
+ case PredefinedVisibleDateRange.fixed:
+ return '7 Days (fixed)';
+ }
}
}
+
+// ignore_for_file: avoid_print, unused_element
diff --git a/example/lib/positioning_demo.dart b/example/lib/positioning_demo.dart
index 2f1c03f..fa2d569 100644
--- a/example/lib/positioning_demo.dart
+++ b/example/lib/positioning_demo.dart
@@ -1,63 +1,162 @@
import 'dart:math';
+import 'package:black_hole_flutter/black_hole_flutter.dart';
import 'package:flutter/material.dart';
-import 'package:time_machine/time_machine.dart';
+import 'package:supercharged/supercharged.dart' hide DateTimeSC;
import 'package:timetable/timetable.dart';
-import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:timetable/src/utils.dart';
+
+// A basic EventProvider containing a single event:
+// eventProvider: EventProvider.list([
+// BasicEvent(
+// id: 0,
+// title: 'My Event',
+// color: Colors.blue,
+// start: LocalDate.today().at(LocalTime(13, 0, 0)),
+// end: LocalDate.today().at(LocalTime(15, 0, 0)),
+// ),
+// ]),
-final EventProvider positioningDemoEventProvider =
- EventProvider.list(_events);
+// For a demo of overlapping events, use this one instead:
+// eventProvider: positioningDemoEventProvider,
-final _events = [
- _DemoEvent(0, 0, LocalTime(10, 0, 0), LocalTime(11, 0, 0)),
- _DemoEvent(0, 1, LocalTime(11, 0, 0), LocalTime(12, 0, 0)),
- _DemoEvent(0, 2, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(1, 0, LocalTime(10, 0, 0), LocalTime(12, 0, 0)),
- _DemoEvent(1, 1, LocalTime(10, 0, 0), LocalTime(12, 0, 0)),
- _DemoEvent(1, 2, LocalTime(14, 0, 0), LocalTime(16, 0, 0)),
- _DemoEvent(1, 3, LocalTime(14, 15, 0), LocalTime(16, 0, 0)),
- _DemoEvent(2, 0, LocalTime(10, 0, 0), LocalTime(20, 0, 0)),
- _DemoEvent(2, 1, LocalTime(10, 0, 0), LocalTime(12, 0, 0)),
- _DemoEvent(2, 2, LocalTime(13, 0, 0), LocalTime(15, 0, 0)),
- _DemoEvent(3, 0, LocalTime(10, 0, 0), LocalTime(20, 0, 0)),
- _DemoEvent(3, 1, LocalTime(12, 0, 0), LocalTime(14, 0, 0)),
- _DemoEvent(3, 2, LocalTime(12, 0, 0), LocalTime(15, 0, 0)),
- _DemoEvent(4, 0, LocalTime(10, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(4, 1, LocalTime(10, 15, 0), LocalTime(13, 0, 0)),
- _DemoEvent(4, 2, LocalTime(10, 30, 0), LocalTime(13, 0, 0)),
- _DemoEvent(4, 3, LocalTime(10, 45, 0), LocalTime(13, 0, 0)),
- _DemoEvent(4, 4, LocalTime(11, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(5, 0, LocalTime(10, 30, 0), LocalTime(13, 30, 0)),
- _DemoEvent(5, 1, LocalTime(10, 30, 0), LocalTime(13, 30, 0)),
- _DemoEvent(5, 2, LocalTime(10, 30, 0), LocalTime(12, 30, 0)),
- _DemoEvent(5, 3, LocalTime(8, 30, 0), LocalTime(18, 0, 0)),
- _DemoEvent(5, 4, LocalTime(15, 30, 0), LocalTime(16, 0, 0)),
- _DemoEvent(5, 5, LocalTime(11, 0, 0), LocalTime(12, 0, 0)),
- _DemoEvent(5, 6, LocalTime(1, 0, 0), LocalTime(2, 0, 0)),
- _DemoEvent(6, 0, LocalTime(9, 30, 0), LocalTime(15, 30, 0)),
- _DemoEvent(6, 1, LocalTime(11, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(6, 2, LocalTime(9, 30, 0), LocalTime(11, 30, 0)),
- _DemoEvent(6, 3, LocalTime(9, 30, 0), LocalTime(10, 30, 0)),
- _DemoEvent(6, 4, LocalTime(10, 0, 0), LocalTime(11, 0, 0)),
- _DemoEvent(6, 5, LocalTime(10, 0, 0), LocalTime(11, 0, 0)),
- _DemoEvent(6, 6, LocalTime(9, 30, 0), LocalTime(10, 30, 0)),
- _DemoEvent(6, 7, LocalTime(9, 30, 0), LocalTime(10, 30, 0)),
- _DemoEvent(6, 8, LocalTime(9, 30, 0), LocalTime(10, 30, 0)),
- _DemoEvent(6, 9, LocalTime(10, 30, 0), LocalTime(12, 30, 0)),
- _DemoEvent(6, 10, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(6, 11, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(6, 12, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(6, 13, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(6, 14, LocalTime(6, 30, 0), LocalTime(8, 0, 0)),
- _DemoEvent(7, 0, LocalTime(2, 30, 0), LocalTime(4, 30, 0)),
- _DemoEvent(7, 1, LocalTime(2, 30, 0), LocalTime(3, 30, 0)),
- _DemoEvent(7, 2, LocalTime(3, 0, 0), LocalTime(4, 0, 0)),
- _DemoEvent(8, 0, LocalTime(20, 0, 0), LocalTime(4, 0, 0), endDateOffset: 1),
- _DemoEvent(9, 1, LocalTime(12, 0, 0), LocalTime(16, 0, 0)),
- _DemoEvent(9, 2, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(9, 3, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(9, 4, LocalTime(12, 0, 0), LocalTime(13, 0, 0)),
- _DemoEvent(9, 5, LocalTime(15, 0, 0), LocalTime(16, 0, 0)),
+// Or even this short example using a Stream:
+// eventProvider: EventProvider.stream(
+// eventGetter: (range) => Stream.periodic(
+// Duration(milliseconds: 16),
+// (i) {
+// final start =
+// LocalDate.today().atMidnight() + Period(minutes: i * 2);
+// return [
+// BasicEvent(
+// id: 0,
+// title: 'Event',
+// color: Colors.blue,
+// start: start,
+// end: start + Period(hours: 5),
+// ),
+// ];
+// },
+// ),
+// ),
+
+// _dateController.page.addListener(() {
+// print('New page: ${_dateController.page.value}');
+// });
+// _timeController.addListener(() {
+// print('New time range: ${_timeController.value}');
+// });
+
+final positioningDemoEvents = [
+ _DemoEvent(0, 0, Duration(hours: 10), Duration(hours: 11)),
+ _DemoEvent(0, 1, Duration(hours: 11), Duration(hours: 12)),
+ _DemoEvent(0, 2, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(1, 0, Duration(hours: 10), Duration(hours: 12)),
+ _DemoEvent(1, 1, Duration(hours: 10), Duration(hours: 12)),
+ _DemoEvent(1, 2, Duration(hours: 14), Duration(hours: 16)),
+ _DemoEvent(1, 3, Duration(hours: 14, minutes: 15), Duration(hours: 16)),
+ _DemoEvent(2, 0, Duration(hours: 10), Duration(hours: 20)),
+ _DemoEvent(2, 1, Duration(hours: 10), Duration(hours: 12)),
+ _DemoEvent(2, 2, Duration(hours: 13), Duration(hours: 15)),
+ _DemoEvent(3, 0, Duration(hours: 10), Duration(hours: 20)),
+ _DemoEvent(3, 1, Duration(hours: 12), Duration(hours: 14)),
+ _DemoEvent(3, 2, Duration(hours: 12), Duration(hours: 15)),
+ _DemoEvent(4, 0, Duration(hours: 10), Duration(hours: 13)),
+ _DemoEvent(4, 1, Duration(hours: 10, minutes: 15), Duration(hours: 13)),
+ _DemoEvent(4, 2, Duration(hours: 10, minutes: 30), Duration(hours: 13)),
+ _DemoEvent(4, 3, Duration(hours: 10, minutes: 45), Duration(hours: 13)),
+ _DemoEvent(4, 4, Duration(hours: 11), Duration(hours: 13)),
+ _DemoEvent(
+ 5,
+ 0,
+ Duration(hours: 10, minutes: 30),
+ Duration(hours: 13, minutes: 30),
+ ),
+ _DemoEvent(
+ 5,
+ 1,
+ Duration(hours: 10, minutes: 30),
+ Duration(hours: 13, minutes: 30),
+ ),
+ _DemoEvent(
+ 5,
+ 2,
+ Duration(hours: 10, minutes: 30),
+ Duration(hours: 12, minutes: 30),
+ ),
+ _DemoEvent(5, 3, Duration(hours: 8, minutes: 30), Duration(hours: 18)),
+ _DemoEvent(5, 4, Duration(hours: 15, minutes: 30), Duration(hours: 16)),
+ _DemoEvent(5, 5, Duration(hours: 11), Duration(hours: 12)),
+ _DemoEvent(5, 6, Duration(hours: 1), Duration(hours: 2)),
+ _DemoEvent(
+ 6,
+ 0,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 15, minutes: 30),
+ ),
+ _DemoEvent(6, 1, Duration(hours: 11), Duration(hours: 13)),
+ _DemoEvent(
+ 6,
+ 2,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 11, minutes: 30),
+ ),
+ _DemoEvent(
+ 6,
+ 3,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 10, minutes: 30),
+ ),
+ _DemoEvent(6, 4, Duration(hours: 10), Duration(hours: 11)),
+ _DemoEvent(6, 5, Duration(hours: 10), Duration(hours: 11)),
+ _DemoEvent(
+ 6,
+ 6,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 10, minutes: 30),
+ ),
+ _DemoEvent(
+ 6,
+ 7,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 10, minutes: 30),
+ ),
+ _DemoEvent(
+ 6,
+ 8,
+ Duration(hours: 9, minutes: 30),
+ Duration(hours: 10, minutes: 30),
+ ),
+ _DemoEvent(
+ 6,
+ 9,
+ Duration(hours: 10, minutes: 30),
+ Duration(hours: 12, minutes: 30),
+ ),
+ _DemoEvent(6, 10, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(6, 11, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(6, 12, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(6, 13, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(6, 14, Duration(hours: 6, minutes: 30), Duration(hours: 8)),
+ _DemoEvent(
+ 7,
+ 0,
+ Duration(hours: 2, minutes: 30),
+ Duration(hours: 4, minutes: 30),
+ ),
+ _DemoEvent(
+ 7,
+ 1,
+ Duration(hours: 2, minutes: 30),
+ Duration(hours: 3, minutes: 30),
+ ),
+ _DemoEvent(7, 2, Duration(hours: 3), Duration(hours: 4)),
+ _DemoEvent(8, 0, Duration(hours: 20), Duration(hours: 4), endDateOffset: 1),
+ _DemoEvent(9, 1, Duration(hours: 12), Duration(hours: 16)),
+ _DemoEvent(9, 2, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(9, 3, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(9, 4, Duration(hours: 12), Duration(hours: 13)),
+ _DemoEvent(9, 5, Duration(hours: 15), Duration(hours: 16)),
_DemoEvent.allDay(0, 0, 1),
_DemoEvent.allDay(1, 1, 1),
_DemoEvent.allDay(2, 0, 2),
@@ -75,33 +174,51 @@ class _DemoEvent extends BasicEvent {
_DemoEvent(
int demoId,
int eventId,
- LocalTime start,
- LocalTime end, {
+ Duration start,
+ Duration end, {
int endDateOffset = 0,
}) : super(
id: '$demoId-$eventId',
title: '$demoId-$eventId',
- color: _getColor('$demoId-$eventId'),
- start: LocalDate.today().addDays(demoId).at(start),
- end: LocalDate.today().addDays(demoId + endDateOffset).at(end),
+ backgroundColor: _getColor('$demoId-$eventId'),
+ start: DateTime.now().toUtc().atStartOfDay + demoId.days + start,
+ end: DateTime.now().toUtc().atStartOfDay +
+ (demoId + endDateOffset).days +
+ end,
);
_DemoEvent.allDay(int id, int startOffset, int length)
: super(
id: 'a-$id',
title: 'a-$id',
- color: _getColor('a-$id'),
- start: LocalDate.today().addDays(startOffset).atMidnight(),
- end: LocalDate.today().addDays(startOffset + length).atMidnight(),
+ backgroundColor: _getColor('a-$id'),
+ start: DateTime.now().toUtc().atStartOfDay + startOffset.days,
+ end:
+ DateTime.now().toUtc().atStartOfDay + (startOffset + length).days,
);
static Color _getColor(String id) {
return Random(id.hashCode)
- .nextColorHsv(
- saturation: 0.7,
- value: 0.8,
- alpha: 1,
- )
+ .nextColorHsv(saturation: 0.6, value: 0.8, alpha: 1)
.toColor();
}
}
+
+List positioningDemoOverlayProvider(
+ BuildContext context,
+ DateTime date,
+) {
+ assert(date.isValidTimetableDate);
+
+ final widget =
+ ColoredBox(color: context.theme.brightness.contrastColor.withOpacity(.1));
+
+ if (DateTime.monday <= date.weekday && date.weekday <= DateTime.friday) {
+ return [
+ TimeOverlay(start: 0.hours, end: 8.hours, widget: widget),
+ TimeOverlay(start: 20.hours, end: 24.hours, widget: widget),
+ ];
+ } else {
+ return [TimeOverlay(start: 0.hours, end: 24.hours, widget: widget)];
+ }
+}
diff --git a/example/lib/utils.dart b/example/lib/utils.dart
index 82fef59..fd55819 100644
--- a/example/lib/utils.dart
+++ b/example/lib/utils.dart
@@ -1,36 +1,77 @@
-import 'dart:io';
-
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:debug_overlay/debug_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:timetable/timetable.dart';
-void setTargetPlatformForDesktop() {
- if (Platform.isLinux || Platform.isWindows) {
- debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia;
- }
+final _mediaOverrideState = ValueNotifier(MediaOverrideState());
+final _supportedLocales = [const Locale('de'), const Locale('en')];
+
+void initDebugOverlay() {
+ // https://pub.dev/packages/debug_overlay
+ DebugOverlay.helpers.value = [
+ MediaOverrideDebugHelper(
+ _mediaOverrideState,
+ supportedLocales: _supportedLocales,
+ )
+ ];
}
class ExampleApp extends StatelessWidget {
- const ExampleApp({@required this.child}) : assert(child != null);
+ const ExampleApp({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
- return MaterialApp(
- title: 'Timetable example',
- theme: _createTheme(Brightness.light),
- darkTheme: _createTheme(Brightness.dark),
- home: child,
+ return ValueListenableBuilder(
+ valueListenable: _mediaOverrideState,
+ builder: (context, overrideState, _) {
+ return MaterialApp(
+ title: 'Timetable example',
+ theme: _createTheme(Brightness.light),
+ darkTheme: _createTheme(Brightness.dark),
+ themeMode: overrideState.themeMode,
+ locale: overrideState.locale,
+ localizationsDelegates: [
+ TimetableLocalizationsDelegate(),
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: _supportedLocales,
+ builder: kIsWeb ? null : DebugOverlay.builder(),
+ home: SafeArea(child: Scaffold(body: child)),
+ );
+ },
);
}
ThemeData _createTheme(Brightness brightness) {
- return ThemeData(
+ var theme = ThemeData(
brightness: brightness,
+ applyElevationOverlayColor: true,
primaryColor: Colors.blue,
- snackBarTheme: SnackBarThemeData(
- behavior: SnackBarBehavior.floating,
+ primarySwatch: Colors.blue,
+ snackBarTheme: SnackBarThemeData(behavior: SnackBarBehavior.floating),
+ );
+ theme = theme.copyWith(
+ colorScheme: theme.colorScheme
+ .copyWith(onBackground: theme.colorScheme.background.contrastColor),
+ textTheme: theme.textTheme.copyWith(
+ headline6:
+ theme.textTheme.headline6!.copyWith(fontWeight: FontWeight.normal),
),
+ appBarTheme: theme.appBarTheme.copyWith(backwardsCompatibility: false),
+ );
+
+ // We want to extend Timetable behind the navigation bar.
+ SystemChrome.setSystemUIOverlayStyle(
+ brightness.contrastSystemUiOverlayStyle
+ .copyWith(systemNavigationBarColor: Colors.transparent),
);
+ return theme;
}
}
diff --git a/example/pubspec.lock b/example/pubspec.lock
index dc446e0..0429194 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -1,114 +1,348 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
+ async:
+ dependency: transitive
+ description:
+ name: async
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.5.0"
black_hole_flutter:
dependency: transitive
description:
name: black_hole_flutter
url: "https://pub.dartlang.org"
source: hosted
- version: "0.2.13"
+ version: "0.3.0"
characters:
dependency: transitive
description:
name: characters
url: "https://pub.dartlang.org"
source: hosted
- version: "1.0.0"
+ version: "1.1.0"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
- version: "1.1.3"
+ version: "1.2.0"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.0"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
- version: "1.14.13"
- convert:
+ version: "1.15.0"
+ dart_date:
+ dependency: transitive
+ description:
+ name: dart_date
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.0-nullsafety.0"
+ data_size:
+ dependency: transitive
+ description:
+ name: data_size
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.0"
+ debug_overlay:
+ dependency: "direct main"
+ description:
+ name: debug_overlay
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.1.1"
+ device_info_plus:
dependency: transitive
description:
- name: convert
+ name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
- version: "2.1.1"
- crypto:
+ version: "1.0.0"
+ device_info_plus_linux:
dependency: transitive
description:
- name: crypto
+ name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
- version: "2.1.5"
- dartx:
+ version: "1.0.0"
+ device_info_plus_macos:
dependency: transitive
description:
- name: dartx
+ name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
- version: "0.5.0"
+ version: "1.0.0"
+ device_info_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: device_info_plus_platform_interface
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.1"
+ device_info_plus_web:
+ dependency: transitive
+ description:
+ name: device_info_plus_web
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ device_info_plus_windows:
+ dependency: transitive
+ description:
+ name: device_info_plus_windows
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ ffi:
+ dependency: transitive
+ description:
+ name: ffi
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "6.1.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_layout_grid:
+ dependency: transitive
+ description:
+ name: flutter_layout_grid
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.1"
+ flutter_localizations:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ http:
+ dependency: transitive
+ description:
+ name: http
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.13.1"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.0.0"
+ implicitly_animated_list:
+ dependency: transitive
+ description:
+ name: implicitly_animated_list
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.0"
+ intl:
+ dependency: transitive
+ description:
+ name: intl
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.17.0"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.6.3"
+ list_diff:
+ dependency: transitive
+ description:
+ name: list_diff
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.12.10"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
- version: "1.1.8"
+ version: "1.3.0"
+ package_info_plus:
+ dependency: transitive
+ description:
+ name: package_info_plus
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ package_info_plus_linux:
+ dependency: transitive
+ description:
+ name: package_info_plus_linux
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ package_info_plus_macos:
+ dependency: transitive
+ description:
+ name: package_info_plus_macos
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ package_info_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: package_info_plus_platform_interface
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ package_info_plus_web:
+ dependency: transitive
+ description:
+ name: package_info_plus_web
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ package_info_plus_windows:
+ dependency: transitive
+ description:
+ name: package_info_plus_windows
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
- version: "1.7.0"
+ version: "1.8.0"
pedantic:
dependency: transitive
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
- version: "1.9.0"
- resource:
+ version: "1.11.0"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ quiver:
dependency: transitive
description:
- name: resource
+ name: quiver
url: "https://pub.dartlang.org"
source: hosted
- version: "2.1.7"
+ version: "3.0.0"
rxdart:
dependency: transitive
description:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
- version: "0.24.1"
+ version: "0.26.0"
+ sensors:
+ dependency: transitive
+ description:
+ name: sensors
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ shake:
+ dependency: transitive
+ description:
+ name: shake
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
- time:
+ source_span:
dependency: transitive
description:
- name: time
+ name: source_span
url: "https://pub.dartlang.org"
source: hosted
- version: "1.3.0"
- time_machine:
- dependency: "direct main"
+ version: "1.8.1"
+ stack_trace:
+ dependency: transitive
description:
- name: time_machine
+ name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
- version: "0.9.13"
+ version: "1.10.0"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.0"
+ supercharged:
+ dependency: transitive
+ description:
+ name: supercharged
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ supercharged_dart:
+ dependency: transitive
+ description:
+ name: supercharged_dart
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ timeago:
+ dependency: transitive
+ description:
+ name: timeago
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.0.0"
timetable:
dependency: "direct main"
description:
@@ -116,20 +350,34 @@ packages:
relative: true
source: path
version: "0.2.7"
+ tuple:
+ dependency: transitive
+ description:
+ name: tuple
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
- version: "1.2.0"
+ version: "1.3.0"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.8"
+ version: "2.1.0"
+ win32:
+ dependency: transitive
+ description:
+ name: win32
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.5"
sdks:
- dart: ">=2.9.0-14.0.dev <3.0.0"
- flutter: ">=1.17.0"
+ dart: ">=2.12.0 <3.0.0"
+ flutter: ">=2.0.0"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 64dc4fc..4259b41 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -1,21 +1,18 @@
name: timetable_example
description: A simple example app for the timetable package
-version: 0.2.7
+publish_to: none
environment:
- sdk: ">=2.7.0 <3.0.0"
+ sdk: '>=2.12.0 <3.0.0'
dependencies:
+ debug_overlay: ^0.1.1
flutter:
sdk: flutter
-
+ flutter_localizations:
+ sdk: flutter
timetable:
path: ../
- time_machine: ^0.9.12
flutter:
uses-material-design: true
-
- assets:
- - packages/time_machine/data/cultures/cultures.bin
- - packages/time_machine/data/tzdb/tzdb.bin
diff --git a/example/web/favicon.png b/example/web/favicon.png
new file mode 100644
index 0000000..8aaa46a
Binary files /dev/null and b/example/web/favicon.png differ
diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png
new file mode 100644
index 0000000..b749bfe
Binary files /dev/null and b/example/web/icons/Icon-192.png differ
diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png
new file mode 100644
index 0000000..88cfd48
Binary files /dev/null and b/example/web/icons/Icon-512.png differ
diff --git a/example/web/index.html b/example/web/index.html
new file mode 100644
index 0000000..65d8e3f
--- /dev/null
+++ b/example/web/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ timetable_example
+
+
+
+
+
+
+
+
diff --git a/lib/src/all_day.dart b/lib/src/all_day.dart
deleted file mode 100644
index f193f44..0000000
--- a/lib/src/all_day.dart
+++ /dev/null
@@ -1,196 +0,0 @@
-import 'dart:math' as math;
-
-import 'package:dartx/dartx.dart';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/rendering.dart';
-import 'package:meta/meta.dart';
-
-/// Information about how an all-day event was laid out.
-@immutable
-class AllDayEventLayoutInfo {
- const AllDayEventLayoutInfo({
- @required this.hiddenStartDays,
- @required this.hiddenEndDays,
- }) : assert(hiddenStartDays != null),
- assert(hiddenStartDays >= 0),
- assert(hiddenEndDays != null),
- assert(hiddenEndDays >= 0);
-
- final double hiddenStartDays;
- final double hiddenEndDays;
-
- @override
- bool operator ==(dynamic other) {
- return other is AllDayEventLayoutInfo &&
- hiddenStartDays == other.hiddenStartDays &&
- hiddenEndDays == other.hiddenEndDays;
- }
-
- @override
- int get hashCode => hashList([hiddenStartDays, hiddenEndDays]);
-}
-
-class AllDayEventBackgroundPainter extends CustomPainter {
- const AllDayEventBackgroundPainter({
- @required this.info,
- @required this.color,
- this.borderRadius = 0,
- }) : assert(info != null),
- assert(color != null),
- assert(borderRadius != null);
-
- final AllDayEventLayoutInfo info;
- final Color color;
- final double borderRadius;
-
- @override
- void paint(Canvas canvas, Size size) {
- canvas.drawPath(
- _getPath(size, info, borderRadius),
- Paint()..color = color,
- );
- }
-
- @override
- bool shouldRepaint(covariant AllDayEventBackgroundPainter oldDelegate) {
- return info != oldDelegate.info ||
- color != oldDelegate.color ||
- borderRadius != oldDelegate.borderRadius;
- }
-}
-
-/// A modified [RoundedRectangleBorder] that morphs to triangular left and/or
-/// right borders if not all of the event is currently visible.
-class AllDayEventBorder extends ShapeBorder {
- const AllDayEventBorder({
- @required this.info,
- this.side = BorderSide.none,
- this.borderRadius = 0,
- }) : assert(info != null),
- assert(side != null),
- assert(borderRadius != null);
-
- final AllDayEventLayoutInfo info;
- final BorderSide side;
- final double borderRadius;
-
- @override
- EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width);
-
- @override
- ShapeBorder scale(double t) {
- return AllDayEventBorder(
- info: info,
- side: side.scale(t),
- borderRadius: borderRadius * t,
- );
- }
-
- @override
- Path getInnerPath(Rect rect, {TextDirection textDirection}) {
- return null;
- }
-
- @override
- Path getOuterPath(Rect rect, {TextDirection textDirection}) {
- return _getPath(rect.size, info, borderRadius);
- }
-
- @override
- void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
- // For some reason, when we paint the background in this shape directly, it
- // lags while scrolling. Hence, we only use it to provide the outer path
- // used for clipping.
- }
-
- @override
- bool operator ==(Object other) {
- if (other.runtimeType != runtimeType) {
- return false;
- }
- return other is AllDayEventBorder &&
- other.info == info &&
- other.side == side &&
- other.borderRadius == borderRadius;
- }
-
- @override
- int get hashCode => hashValues(info, side, borderRadius);
-
- @override
- String toString() =>
- '${objectRuntimeType(this, 'RoundedRectangleBorder')}($side, $borderRadius)';
-}
-
-Path _getPath(Size size, AllDayEventLayoutInfo info, double radius) {
- final height = size.height;
- // final radius = borderRadius.coerceAtMost(width / 2);
-
- final maxTipWidth = height / 4;
- final leftTipWidth = info.hiddenStartDays.coerceAtMost(1) * maxTipWidth;
- final rightTipWidth = info.hiddenEndDays.coerceAtMost(1) * maxTipWidth;
-
- final width = size.width;
- // final leftTipBase = math.min(leftTipWidth + radius, width - radius);
- // final rightTipBase = math.max(width - rightTipWidth - radius, radius);
- final leftTipBase = info.hiddenStartDays > 0
- ? math.min(leftTipWidth + radius, width - radius)
- : leftTipWidth + radius;
- final rightTipBase = info.hiddenEndDays > 0
- ? math.max(width - rightTipWidth - radius, radius)
- : width - rightTipWidth - radius;
-
- final tipSize = Size.square(radius * 2);
-
- // no tip: 0 ≈ 0°
- // full tip: PI / 4 ≈ 45°
- final leftTipAngle = math.pi / 2 - math.atan2(height / 2, leftTipWidth);
- final rightTipAngle = math.pi / 2 - math.atan2(height / 2, rightTipWidth);
-
- return Path()
- ..moveTo(leftTipBase, 0)
- // Right top
- ..arcTo(
- Offset(rightTipBase - radius, 0) & tipSize,
- math.pi * 3 / 2,
- math.pi / 2 - rightTipAngle,
- false,
- )
- // Right tip
- ..arcTo(
- Offset(rightTipBase + rightTipWidth - radius, height / 2 - radius) &
- tipSize,
- -rightTipAngle,
- 2 * rightTipAngle,
- false,
- )
- // Right bottom
- ..arcTo(
- Offset(rightTipBase - radius, height - radius * 2) & tipSize,
- rightTipAngle,
- math.pi / 2 - rightTipAngle,
- false,
- )
- // Left bottom
- ..arcTo(
- Offset(leftTipBase - radius, height - radius * 2) & tipSize,
- math.pi / 2,
- math.pi / 2 - leftTipAngle,
- false,
- )
- // Left tip
- ..arcTo(
- Offset(leftTipBase - leftTipWidth - radius, height / 2 - radius) &
- tipSize,
- math.pi - leftTipAngle,
- 2 * leftTipAngle,
- false,
- )
- // Left top
- ..arcTo(
- Offset(leftTipBase - radius, 0) & tipSize,
- math.pi + leftTipAngle,
- math.pi / 2 - leftTipAngle,
- false,
- );
-}
diff --git a/lib/src/basic.dart b/lib/src/basic.dart
deleted file mode 100644
index adedd90..0000000
--- a/lib/src/basic.dart
+++ /dev/null
@@ -1,147 +0,0 @@
-import 'package:black_hole_flutter/black_hole_flutter.dart';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/material.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-
-import 'all_day.dart';
-import 'event.dart';
-
-/// A basic implementation of [Event] to get you started.
-///
-/// See also:
-/// - [BasicEventWidget], which can display instances of [BasicEvent].
-class BasicEvent extends Event {
- const BasicEvent({
- @required Object id,
- @required this.title,
- @required this.color,
- @required LocalDateTime start,
- @required LocalDateTime end,
- }) : assert(title != null),
- super(id: id, start: start, end: end);
-
- /// A title for the user, used e.g. by [BasicEventWidget].
- final String title;
-
- /// [Color] used for displaying this event.
- ///
- /// This is used e.g. by [BasicEventWidget] as the background color.
- final Color color;
-
- @override
- bool operator ==(dynamic other) =>
- super == other && title == other.title && color == other.color;
-
- @override
- int get hashCode => hashList([super.hashCode, title, color]);
-}
-
-/// A simple [Widget] for displaying a [BasicEvent].
-class BasicEventWidget extends StatelessWidget {
- const BasicEventWidget(
- this.event, {
- Key key,
- this.onTap,
- }) : assert(event != null),
- super(key: key);
-
- /// The [BasicEvent] to be displayed.
- final BasicEvent event;
-
- /// An optional [VoidCallback] that will be invoked when the user taps this
- /// widget.
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) {
- return Material(
- shape: RoundedRectangleBorder(
- side: BorderSide(
- color: context.theme.scaffoldBackgroundColor,
- width: 0.75,
- ),
- borderRadius: BorderRadius.circular(4),
- ),
- color: event.color,
- child: InkWell(
- onTap: onTap,
- child: Padding(
- padding: EdgeInsets.fromLTRB(4, 2, 4, 0),
- child: DefaultTextStyle(
- style: context.textTheme.bodyText2.copyWith(
- fontSize: 12,
- color: event.color.highEmphasisOnColor,
- ),
- child: Text(event.title),
- ),
- ),
- ),
- );
- }
-}
-
-/// A simple [Widget] for displaying a [BasicEvent] as an all-day event.
-class BasicAllDayEventWidget extends StatelessWidget {
- const BasicAllDayEventWidget(
- this.event, {
- Key key,
- @required this.info,
- this.borderRadius = 4,
- this.onTap,
- }) : assert(event != null),
- assert(info != null),
- assert(borderRadius != null),
- super(key: key);
-
- /// The [BasicEvent] to be displayed.
- final BasicEvent event;
- final AllDayEventLayoutInfo info;
- final double borderRadius;
-
- /// An optional [VoidCallback] that will be invoked when the user taps this
- /// widget.
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: EdgeInsets.all(2),
- child: CustomPaint(
- painter: AllDayEventBackgroundPainter(
- info: info,
- color: event.color,
- borderRadius: borderRadius,
- ),
- child: Material(
- shape: AllDayEventBorder(
- info: info,
- side: BorderSide.none,
- borderRadius: borderRadius,
- ),
- clipBehavior: Clip.antiAlias,
- color: Colors.transparent,
- child: InkWell(
- onTap: onTap,
- child: _buildContent(context),
- ),
- ),
- ),
- );
- }
-
- Widget _buildContent(BuildContext context) {
- return Padding(
- padding: EdgeInsets.fromLTRB(4, 2, 0, 2),
- child: Align(
- alignment: AlignmentDirectional.centerStart,
- child: DefaultTextStyle(
- style: context.textTheme.bodyText2.copyWith(
- fontSize: 14,
- color: event.color.highEmphasisOnColor,
- ),
- child: Text(event.title, maxLines: 1),
- ),
- ),
- );
- }
-}
diff --git a/lib/src/callbacks.dart b/lib/src/callbacks.dart
new file mode 100644
index 0000000..d3a8765
--- /dev/null
+++ b/lib/src/callbacks.dart
@@ -0,0 +1,117 @@
+import 'package:flutter/widgets.dart';
+
+import 'components/date_content.dart';
+import 'components/date_header.dart';
+import 'components/multi_date_event_header.dart';
+import 'components/week_indicator.dart';
+import 'week.dart';
+
+typedef WeekTapCallback = void Function(Week week);
+typedef DateTapCallback = void Function(DateTime date);
+typedef DateTimeTapCallback = void Function(DateTime dateTime);
+
+@immutable
+class TimetableCallbacks {
+ const TimetableCallbacks({
+ this.onWeekTap,
+ this.onDateTap,
+ this.onDateBackgroundTap,
+ this.onDateTimeBackgroundTap,
+ });
+
+ /// Called when the user taps on a week.
+ ///
+ /// Used internally by [WeekIndicator].
+ final WeekTapCallback? onWeekTap;
+
+ /// Called when the user taps on a date.
+ ///
+ /// You can react to this, e.g., by changing your view to just show this
+ /// single date.
+ ///
+ /// Used internally by [DateHeader].
+ final DateTapCallback? onDateTap;
+
+ /// Called when the user taps on the background of a date.
+ ///
+ /// You can react to this, e.g., by creating an event for that specific date.
+ ///
+ /// Used internally by [MultiDateEventHeader].
+ final DateTapCallback? onDateBackgroundTap;
+
+ /// Called when the user taps on the background of a date at a specific time.
+ ///
+ /// You can react to this, e.g., by creating an event for that specific date
+ /// and time.
+ ///
+ /// Used internally by [DateContent].
+ final DateTimeTapCallback? onDateTimeBackgroundTap;
+
+ TimetableCallbacks copyWith({
+ WeekTapCallback? onWeekTap,
+ bool clearOnWeekTap = false,
+ DateTapCallback? onDateTap,
+ bool clearOnDateTap = false,
+ DateTapCallback? onDateBackgroundTap,
+ bool clearOnDateBackgroundTap = false,
+ DateTimeTapCallback? onDateTimeBackgroundTap,
+ bool clearOnDateTimeBackgroundTap = false,
+ }) {
+ assert(!(clearOnWeekTap && onWeekTap != null));
+ assert(!(clearOnDateTap && onDateTap != null));
+ assert(!(clearOnDateBackgroundTap && onDateBackgroundTap != null));
+ assert(!(clearOnDateTimeBackgroundTap && onDateTimeBackgroundTap != null));
+
+ return TimetableCallbacks(
+ onWeekTap: clearOnWeekTap ? null : onWeekTap ?? this.onWeekTap,
+ onDateTap: clearOnDateTap ? null : onDateTap ?? this.onDateTap,
+ onDateBackgroundTap: clearOnDateBackgroundTap
+ ? null
+ : onDateBackgroundTap ?? this.onDateBackgroundTap,
+ onDateTimeBackgroundTap: clearOnDateTimeBackgroundTap
+ ? null
+ : onDateTimeBackgroundTap ?? this.onDateTimeBackgroundTap,
+ );
+ }
+
+ @override
+ int get hashCode {
+ return hashValues(
+ onWeekTap,
+ onDateTap,
+ onDateBackgroundTap,
+ onDateTimeBackgroundTap,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is TimetableCallbacks &&
+ onWeekTap == other.onWeekTap &&
+ onDateTap == other.onDateTap &&
+ onDateBackgroundTap == other.onDateBackgroundTap &&
+ onDateTimeBackgroundTap == other.onDateTimeBackgroundTap;
+ }
+}
+
+/// Provides the default callbacks for Timetable widgets below it.
+///
+/// [DefaultTimetableCallbacks] widgets above this on are overridden.
+class DefaultTimetableCallbacks extends InheritedWidget {
+ const DefaultTimetableCallbacks({
+ required this.callbacks,
+ required Widget child,
+ }) : super(child: child);
+
+ final TimetableCallbacks callbacks;
+
+ @override
+ bool updateShouldNotify(DefaultTimetableCallbacks oldWidget) =>
+ callbacks != oldWidget.callbacks;
+
+ static TimetableCallbacks? of(BuildContext context) {
+ return context
+ .dependOnInheritedWidgetOfExactType()
+ ?.callbacks;
+ }
+}
diff --git a/lib/src/components/date_content.dart b/lib/src/components/date_content.dart
new file mode 100644
index 0000000..ae656d9
--- /dev/null
+++ b/lib/src/components/date_content.dart
@@ -0,0 +1,77 @@
+import 'package:flutter/widgets.dart';
+
+import '../callbacks.dart';
+import '../event/event.dart';
+import '../time/overlay.dart';
+import '../utils.dart';
+import 'date_events.dart';
+import 'time_overlays.dart';
+
+/// A widget that displays [Event]s and [TimeOverlay]s.
+///
+/// If [onBackgroundTap] is not supplied, [DefaultTimetableCallbacks]'s
+/// `onDateTimeBackgroundTap` is used if it's provided above in the widget tree.
+///
+/// See also:
+///
+/// * [DateEvents] and [TimeOverlays], which are used to actually layout
+/// [Event]s and [TimeOverlay]s. [DateEvents] can be styled.
+/// * [DefaultTimetableCallbacks], which provides callbacks to descendant
+/// Timetable widgets.
+class DateContent extends StatelessWidget {
+ DateContent({
+ Key? key,
+ required this.date,
+ required List events,
+ this.overlays = const [],
+ this.onBackgroundTap,
+ }) : assert(date.isValidTimetableDate),
+ assert(
+ events.every((e) => e.interval.intersects(date.fullDayInterval)),
+ 'All events must intersect the given date',
+ ),
+ assert(
+ events.toSet().length == events.length,
+ 'Events may not contain duplicates',
+ ),
+ events = events.sortedByStartLength(),
+ super(key: key);
+
+ final DateTime date;
+
+ final List events;
+ final List overlays;
+
+ final DateTimeTapCallback? onBackgroundTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final onBackgroundTap = this.onBackgroundTap ??
+ DefaultTimetableCallbacks.of(context)?.onDateTimeBackgroundTap;
+
+ return LayoutBuilder(builder: (context, constraints) {
+ final height = constraints.maxHeight;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTapUp: onBackgroundTap != null
+ ? (details) =>
+ onBackgroundTap(date + (details.localPosition.dy / height).days)
+ : null,
+ child: Stack(children: [
+ _buildOverlaysForPosition(TimeOverlayPosition.behindEvents),
+ DateEvents(date: date, events: events),
+ _buildOverlaysForPosition(TimeOverlayPosition.inFrontOfEvents),
+ ]),
+ );
+ });
+ }
+
+ Widget _buildOverlaysForPosition(TimeOverlayPosition position) {
+ return Positioned.fill(
+ child: TimeOverlays(
+ overlays: overlays.where((it) => it.position == position).toList(),
+ ),
+ );
+ }
+}
diff --git a/lib/src/components/date_dividers.dart b/lib/src/components/date_dividers.dart
new file mode 100644
index 0000000..e8fb804
--- /dev/null
+++ b/lib/src/components/date_dividers.dart
@@ -0,0 +1,109 @@
+import 'package:flutter/material.dart';
+
+import '../config.dart';
+import '../date/controller.dart';
+import '../theme.dart';
+
+/// A widget that displays vertical dividers betweeen dates.
+///
+/// A [DefaultDateController] must be above in the widget tree.
+///
+/// See also:
+///
+/// * [DateDividersStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class DateDividers extends StatelessWidget {
+ const DateDividers({
+ Key? key,
+ this.style,
+ this.child,
+ }) : super(key: key);
+
+ final DateDividersStyle? style;
+ final Widget? child;
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomPaint(
+ painter: _DateDividersPainter(
+ controller: DefaultDateController.of(context)!,
+ style: style ?? TimetableTheme.orDefaultOf(context).dateDividersStyle,
+ ),
+ child: child,
+ );
+ }
+}
+
+/// Defines visual properties for [DateDividers].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class DateDividersStyle {
+ factory DateDividersStyle(
+ BuildContext context, {
+ Color? color,
+ double? width,
+ }) {
+ final dividerBorderSide = Divider.createBorderSide(context);
+ return DateDividersStyle.raw(
+ color: color ?? dividerBorderSide.color,
+ width: width ?? dividerBorderSide.width,
+ );
+ }
+
+ const DateDividersStyle.raw({
+ required this.color,
+ required this.width,
+ }) : assert(width >= 0);
+
+ final Color color;
+ final double width;
+
+ DateDividersStyle copyWith({Color? color, double? width}) {
+ return DateDividersStyle.raw(
+ color: color ?? this.color,
+ width: width ?? this.width,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(color, width);
+ @override
+ bool operator ==(Object other) {
+ return other is DateDividersStyle &&
+ color == other.color &&
+ width == other.width;
+ }
+}
+
+class _DateDividersPainter extends CustomPainter {
+ _DateDividersPainter({
+ required this.controller,
+ required this.style,
+ }) : _paint = Paint()
+ ..color = style.color
+ ..strokeWidth = style.width,
+ super(repaint: controller);
+
+ final DateController controller;
+ final DateDividersStyle style;
+ final Paint _paint;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ canvas.clipRect(Offset.zero & size);
+ final pageValue = controller.value;
+ final initialOffset = 1 - pageValue.page % 1;
+ for (var i = -1; i + initialOffset < pageValue.visibleDayCount + 1; i++) {
+ final x = (initialOffset + i) * size.width / pageValue.visibleDayCount;
+ canvas.drawLine(Offset(x, 0), Offset(x, size.height), _paint);
+ }
+ }
+
+ @override
+ bool shouldRepaint(_DateDividersPainter oldDelegate) =>
+ style != oldDelegate.style;
+}
diff --git a/lib/src/components/date_events.dart b/lib/src/components/date_events.dart
new file mode 100644
index 0000000..2e97408
--- /dev/null
+++ b/lib/src/components/date_events.dart
@@ -0,0 +1,395 @@
+import 'dart:ui';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/widgets.dart';
+
+import '../config.dart';
+import '../event/builder.dart';
+import '../event/event.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays the given [Event]s.
+///
+/// If [eventBuilder] is not provided, a [DefaultEventBuilder] must be above in
+/// the widget tree.
+///
+/// See also:
+///
+/// * [DateEventsStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class DateEvents extends StatelessWidget {
+ DateEvents({
+ Key? key,
+ required this.date,
+ required List events,
+ this.eventBuilder,
+ this.style,
+ }) : assert(date.isValidTimetableDate),
+ assert(
+ events.every((e) => e.interval.intersects(date.fullDayInterval)),
+ 'All events must intersect the given date',
+ ),
+ assert(
+ events.toSet().length == events.length,
+ 'Events may not contain duplicates',
+ ),
+ events = events.sortedByStartLength(),
+ super(key: key);
+
+ final DateTime date;
+ final List events;
+ final EventBuilder? eventBuilder;
+ final DateEventsStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final eventBuilder =
+ this.eventBuilder ?? DefaultEventBuilder.of(context)!;
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).dateEventsStyleProvider(date);
+ return Padding(
+ padding: style.padding,
+ child: CustomMultiChildLayout(
+ delegate: _DayEventsLayoutDelegate(date, events, style),
+ children: [
+ for (final event in events)
+ LayoutId(
+ key: ValueKey(event),
+ id: event,
+ child: eventBuilder(context, event),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [DateEvents].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+class DateEventsStyle {
+ factory DateEventsStyle(
+ // To allow future updates to use the context and align the parameters to
+ // other style constructors.
+ // ignore: avoid_unused_constructor_parameters
+ BuildContext context,
+ // ignore: avoid_unused_constructor_parameters, See above.
+ DateTime date, {
+ Duration? minEventDuration,
+ double? minEventHeight,
+ EdgeInsetsGeometry? padding,
+ bool? enableStacking,
+ Duration? minEventDeltaForStacking,
+ double? stackedEventSpacing,
+ }) {
+ return DateEventsStyle.raw(
+ minEventDuration: minEventDuration ?? Duration(minutes: 30),
+ minEventHeight: minEventHeight ?? 16,
+ padding: padding ?? EdgeInsets.only(right: 1),
+ enableStacking: enableStacking ?? true,
+ minEventDeltaForStacking:
+ minEventDeltaForStacking ?? Duration(minutes: 15),
+ stackedEventSpacing: stackedEventSpacing ?? 4,
+ );
+ }
+
+ const DateEventsStyle.raw({
+ required this.minEventDuration,
+ required this.minEventHeight,
+ required this.padding,
+ required this.enableStacking,
+ required this.minEventDeltaForStacking,
+ required this.stackedEventSpacing,
+ });
+
+ /// Minimum [Duration] to size a part-day event.
+ ///
+ /// Can be used together with [minEventHeight].
+ final Duration minEventDuration;
+
+ /// Minimum height to size a part-day event.
+ ///
+ /// Can be used together with [minEventDuration].
+ final double minEventHeight;
+
+ final EdgeInsetsGeometry padding;
+
+ /// Controls whether overlapping events may be stacked on top of each other.
+ ///
+ /// If set to `true`, intersecting events may be stacked if their start values
+ /// differ by at least [minEventDeltaForStacking]. If set to
+ /// `false`, intersecting events will always be shown next to each other and
+ /// not overlap.
+ final bool enableStacking;
+
+ /// When the start values of two events differ by at least this value, they
+ /// may be stacked on top of each other.
+ ///
+ /// If the difference is less, they will be shown next to each other.
+ ///
+ /// See also:
+ ///
+ /// * [enableStacking], which can disable the stacking behavior completely.
+ final Duration minEventDeltaForStacking;
+
+ /// Horizontal space between two parallel events stacked on top of each other.
+ final double stackedEventSpacing;
+
+ DateEventsStyle copyWith({
+ Duration? minEventDuration,
+ double? minEventHeight,
+ EdgeInsetsGeometry? padding,
+ bool? enableStacking,
+ Duration? minEventDeltaForStacking,
+ double? stackedEventSpacing,
+ }) {
+ return DateEventsStyle.raw(
+ minEventDuration: minEventDuration ?? this.minEventDuration,
+ minEventHeight: minEventHeight ?? this.minEventHeight,
+ padding: padding ?? this.padding,
+ enableStacking: enableStacking ?? this.enableStacking,
+ minEventDeltaForStacking:
+ minEventDeltaForStacking ?? this.minEventDeltaForStacking,
+ stackedEventSpacing: stackedEventSpacing ?? this.stackedEventSpacing,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(
+ minEventDuration,
+ minEventHeight,
+ padding,
+ enableStacking,
+ minEventDeltaForStacking,
+ stackedEventSpacing,
+ );
+ @override
+ bool operator ==(Object other) {
+ return other is DateEventsStyle &&
+ other.minEventDuration == minEventDuration &&
+ other.minEventHeight == minEventHeight &&
+ other.padding == padding &&
+ other.enableStacking == enableStacking &&
+ other.minEventDeltaForStacking == minEventDeltaForStacking &&
+ other.stackedEventSpacing == stackedEventSpacing;
+ }
+}
+
+class _DayEventsLayoutDelegate
+ extends MultiChildLayoutDelegate {
+ _DayEventsLayoutDelegate(this.date, this.events, this.style)
+ : assert(date.isValidTimetableDate);
+
+ static const minWidth = 4.0;
+
+ final DateTime date;
+ final List events;
+
+ final DateEventsStyle style;
+
+ @override
+ void performLayout(Size size) {
+ final positions = _calculatePositions(size.height);
+
+ double durationToY(Duration duration) {
+ assert(duration.isValidTimetableTimeOfDay);
+ return size.height * (duration / 1.days);
+ }
+
+ double timeToY(DateTime dateTime) {
+ assert(dateTime.isValidTimetableDateTime);
+
+ if (dateTime < date) return 0;
+ if (dateTime.atStartOfDay > date) return size.height;
+ return durationToY(dateTime.timeOfDay);
+ }
+
+ for (final event in events) {
+ final top = timeToY(event.start)
+ .coerceAtMost(size.height - durationToY(style.minEventDuration))
+ .coerceAtMost(size.height - style.minEventHeight);
+ final height = durationToY(_durationOn(event, size.height))
+ .clamp(0, size.height - top)
+ .toDouble();
+
+ final position = positions.eventPositions[event]!;
+ final columnWidth =
+ size.width / positions.groupColumnCounts[position.group];
+ final columnLeft = columnWidth * position.column;
+ final left = columnLeft + position.index * style.stackedEventSpacing;
+ final width = columnWidth * position.columnSpan -
+ position.index * style.stackedEventSpacing;
+
+ final childSize = Size(width.coerceAtLeast(minWidth), height);
+ layoutChild(event, BoxConstraints.tight(childSize));
+ positionChild(event, Offset(left, top));
+ }
+ }
+
+ _EventPositions _calculatePositions(double height) {
+ // How this layout algorithm works:
+ // We first divide all events into groups, whereas a group contains all
+ // events that intersect one another.
+ // Inside a group, events with very close start times are split into
+ // multiple columns.
+ final positions = _EventPositions();
+
+ var currentGroup = [];
+ DateTime? currentEnd;
+ for (final event in events) {
+ if (currentEnd != null && event.start >= currentEnd) {
+ _endGroup(positions, currentGroup, height);
+ currentGroup = [];
+ currentEnd = null;
+ }
+
+ currentGroup.add(event);
+ final actualEnd = _actualEnd(event, height);
+ currentEnd = currentEnd == null
+ ? actualEnd
+ : currentEnd.coerceAtLeast(_actualEnd(event, height));
+ }
+ _endGroup(positions, currentGroup, height);
+
+ return positions;
+ }
+
+ void _endGroup(
+ _EventPositions positions,
+ List currentGroup,
+ double height,
+ ) {
+ if (currentGroup.isEmpty) return;
+ if (currentGroup.length == 1) {
+ positions.eventPositions[currentGroup.first] =
+ _SingleEventPosition(positions.groupColumnCounts.length, 0, 0);
+ positions.groupColumnCounts.add(1);
+ return;
+ }
+
+ final columns = >[];
+ for (final event in currentGroup) {
+ var minColumn = -1;
+ var minIndex = 1 << 31;
+ DateTime? minEnd;
+ var columnFound = false;
+ for (var columnIndex = 0; columnIndex < columns.length; columnIndex++) {
+ final column = columns[columnIndex];
+ final other = column.last;
+
+ // No space in current column
+ if (!style.enableStacking && event.start < _actualEnd(other, height) ||
+ style.enableStacking &&
+ event.start < other.start + style.minEventDeltaForStacking) {
+ continue;
+ }
+
+ final index = column
+ .where((e) => _actualEnd(e, height) >= event.start)
+ .map((e) => positions.eventPositions[e]!.index)
+ .max() ??
+ -1;
+
+ final previousEnd = column
+ .map((it) => it.end)
+ .reduce((value, element) => value.coerceAtLeast(element));
+
+ // Further at the top and hence wider
+ if (index < minIndex ||
+ (index == minIndex && (minEnd != null && previousEnd < minEnd))) {
+ minColumn = columnIndex;
+ minIndex = index;
+ minEnd = previousEnd;
+ columnFound = true;
+ }
+ }
+
+ // If no column fits
+ if (!columnFound) {
+ positions.eventPositions[event] = _SingleEventPosition(
+ positions.groupColumnCounts.length, columns.length, 0);
+ columns.add([event]);
+ continue;
+ }
+
+ positions.eventPositions[event] = _SingleEventPosition(
+ positions.groupColumnCounts.length, minColumn, minIndex + 1);
+ columns[minColumn].add(event);
+ }
+
+ // Expand events to multiple columns if possible.
+ for (final event in currentGroup) {
+ final position = positions.eventPositions[event]!;
+ if (position.column == columns.length - 1) continue;
+
+ var columnSpan = 1;
+ for (var i = position.column + 1; i < columns.length; i++) {
+ final hasOverlapInColumn = currentGroup
+ .where((e) => positions.eventPositions[e]!.column == i)
+ .where((e) =>
+ event.start < _actualEnd(e, height) &&
+ e.start < _actualEnd(event, height))
+ .isNotEmpty;
+ if (hasOverlapInColumn) break;
+
+ columnSpan++;
+ }
+ positions.eventPositions[event] =
+ position.copyWith(columnSpan: columnSpan);
+ }
+
+ positions.groupColumnCounts.add(columns.length);
+ }
+
+ DateTime _actualEnd(E event, double height) {
+ final minDurationForHeight = (style.minEventHeight / height).days;
+ return event.end
+ .coerceAtLeast(event.start + style.minEventDuration)
+ .coerceAtLeast(event.start + minDurationForHeight);
+ }
+
+ Duration _durationOn(E event, double height) {
+ final start = event.start.coerceAtLeast(date);
+ final end = _actualEnd(event, height).coerceAtMost(date + 1.days);
+ return end.difference(start);
+ }
+
+ @override
+ bool shouldRelayout(_DayEventsLayoutDelegate oldDelegate) {
+ return date != oldDelegate.date ||
+ style != oldDelegate.style ||
+ !DeepCollectionEquality().equals(events, oldDelegate.events);
+ }
+}
+
+class _EventPositions {
+ final List groupColumnCounts = [];
+ final Map eventPositions = {};
+}
+
+class _SingleEventPosition {
+ _SingleEventPosition(
+ this.group,
+ this.column,
+ this.index, {
+ this.columnSpan = 1,
+ });
+
+ final int group;
+ final int column;
+ final int columnSpan;
+ final int index;
+
+ _SingleEventPosition copyWith({int? columnSpan}) {
+ return _SingleEventPosition(
+ group,
+ column,
+ index,
+ columnSpan: columnSpan ?? this.columnSpan,
+ );
+ }
+}
diff --git a/lib/src/components/date_header.dart b/lib/src/components/date_header.dart
new file mode 100644
index 0000000..96b63a7
--- /dev/null
+++ b/lib/src/components/date_header.dart
@@ -0,0 +1,148 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../callbacks.dart';
+import '../config.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+import 'date_indicator.dart';
+import 'weekday_indicator.dart';
+
+/// A widget that displays the weekday and date of month for the given date.
+///
+/// If [onTap] is not supplied, [DefaultTimetableCallbacks]'s `onDateTap` is
+/// used if it's provided above in the widget tree.
+///
+/// See also:
+///
+/// * [DateHeaderStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+/// * [DefaultTimetableCallbacks], which provides callbacks to descendant
+/// Timetable widgets.
+class DateHeader extends StatelessWidget {
+ DateHeader(
+ this.date, {
+ Key? key,
+ this.onTap,
+ this.style,
+ }) : assert(date.isValidTimetableDate),
+ super(key: key);
+
+ final DateTime date;
+ final VoidCallback? onTap;
+ final DateHeaderStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).dateHeaderStyleProvider(date);
+ final callbacks = DefaultTimetableCallbacks.of(context);
+ final defaultOnTap = callbacks?.onDateTap;
+
+ return InkWell(
+ onTap: onTap ?? (defaultOnTap != null ? () => defaultOnTap(date) : null),
+ child: Tooltip(
+ message: style.tooltip,
+ child: Padding(
+ padding: style.padding,
+ child: DefaultTimetableCallbacks(
+ callbacks: (callbacks ?? TimetableCallbacks())
+ .copyWith(clearOnDateTap: true),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ if (style.showWeekdayIndicator) WeekdayIndicator(date),
+ if (style.showWeekdayIndicator && style.showDateIndicator)
+ SizedBox(height: style.indicatorSpacing),
+ if (style.showDateIndicator) DateIndicator(date),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [DateHeader].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class DateHeaderStyle {
+ factory DateHeaderStyle(
+ BuildContext context,
+ DateTime date, {
+ String? tooltip,
+ EdgeInsetsGeometry? padding,
+ bool? showWeekdayIndicator,
+ double? indicatorSpacing,
+ bool? showDateIndicator,
+ }) {
+ assert(date.isValidTimetableDate);
+
+ return DateHeaderStyle.raw(
+ tooltip: tooltip ??
+ () {
+ context.dependOnTimetableLocalizations();
+ return DateFormat.yMMMMEEEEd().format(date);
+ }(),
+ padding: padding ?? EdgeInsets.all(4),
+ showWeekdayIndicator: showWeekdayIndicator ?? true,
+ indicatorSpacing: indicatorSpacing ?? 4,
+ showDateIndicator: showDateIndicator ?? true,
+ );
+ }
+
+ const DateHeaderStyle.raw({
+ required this.tooltip,
+ required this.padding,
+ required this.showWeekdayIndicator,
+ required this.indicatorSpacing,
+ required this.showDateIndicator,
+ });
+
+ final String tooltip;
+ final EdgeInsetsGeometry padding;
+ final bool showWeekdayIndicator;
+ final double indicatorSpacing;
+ final bool showDateIndicator;
+
+ DateHeaderStyle copyWith({
+ String? tooltip,
+ EdgeInsetsGeometry? padding,
+ bool? showWeekdayIndicator,
+ double? indicatorSpacing,
+ bool? showDateIndicator,
+ }) {
+ return DateHeaderStyle.raw(
+ tooltip: tooltip ?? this.tooltip,
+ padding: padding ?? this.padding,
+ showWeekdayIndicator: showWeekdayIndicator ?? this.showWeekdayIndicator,
+ indicatorSpacing: indicatorSpacing ?? this.indicatorSpacing,
+ showDateIndicator: showDateIndicator ?? this.showDateIndicator,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(
+ tooltip,
+ padding,
+ showWeekdayIndicator,
+ indicatorSpacing,
+ showDateIndicator,
+ );
+ @override
+ bool operator ==(Object other) {
+ return other is DateHeaderStyle &&
+ tooltip == other.tooltip &&
+ padding == other.padding &&
+ showWeekdayIndicator == other.showWeekdayIndicator &&
+ indicatorSpacing == other.indicatorSpacing &&
+ showDateIndicator == other.showDateIndicator;
+ }
+}
diff --git a/lib/src/components/date_indicator.dart b/lib/src/components/date_indicator.dart
new file mode 100644
index 0000000..ca182b8
--- /dev/null
+++ b/lib/src/components/date_indicator.dart
@@ -0,0 +1,136 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../callbacks.dart';
+import '../config.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays the date of month for the given date.
+///
+/// If [onTap] is not supplied, [DefaultTimetableCallbacks]'s `onDateTap` is
+/// used if it's provided above in the widget tree.
+///
+/// See also:
+///
+/// * [DateIndicatorStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+/// * [DefaultTimetableCallbacks], which provides callbacks to descendant
+/// Timetable widgets.
+class DateIndicator extends StatelessWidget {
+ DateIndicator(
+ this.date, {
+ Key? key,
+ this.onTap,
+ this.style,
+ }) : assert(date.isValidTimetableDate),
+ super(key: key);
+
+ final DateTime date;
+ final VoidCallback? onTap;
+ final DateIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).dateIndicatorStyleProvider(date);
+ final defaultOnTap = DefaultTimetableCallbacks.of(context)?.onDateTap;
+
+ return InkResponse(
+ onTap: onTap ?? (defaultOnTap != null ? () => defaultOnTap(date) : null),
+ child: DecoratedBox(
+ decoration: style.decoration,
+ child: Padding(
+ padding: style.padding,
+ child: Text(style.label, style: style.textStyle),
+ ),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [DateIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class DateIndicatorStyle {
+ factory DateIndicatorStyle(
+ BuildContext context,
+ DateTime date, {
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ assert(date.isValidTimetableDate);
+
+ final today = DateTimeTimetable.today();
+ final isInFuture = date > today;
+ final isToday = date == today;
+
+ final theme = context.theme;
+ return DateIndicatorStyle.raw(
+ decoration: decoration ??
+ BoxDecoration(
+ shape: BoxShape.circle,
+ color: isToday ? theme.colorScheme.primary : Colors.transparent,
+ ),
+ padding: padding ?? EdgeInsets.all(8),
+ textStyle: textStyle ??
+ context.textTheme.subtitle1!.copyWith(
+ color: isToday
+ ? theme.colorScheme.primary.highEmphasisOnColor
+ : isInFuture
+ ? theme.colorScheme.background.contrastColor
+ : theme.colorScheme.background.mediumEmphasisOnColor,
+ ),
+ label: label ??
+ () {
+ context.dependOnTimetableLocalizations();
+ return DateFormat('d').format(date);
+ }(),
+ );
+ }
+
+ const DateIndicatorStyle.raw({
+ required this.decoration,
+ required this.padding,
+ required this.textStyle,
+ required this.label,
+ });
+
+ final Decoration decoration;
+ final EdgeInsetsGeometry padding;
+ final TextStyle textStyle;
+ final String label;
+
+ DateIndicatorStyle copyWith({
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ return DateIndicatorStyle.raw(
+ decoration: decoration ?? this.decoration,
+ padding: padding ?? this.padding,
+ textStyle: textStyle ?? this.textStyle,
+ label: label ?? this.label,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(decoration, padding, textStyle, label);
+ @override
+ bool operator ==(Object other) {
+ return other is DateIndicatorStyle &&
+ decoration == other.decoration &&
+ padding == other.padding &&
+ textStyle == other.textStyle &&
+ label == other.label;
+ }
+}
diff --git a/lib/src/components/hour_dividers.dart b/lib/src/components/hour_dividers.dart
new file mode 100644
index 0000000..aacf3ce
--- /dev/null
+++ b/lib/src/components/hour_dividers.dart
@@ -0,0 +1,101 @@
+import 'package:flutter/material.dart';
+
+import '../config.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays horizontal dividers betweeen hours of a day.
+///
+/// See also:
+///
+/// * [HourDividersStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class HourDividers extends StatelessWidget {
+ const HourDividers({
+ Key? key,
+ this.style,
+ this.child,
+ }) : super(key: key);
+
+ final HourDividersStyle? style;
+ final Widget? child;
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomPaint(
+ painter: _HourDividersPainter(
+ style: style ?? TimetableTheme.orDefaultOf(context).hourDividersStyle,
+ ),
+ child: child,
+ );
+ }
+}
+
+/// Defines visual properties for [HourDividers].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class HourDividersStyle {
+ factory HourDividersStyle(
+ BuildContext context, {
+ Color? color,
+ double? width,
+ }) {
+ final dividerBorderSide = Divider.createBorderSide(context);
+ return HourDividersStyle.raw(
+ color: color ?? dividerBorderSide.color,
+ width: width ?? dividerBorderSide.width,
+ );
+ }
+
+ const HourDividersStyle.raw({
+ required this.color,
+ required this.width,
+ }) : assert(width >= 0);
+
+ final Color color;
+ final double width;
+
+ HourDividersStyle copyWith({Color? color, double? width}) {
+ return HourDividersStyle.raw(
+ color: color ?? this.color,
+ width: width ?? this.width,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(color, width);
+ @override
+ bool operator ==(Object other) {
+ return other is HourDividersStyle &&
+ color == other.color &&
+ width == other.width;
+ }
+}
+
+class _HourDividersPainter extends CustomPainter {
+ _HourDividersPainter({
+ required this.style,
+ }) : _paint = Paint()
+ ..color = style.color
+ ..strokeWidth = style.width;
+
+ final HourDividersStyle style;
+ final Paint _paint;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final heightPerHour = size.height / Duration.hoursPerDay;
+ for (final h in InternalDateTimeTimetable.innerDateHours) {
+ final y = h * heightPerHour;
+ canvas.drawLine(Offset(-8, y), Offset(size.width, y), _paint);
+ }
+ }
+
+ @override
+ bool shouldRepaint(_HourDividersPainter oldDelegate) =>
+ style != oldDelegate.style;
+}
diff --git a/lib/src/components/month_indicator.dart b/lib/src/components/month_indicator.dart
new file mode 100644
index 0000000..4d48d0f
--- /dev/null
+++ b/lib/src/components/month_indicator.dart
@@ -0,0 +1,109 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../config.dart';
+import '../date/controller.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays the name of the given month.
+///
+/// See also:
+///
+/// * [MonthIndicatorStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class MonthIndicator extends StatelessWidget {
+ MonthIndicator(
+ this.month, {
+ Key? key,
+ this.style,
+ }) : assert(month.isValidTimetableMonth),
+ super(key: key);
+ static Widget forController(DateController? controller, {Key? key}) =>
+ _MonthIndicatorForController(controller, key: key);
+
+ final DateTime month;
+ final MonthIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).monthIndicatorStyleProvider(month);
+
+ return Text(style.label, style: style.textStyle);
+ }
+}
+
+/// Defines visual properties for [MonthIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class MonthIndicatorStyle {
+ factory MonthIndicatorStyle(
+ BuildContext context,
+ DateTime month, {
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ assert(month.isValidTimetableMonth);
+
+ final theme = context.theme;
+ return MonthIndicatorStyle.raw(
+ textStyle: textStyle ?? theme.textTheme.subtitle1!,
+ label: label ??
+ () {
+ context.dependOnTimetableLocalizations();
+ return DateFormat.MMMM().format(month);
+ }(),
+ );
+ }
+
+ const MonthIndicatorStyle.raw({
+ required this.textStyle,
+ required this.label,
+ });
+
+ final TextStyle textStyle;
+ final String label;
+
+ MonthIndicatorStyle copyWith({TextStyle? textStyle, String? label}) {
+ return MonthIndicatorStyle.raw(
+ textStyle: textStyle ?? this.textStyle,
+ label: label ?? this.label,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(textStyle, label);
+ @override
+ bool operator ==(Object other) {
+ return other is MonthIndicatorStyle &&
+ textStyle == other.textStyle &&
+ label == other.label;
+ }
+}
+
+class _MonthIndicatorForController extends StatelessWidget {
+ const _MonthIndicatorForController(
+ this.controller, {
+ Key? key,
+ this.style,
+ }) : super(key: key);
+
+ final DateController? controller;
+ final MonthIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = this.controller ?? DefaultDateController.of(context)!;
+ return ValueListenableBuilder(
+ valueListenable: controller.date.map((it) => it.firstDayOfMonth),
+ builder: (context, month, _) => MonthIndicator(month, style: style),
+ );
+ }
+}
diff --git a/lib/src/components/month_widget.dart b/lib/src/components/month_widget.dart
new file mode 100644
index 0000000..3979a58
--- /dev/null
+++ b/lib/src/components/month_widget.dart
@@ -0,0 +1,274 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_layout_grid/flutter_layout_grid.dart';
+
+import '../config.dart';
+import '../theme.dart';
+import '../utils.dart';
+import '../week.dart';
+import 'date_indicator.dart';
+import 'week_indicator.dart';
+import 'weekday_indicator.dart';
+
+/// A widget that displays the days of the given month in a grid, with weekdays
+/// at the top and week numbers at the left.
+///
+/// See also:
+///
+/// * [MonthWidgetStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class MonthWidget extends StatelessWidget {
+ MonthWidget(
+ this.month, {
+ DateWidgetBuilder? weekDayBuilder,
+ WeekWidgetBuilder? weekBuilder,
+ DateWidgetBuilder? dateBuilder,
+ this.style,
+ }) : assert(month.isValidTimetableMonth),
+ weekDayBuilder =
+ weekDayBuilder ?? ((context, date) => WeekdayIndicator(date)),
+ weekBuilder = weekBuilder ??
+ ((context, week) {
+ final timetableTheme = TimetableTheme.orDefaultOf(context);
+ return WeekIndicator(
+ week,
+ style: (style ?? timetableTheme.monthWidgetStyleProvider(month))
+ .removeIndividualWeekDecorations
+ ? timetableTheme
+ .weekIndicatorStyleProvider(week)
+ .copyWith(decoration: BoxDecoration())
+ : null,
+ alwaysUseNarrowestVariant: true,
+ );
+ }),
+ dateBuilder = dateBuilder ??
+ ((context, date) {
+ assert(date.isValidTimetableDate);
+
+ final timetableTheme = TimetableTheme.orDefaultOf(context);
+ DateIndicatorStyle? dateStyle;
+ if (date.firstDayOfMonth != month &&
+ (style ?? timetableTheme.monthWidgetStyleProvider(month))
+ .showDatesFromOtherMonthsAsDisabled) {
+ final original =
+ timetableTheme.dateIndicatorStyleProvider(date);
+ dateStyle = original.copyWith(
+ textStyle: original.textStyle.copyWith(
+ color: context.theme.colorScheme.background.disabledOnColor,
+ ),
+ );
+ }
+ return DateIndicator(date, style: dateStyle);
+ });
+
+ final DateTime month;
+
+ final DateWidgetBuilder weekDayBuilder;
+ final WeekWidgetBuilder weekBuilder;
+ final DateWidgetBuilder dateBuilder;
+
+ final MonthWidgetStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).monthWidgetStyleProvider(month);
+
+ final firstDay = month.previousOrSame(style.startOfWeek);
+ final weekCount = (month.lastDayOfMonth.difference(firstDay).inDays /
+ DateTime.daysPerWeek)
+ .ceil();
+
+ final today = DateTimeTimetable.today();
+
+ Widget buildDate(int week, int weekday) {
+ final date = firstDay + (DateTime.daysPerWeek * week + weekday).days;
+ if (!style.showDatesFromOtherMonths && date.firstDayOfMonth != month) {
+ return SizedBox.shrink();
+ }
+
+ return Center(
+ child: Padding(
+ padding: style.datePadding,
+ child: dateBuilder(context, date),
+ ),
+ );
+ }
+
+ return LayoutGrid(
+ columnSizes: [
+ auto,
+ ...repeat(DateTime.daysPerWeek, [1.fr]),
+ ],
+ rowSizes: [
+ auto,
+ ...repeat(weekCount, [auto]),
+ ],
+ children: [
+ // By using today as the base, highlighting for the current day is
+ // applied automatically.
+ for (final day in 1.rangeTo(DateTime.daysPerWeek))
+ GridPlacement(
+ columnStart: day,
+ rowStart: 0,
+ child: Center(
+ child:
+ weekDayBuilder(context, today + (day - today.weekday).days),
+ ),
+ ),
+ GridPlacement(
+ columnStart: 0,
+ rowStart: 1,
+ rowSpan: weekCount,
+ child: _buildWeeks(context, style, firstDay, weekCount),
+ ),
+ for (final week in 0.until(weekCount))
+ for (final weekday in 0.until(DateTime.daysPerWeek))
+ GridPlacement(
+ columnStart: 1 + weekday,
+ rowStart: 1 + week,
+ child: buildDate(week, weekday),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildWeeks(
+ BuildContext context,
+ MonthWidgetStyle style,
+ DateTime firstDay,
+ int weekCount,
+ ) {
+ assert(firstDay.isValidTimetableDate);
+
+ return DecoratedBox(
+ decoration: style.weeksDecoration,
+ child: Padding(
+ padding: style.weeksPadding,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ for (final index in 0.until(weekCount))
+ weekBuilder(
+ context,
+ (firstDay + (index * DateTime.daysPerWeek).days).week,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [MonthWidget].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class MonthWidgetStyle {
+ factory MonthWidgetStyle(
+ BuildContext context,
+ DateTime month, {
+ int? startOfWeek,
+ Decoration? weeksDecoration,
+ EdgeInsetsGeometry? weeksPadding,
+ bool? removeIndividualWeekDecorations,
+ EdgeInsetsGeometry? datePadding,
+ bool? showDatesFromOtherMonths,
+ bool? showDatesFromOtherMonthsAsDisabled,
+ }) {
+ assert(startOfWeek.isValidTimetableDayOfWeek);
+ assert(month.isValidTimetableMonth);
+
+ final theme = context.theme;
+ removeIndividualWeekDecorations ??= true;
+ return MonthWidgetStyle.raw(
+ startOfWeek: startOfWeek ?? DateTime.monday,
+ weeksDecoration: weeksDecoration ??
+ (removeIndividualWeekDecorations
+ ? BoxDecoration(
+ color: theme.colorScheme.brightness.contrastColor
+ .withOpacity(0.05),
+ borderRadius: BorderRadius.circular(4),
+ )
+ : BoxDecoration()),
+ weeksPadding: weeksPadding ?? EdgeInsets.symmetric(vertical: 12),
+ removeIndividualWeekDecorations: removeIndividualWeekDecorations,
+ datePadding: datePadding ?? EdgeInsets.all(4),
+ showDatesFromOtherMonths: showDatesFromOtherMonths ?? true,
+ showDatesFromOtherMonthsAsDisabled:
+ showDatesFromOtherMonthsAsDisabled ?? true,
+ );
+ }
+
+ const MonthWidgetStyle.raw({
+ required this.startOfWeek,
+ required this.weeksDecoration,
+ required this.weeksPadding,
+ required this.removeIndividualWeekDecorations,
+ required this.datePadding,
+ required this.showDatesFromOtherMonths,
+ required this.showDatesFromOtherMonthsAsDisabled,
+ });
+
+ final int startOfWeek;
+ final Decoration weeksDecoration;
+ final EdgeInsetsGeometry weeksPadding;
+ final bool removeIndividualWeekDecorations;
+ final EdgeInsetsGeometry datePadding;
+
+ /// Whether dates from adjacent months are displayed to fill the grid.
+ final bool showDatesFromOtherMonths;
+
+ /// Whether dates from adjacent months are displayed with lower text opacity.
+ final bool showDatesFromOtherMonthsAsDisabled;
+
+ MonthWidgetStyle copyWith({
+ int? startOfWeek,
+ Decoration? weeksDecoration,
+ EdgeInsetsGeometry? weeksPadding,
+ bool? removeIndividualWeekDecorations,
+ EdgeInsetsGeometry? datePadding,
+ bool? showDatesFromOtherMonths,
+ bool? showDatesFromOtherMonthsAsDisabled,
+ }) {
+ return MonthWidgetStyle.raw(
+ startOfWeek: startOfWeek ?? this.startOfWeek,
+ weeksDecoration: weeksDecoration ?? this.weeksDecoration,
+ weeksPadding: weeksPadding ?? this.weeksPadding,
+ removeIndividualWeekDecorations: removeIndividualWeekDecorations ??
+ this.removeIndividualWeekDecorations,
+ datePadding: datePadding ?? this.datePadding,
+ showDatesFromOtherMonths:
+ showDatesFromOtherMonths ?? this.showDatesFromOtherMonths,
+ showDatesFromOtherMonthsAsDisabled: showDatesFromOtherMonthsAsDisabled ??
+ this.showDatesFromOtherMonthsAsDisabled,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(
+ startOfWeek,
+ weeksDecoration,
+ weeksPadding,
+ removeIndividualWeekDecorations,
+ datePadding,
+ showDatesFromOtherMonths,
+ showDatesFromOtherMonthsAsDisabled,
+ );
+ @override
+ bool operator ==(Object other) {
+ return other is MonthWidgetStyle &&
+ startOfWeek == other.startOfWeek &&
+ weeksDecoration == other.weeksDecoration &&
+ weeksPadding == other.weeksPadding &&
+ removeIndividualWeekDecorations ==
+ other.removeIndividualWeekDecorations &&
+ datePadding == other.datePadding &&
+ showDatesFromOtherMonths == other.showDatesFromOtherMonths &&
+ showDatesFromOtherMonthsAsDisabled ==
+ other.showDatesFromOtherMonthsAsDisabled;
+ }
+}
diff --git a/lib/src/components/multi_date_content.dart b/lib/src/components/multi_date_content.dart
new file mode 100644
index 0000000..0105d44
--- /dev/null
+++ b/lib/src/components/multi_date_content.dart
@@ -0,0 +1,170 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart' hide Interval;
+
+import '../config.dart';
+import '../date/controller.dart';
+import '../date/date_page_view.dart';
+import '../event/event.dart';
+import '../event/provider.dart';
+import '../time/overlay.dart';
+import '../time/zoom.dart';
+import '../utils.dart';
+import 'date_content.dart';
+import 'date_dividers.dart';
+import 'hour_dividers.dart';
+import 'now_indicator.dart';
+
+/// A widget that displays the content of multiple consecutive dates, zoomable
+/// and with decoration like date and hour dividers.
+///
+/// A [DefaultDateController] must be above in the widget tree.
+///
+/// See also:
+///
+/// * [PartDayDraggableEvent], which can be wrapped around an event widget to
+/// make it draggable to a different time or date.
+/// * [DefaultEventProvider] (and [TimetableConfig]), which provide the [Event]s
+/// to be displayed.
+/// * [DefaultTimeOverlayProvider] (and [TimetableConfig]), which provide the
+/// [TimeOverlay]s to be displayed.
+/// * [DateDividers], [TimeZoom], [HourDividers], [NowIndicator],
+/// [DatePageView], and [DateContent], which are used internally by this
+/// widget and can be styled.
+class MultiDateContent extends StatelessWidget {
+ const MultiDateContent({Key? key}) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return DateDividers(
+ child: TimeZoom(
+ child: HourDividers(
+ child: NowIndicator(
+ child: LayoutBuilder(
+ builder: (context, constraints) =>
+ _buildEvents(context, constraints.biggest),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildEvents(BuildContext context, Size size) {
+ final dateController = DefaultDateController.of(context)!;
+
+ return _DragInfos(
+ context: context,
+ dateController: dateController,
+ size: size,
+ child: DatePageView(
+ controller: dateController,
+ builder: (context, date) => DateContent(
+ date: date,
+ events:
+ DefaultEventProvider.of(context)?.call(date.fullDayInterval) ??
+ [],
+ overlays:
+ DefaultTimeOverlayProvider.of(context)?.call(context, date) ?? [],
+ ),
+ ),
+ );
+ }
+}
+
+class _DragInfos extends InheritedWidget {
+ const _DragInfos({
+ required this.context,
+ required this.dateController,
+ required this.size,
+ required Widget child,
+ }) : super(child: child);
+
+ // Storing the context feels wrong but I haven't found a different way to
+ // transform global coordinates back to local ones in this context.
+ final BuildContext context;
+ final DateController dateController;
+ final Size size;
+
+ static DateTime resolveOffset(BuildContext context, Offset globalOffset) {
+ final dragInfos = context.dependOnInheritedWidgetOfExactType<_DragInfos>()!;
+
+ final localOffset = (dragInfos.context.findRenderObject()! as RenderBox)
+ .globalToLocal(globalOffset);
+ final pageValue = dragInfos.dateController.value;
+ final page = (pageValue.page +
+ localOffset.dx / dragInfos.size.width * pageValue.visibleDayCount)
+ .floor();
+ return DateTimeTimetable.dateFromPage(page) +
+ 1.days * (localOffset.dy / dragInfos.size.height);
+ }
+
+ @override
+ bool updateShouldNotify(_DragInfos oldWidget) {
+ return context != oldWidget.context ||
+ dateController != oldWidget.dateController ||
+ size != oldWidget.size;
+ }
+}
+
+/// A widget that makes its child draggable starting from long press.
+///
+/// It must be used inside a [MultiDateContent].
+class PartDayDraggableEvent extends StatefulWidget {
+ PartDayDraggableEvent({
+ this.onDragStart,
+ this.onDragUpdate,
+ this.onDragEnd,
+ required this.child,
+ Widget? childWhileDragging,
+ }) : childWhileDragging =
+ childWhileDragging ?? Opacity(opacity: 0.6, child: child);
+
+ final void Function()? onDragStart;
+ final void Function(DateTime)? onDragUpdate;
+
+ /// Called when a drag gesture is ended.
+ ///
+ /// The [DateTime] is `null` when the user long tapps but then doesn't move
+ /// their finger at all.
+ final void Function(DateTime?)? onDragEnd;
+
+ final Widget child;
+ final Widget childWhileDragging;
+
+ @override
+ _PartDayDraggableEventState createState() => _PartDayDraggableEventState();
+}
+
+class _PartDayDraggableEventState extends State {
+ DateTime? _lastDragDateTime;
+
+ @override
+ Widget build(BuildContext context) {
+ return LongPressDraggable<_DragData>(
+ data: _DragData(),
+ maxSimultaneousDrags: 1,
+ onDragStarted: widget.onDragStart,
+ onDragUpdate: widget.onDragUpdate != null
+ ? (details) {
+ _lastDragDateTime =
+ _DragInfos.resolveOffset(context, details.globalPosition);
+ widget.onDragUpdate!(_lastDragDateTime!);
+ }
+ : null,
+ onDragEnd: widget.onDragEnd != null
+ ? (details) {
+ widget.onDragEnd!(_lastDragDateTime!);
+ _lastDragDateTime = null;
+ }
+ : null,
+ child: widget.child,
+ childWhenDragging: widget.childWhileDragging,
+ feedback: SizedBox.shrink(),
+ );
+ }
+}
+
+@immutable
+class _DragData {
+ const _DragData();
+}
diff --git a/lib/src/components/multi_date_event_header.dart b/lib/src/components/multi_date_event_header.dart
new file mode 100644
index 0000000..4680960
--- /dev/null
+++ b/lib/src/components/multi_date_event_header.dart
@@ -0,0 +1,465 @@
+import 'dart:ui';
+
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart' hide Interval;
+import 'package:flutter/rendering.dart';
+
+import '../callbacks.dart';
+import '../config.dart';
+import '../date/controller.dart';
+import '../date/date_page_view.dart';
+import '../date/visible_date_range.dart';
+import '../event/all_day.dart';
+import '../event/builder.dart';
+import '../event/event.dart';
+import '../event/provider.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays all-day [Event]s.
+///
+/// A [DefaultDateController] and [DefaultEventBuilder] must be above in the
+/// widget tree.
+///
+/// If [onBackgroundTap] is not supplied, [DefaultTimetableCallbacks]'s
+/// `onDateBackgroundTap` is used if it's provided above in the widget tree.
+///
+/// See also:
+///
+/// * [DefaultEventProvider] (and [TimetableConfig]), which provide the [Event]s
+/// to be displayed.
+/// * [MultiDateEventHeaderStyle], which defines visual properties for this
+/// widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+/// * [DefaultTimetableCallbacks], which provides callbacks to descendant
+/// Timetable widgets.
+class MultiDateEventHeader extends StatelessWidget {
+ const MultiDateEventHeader({
+ Key? key,
+ this.onBackgroundTap,
+ this.style,
+ }) : super(key: key);
+
+ final DateTapCallback? onBackgroundTap;
+ final MultiDateEventHeaderStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).multiDateEventHeaderStyle;
+
+ return Stack(children: [
+ Positioned.fill(
+ child: DatePageView(builder: (context, date) => SizedBox()),
+ ),
+ ClipRect(
+ child: Padding(
+ padding: style.padding,
+ child: LayoutBuilder(
+ builder: (context, constraints) =>
+ ValueListenableBuilder(
+ valueListenable: DefaultDateController.of(context)!,
+ builder: (context, pageValue, __) => _buildContent(
+ context,
+ style,
+ pageValue,
+ constraints.maxWidth,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ]);
+ }
+
+ Widget _buildContent(
+ BuildContext context,
+ MultiDateEventHeaderStyle style,
+ DatePageValue pageValue,
+ double width,
+ ) {
+ final visibleDates = Interval(
+ DateTimeTimetable.dateFromPage(pageValue.page.floor()),
+ DateTimeTimetable.dateFromPage(
+ (pageValue.page + pageValue.visibleDayCount).ceil(),
+ ) -
+ 1.milliseconds,
+ );
+ assert(visibleDates.isValidTimetableDateInterval);
+
+ final onBackgroundTap = this.onBackgroundTap ??
+ DefaultTimetableCallbacks.of(context)?.onDateBackgroundTap;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTapUp: onBackgroundTap != null
+ ? (details) {
+ final tappedCell =
+ details.localPosition.dx / width * pageValue.visibleDayCount;
+ final page = (pageValue.page + tappedCell).floor();
+ onBackgroundTap(DateTimeTimetable.dateFromPage(page));
+ }
+ : null,
+ child: _buildEventLayout(context, style, visibleDates, pageValue),
+ );
+ }
+
+ Widget _buildEventLayout(
+ BuildContext context,
+ MultiDateEventHeaderStyle style,
+ Interval visibleDates,
+ DatePageValue pageValue,
+ ) {
+ assert(visibleDates.isValidTimetableDateInterval);
+
+ final events =
+ DefaultEventProvider.of(context)?.call(visibleDates) ?? [];
+
+ return _EventsWidget(
+ visibleRange: pageValue.visibleRange,
+ currentlyVisibleDates: visibleDates,
+ page: pageValue.page,
+ eventHeight: style.eventHeight,
+ children: [
+ for (final event in events)
+ _EventParentDataWidget(
+ key: ValueKey(event),
+ event: event,
+ child: _buildEvent(context, event, pageValue),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildEvent(BuildContext context, E event, DatePageValue pageValue) {
+ return DefaultEventBuilder.allDayOf(context)!(
+ context,
+ event,
+ AllDayEventLayoutInfo(
+ hiddenStartDays: (pageValue.page - event.start.page).coerceAtLeast(0),
+ hiddenEndDays:
+ (event.end.page.ceil() - pageValue.page - pageValue.visibleDayCount)
+ .coerceAtLeast(0),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [MultiDateEventHeader].
+class MultiDateEventHeaderStyle {
+ factory MultiDateEventHeaderStyle(
+ // To allow future updates to use the context and align the parameters to
+ // other style constructors.
+ // ignore: avoid_unused_constructor_parameters
+ BuildContext context, {
+ double? eventHeight,
+ EdgeInsetsGeometry? padding,
+ }) {
+ return MultiDateEventHeaderStyle.raw(
+ eventHeight: eventHeight ?? 24,
+ padding: padding ?? EdgeInsets.zero,
+ );
+ }
+
+ const MultiDateEventHeaderStyle.raw({
+ this.eventHeight = 24,
+ this.padding = EdgeInsets.zero,
+ });
+
+ /// Height of a single all-day event.
+ final double eventHeight;
+
+ final EdgeInsetsGeometry padding;
+
+ MultiDateEventHeaderStyle copyWith({
+ double? eventHeight,
+ EdgeInsetsGeometry? padding,
+ }) {
+ return MultiDateEventHeaderStyle.raw(
+ eventHeight: eventHeight ?? this.eventHeight,
+ padding: padding ?? this.padding,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(eventHeight, padding);
+ @override
+ bool operator ==(Object other) {
+ return other is MultiDateEventHeaderStyle &&
+ eventHeight == other.eventHeight &&
+ padding == other.padding;
+ }
+}
+
+class _EventParentDataWidget
+ extends ParentDataWidget<_EventParentData> {
+ const _EventParentDataWidget({
+ Key? key,
+ required this.event,
+ required Widget child,
+ }) : super(key: key, child: child);
+
+ final E event;
+
+ @override
+ Type get debugTypicalAncestorWidgetClass => _EventsWidget;
+
+ @override
+ void applyParentData(RenderObject renderObject) {
+ assert(renderObject.parentData is _EventParentData);
+ final parentData = renderObject.parentData! as _EventParentData;
+
+ if (parentData.event == event) return;
+
+ parentData.event = event;
+ final targetParent = renderObject.parent;
+ if (targetParent is RenderObject) targetParent.markNeedsLayout();
+ }
+}
+
+class _EventsWidget extends MultiChildRenderObjectWidget {
+ _EventsWidget({
+ required this.visibleRange,
+ required this.currentlyVisibleDates,
+ required this.page,
+ required this.eventHeight,
+ required List<_EventParentDataWidget> children,
+ }) : super(children: children);
+
+ final VisibleDateRange visibleRange;
+ final Interval currentlyVisibleDates;
+ final double page;
+ final double eventHeight;
+
+ @override
+ RenderObject createRenderObject(BuildContext context) {
+ return _EventsLayout(
+ visibleRange: visibleRange,
+ currentlyVisibleDates: currentlyVisibleDates,
+ page: page,
+ eventHeight: eventHeight,
+ );
+ }
+
+ @override
+ void updateRenderObject(BuildContext context, _EventsLayout renderObject) {
+ renderObject
+ ..visibleRange = visibleRange
+ ..currentlyVisibleDates = currentlyVisibleDates
+ ..page = page
+ ..eventHeight = eventHeight;
+ }
+}
+
+class _EventParentData
+ extends ContainerBoxParentData {
+ E? event;
+}
+
+class _EventsLayout extends RenderBox
+ with
+ ContainerRenderObjectMixin>,
+ RenderBoxContainerDefaultsMixin> {
+ _EventsLayout({
+ required VisibleDateRange visibleRange,
+ required Interval currentlyVisibleDates,
+ required double page,
+ required double eventHeight,
+ }) : _visibleRange = visibleRange,
+ assert(currentlyVisibleDates.isValidTimetableDateInterval),
+ _currentlyVisibleDates = currentlyVisibleDates,
+ _page = page,
+ _eventHeight = eventHeight;
+
+ VisibleDateRange _visibleRange;
+ VisibleDateRange get visibleRange => _visibleRange;
+ set visibleRange(VisibleDateRange value) {
+ if (_visibleRange == value) return;
+
+ _visibleRange = value;
+ markNeedsLayout();
+ }
+
+ Interval _currentlyVisibleDates;
+ Interval get currentlyVisibleDates => _currentlyVisibleDates;
+ set currentlyVisibleDates(Interval value) {
+ assert(value.isValidTimetableDateInterval);
+ if (_currentlyVisibleDates == value) return;
+
+ _currentlyVisibleDates = value;
+ markNeedsLayout();
+ }
+
+ double _page;
+ double get page => _page;
+ set page(double value) {
+ if (_page == value) return;
+
+ _page = value;
+ markNeedsLayout();
+ }
+
+ double _eventHeight;
+ double get eventHeight => _eventHeight;
+ set eventHeight(double value) {
+ if (_eventHeight == value) return;
+
+ _eventHeight = value;
+ markNeedsLayout();
+ }
+
+ Iterable get _events => children.map((child) => child.data().event!);
+
+ @override
+ void setupParentData(RenderObject child) {
+ if (child.parentData is! _EventParentData) {
+ child.parentData = _EventParentData();
+ }
+ }
+
+ @override
+ double computeMinIntrinsicWidth(double height) {
+ assert(_debugThrowIfNotCheckingIntrinsics());
+ return 0;
+ }
+
+ @override
+ double computeMaxIntrinsicWidth(double height) {
+ assert(_debugThrowIfNotCheckingIntrinsics());
+ return 0;
+ }
+
+ bool _debugThrowIfNotCheckingIntrinsics() {
+ assert(() {
+ if (!RenderObject.debugCheckingIntrinsics) {
+ throw Exception("_EventsLayout doesn't have an intrinsic width.");
+ }
+ return true;
+ }());
+ return true;
+ }
+
+ @override
+ double computeMinIntrinsicHeight(double width) =>
+ _parallelEventCount() * eventHeight;
+ @override
+ double computeMaxIntrinsicHeight(double width) =>
+ _parallelEventCount() * eventHeight;
+
+ final _yPositions = {};
+
+ @override
+ void performLayout() {
+ assert(!sizedByParent);
+
+ if (children.isEmpty) {
+ size = Size(constraints.maxWidth, 0);
+ return;
+ }
+
+ _updateEventPositions();
+ size = Size(constraints.maxWidth, _parallelEventCount() * eventHeight);
+ _positionEvents();
+ }
+
+ void _updateEventPositions() {
+ // Remove events outside the current viewport (with some buffer).
+ _yPositions.removeWhere((e, _) {
+ return e.start.page.floor() >= currentlyVisibleDates.end.page.ceil() ||
+ e.end.page.ceil() <= currentlyVisibleDates.start.page;
+ });
+
+ // Remove old events.
+ _yPositions.removeWhere((e, _) => !_events.contains(e));
+
+ // Insert new events.
+ final sortedEvents = _events
+ .where((it) => !_yPositions.containsKey(it))
+ .sortedByStartLength();
+
+ Iterable eventsWithPosition(int y) {
+ return _yPositions.entries.where((e) => e.value == y).map((e) => e.key);
+ }
+
+ outer:
+ for (final event in sortedEvents) {
+ var y = 0;
+ final interval = event.interval.dateInterval;
+ while (true) {
+ final intersectingEvents = eventsWithPosition(y);
+ if (intersectingEvents
+ .every((e) => !e.interval.dateInterval.intersects(interval))) {
+ _yPositions[event] = y;
+ continue outer;
+ }
+
+ y++;
+ }
+ }
+ }
+
+ void _positionEvents() {
+ final dateWidth = size.width / visibleRange.visibleDayCount;
+ for (final child in children) {
+ final data = child.data();
+ final event = data.event!;
+
+ final dateInterval = event.interval.dateInterval;
+ final startPage = dateInterval.start.page;
+ final left = ((startPage - page) * dateWidth).coerceAtLeast(0);
+ final endPage = dateInterval.end.page.ceilToDouble();
+ final right = ((endPage - page) * dateWidth).coerceAtMost(size.width);
+
+ child.layout(
+ BoxConstraints(
+ minWidth: right - left,
+ maxWidth: (right - left).coerceAtLeast(dateWidth),
+ minHeight: eventHeight,
+ maxHeight: eventHeight,
+ ),
+ parentUsesSize: true,
+ );
+ final actualLeft = startPage >= page
+ ? left
+ : left.coerceAtMost(right - child.size.width);
+ data.offset = Offset(actualLeft, _yPositions[event]! * eventHeight);
+ }
+ }
+
+ double _parallelEventCount() {
+ int parallelEventsFrom(int page) {
+ final startDate = DateTimeTimetable.dateFromPage(page);
+ final interval = Interval(
+ startDate,
+ (startDate + (visibleRange.visibleDayCount - 1).days).atEndOfDay,
+ );
+ assert(interval.isValidTimetableDateInterval);
+
+ final maxEventPosition = _yPositions.entries
+ .where((e) => e.key.interval.intersects(interval))
+ .map((e) => e.value)
+ .max();
+ return maxEventPosition != null ? maxEventPosition + 1 : 0;
+ }
+
+ _updateEventPositions();
+ final oldParallelEvents = parallelEventsFrom(page.floor());
+ final newParallelEvents = parallelEventsFrom(page.ceil());
+ final t = page - page.floorToDouble();
+ return lerpDouble(oldParallelEvents, newParallelEvents, t)!;
+ }
+
+ @override
+ bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
+ defaultHitTestChildren(result, position: position);
+
+ @override
+ void paint(PaintingContext context, Offset offset) =>
+ defaultPaint(context, offset);
+}
+
+extension _ParentData on RenderBox {
+ _EventParentData data() =>
+ parentData! as _EventParentData;
+}
diff --git a/lib/src/components/now_indicator.dart b/lib/src/components/now_indicator.dart
new file mode 100644
index 0000000..aae85da
--- /dev/null
+++ b/lib/src/components/now_indicator.dart
@@ -0,0 +1,339 @@
+import 'dart:ui';
+
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+
+import '../config.dart';
+import '../date/controller.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays an indicator at the current date and time.
+///
+/// The indicator consists of two parts:
+///
+/// * a small [NowIndicatorShape] at the left side
+/// * a horizontal line spanning the whole day
+///
+/// See also:
+///
+/// * [NowIndicatorStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class NowIndicator extends StatefulWidget {
+ const NowIndicator({
+ Key? key,
+ this.style,
+ this.child,
+ }) : super(key: key);
+
+ final NowIndicatorStyle? style;
+ final Widget? child;
+
+ @override
+ _NowIndicatorState createState() => _NowIndicatorState();
+}
+
+class _NowIndicatorState extends State {
+ // TODO(JonasWanke): Vary this depending on the widget size.
+ final _timeListenable =
+ StreamChangeNotifier(Stream.periodic(1.seconds * (1 / 60)));
+
+ @override
+ void dispose() {
+ _timeListenable.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomPaint(
+ foregroundPainter: _NowIndicatorPainter(
+ controller: DefaultDateController.of(context)!,
+ style: widget.style ??
+ TimetableTheme.orDefaultOf(context).nowIndicatorStyle,
+ repaint: _timeListenable,
+ ),
+ child: widget.child,
+ );
+ }
+}
+
+/// Defines visual properties for [NowIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class NowIndicatorStyle {
+ factory NowIndicatorStyle(
+ BuildContext context, {
+ NowIndicatorShape? shape,
+ Color? lineColor,
+ double? lineWidth,
+ }) {
+ final defaultColor = context.theme.colorScheme.onBackground;
+ return NowIndicatorStyle.raw(
+ shape: shape ?? CircleNowIndicatorShape(color: defaultColor),
+ lineColor: lineColor ?? defaultColor,
+ lineWidth: lineWidth ?? 1,
+ );
+ }
+
+ const NowIndicatorStyle.raw({
+ required this.shape,
+ required this.lineColor,
+ required this.lineWidth,
+ }) : assert(lineWidth >= 0);
+
+ final NowIndicatorShape shape;
+ final Color lineColor;
+ final double lineWidth;
+
+ NowIndicatorStyle copyWith({
+ NowIndicatorShape? shape,
+ Color? lineColor,
+ double? lineWidth,
+ }) {
+ return NowIndicatorStyle.raw(
+ shape: shape ?? this.shape,
+ lineColor: lineColor ?? this.lineColor,
+ lineWidth: lineWidth ?? this.lineWidth,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(shape, lineColor, lineWidth);
+ @override
+ bool operator ==(Object other) {
+ return other is NowIndicatorStyle &&
+ shape == other.shape &&
+ lineColor == other.lineColor &&
+ lineWidth == other.lineWidth;
+ }
+}
+
+// Shapes
+
+/// A shape that is drawn at the left side of the [NowIndicator].
+///
+/// See also:
+///
+/// * [CircleNowIndicatorShape], which draws a small circle.
+/// * [TriangleNowIndicatorShape], which draws a small triangle.
+/// * [EmptyNowIndicatorShape], which draws nothing.
+/// * [NowIndicatorStyle], which uses this class.
+@immutable
+abstract class NowIndicatorShape {
+ const NowIndicatorShape();
+
+ void paint(
+ Canvas canvas,
+ Size size,
+ double dateStartOffset,
+ double dateEndOffset,
+ double timeOffset,
+ );
+
+ double interpolateSizeBasedOnVisibility(
+ double value,
+ Size size,
+ double dateStartOffset,
+ double dateEndOffset,
+ ) {
+ final dateWidth = dateEndOffset - dateStartOffset;
+ if (dateEndOffset < dateWidth) {
+ return lerpDouble(0, value, dateEndOffset / dateWidth)!;
+ } else if (dateStartOffset > size.width - dateWidth) {
+ return lerpDouble(0, value, (size.width - dateStartOffset) / dateWidth)!;
+ } else {
+ return value;
+ }
+ }
+
+ NowIndicatorShape copyWith();
+
+ @override
+ int get hashCode;
+ @override
+ bool operator ==(Object other);
+}
+
+/// A [NowIndicatorShape] that draws nothing.
+///
+/// See also:
+///
+/// * [CircleNowIndicatorShape], which draws a small circle.
+/// * [TriangleNowIndicatorShape], which draws a small triangle.
+class EmptyNowIndicatorShape extends NowIndicatorShape {
+ const EmptyNowIndicatorShape();
+
+ @override
+ void paint(
+ Canvas canvas,
+ Size size,
+ double dateStartOffset,
+ double dateEndOffset,
+ double timeOffset,
+ ) {}
+
+ @override
+ EmptyNowIndicatorShape copyWith() => EmptyNowIndicatorShape();
+
+ @override
+ int get hashCode => 0;
+ @override
+ bool operator ==(Object other) {
+ return other is EmptyNowIndicatorShape;
+ }
+}
+
+/// A [NowIndicatorShape] that draws a small circle.
+///
+/// See also:
+///
+/// * [TriangleNowIndicatorShape], which draws a small triangle.
+/// * [EmptyNowIndicatorShape], which draws nothing.
+class CircleNowIndicatorShape extends NowIndicatorShape {
+ CircleNowIndicatorShape({required this.color, this.radius = 4})
+ : _paint = Paint()..color = color;
+
+ final Color color;
+ final double radius;
+ final Paint _paint;
+
+ @override
+ void paint(
+ Canvas canvas,
+ Size size,
+ double dateStartOffset,
+ double dateEndOffset,
+ double timeOffset,
+ ) {
+ canvas.drawCircle(
+ Offset(dateStartOffset.coerceAtLeast(0), timeOffset),
+ interpolateSizeBasedOnVisibility(
+ radius,
+ size,
+ dateStartOffset,
+ dateEndOffset,
+ ),
+ _paint,
+ );
+ }
+
+ @override
+ CircleNowIndicatorShape copyWith({Color? color, double? radius}) {
+ return CircleNowIndicatorShape(
+ color: color ?? this.color,
+ radius: radius ?? this.radius,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(color, radius);
+ @override
+ bool operator ==(Object other) {
+ return other is CircleNowIndicatorShape &&
+ color == other.color &&
+ radius == other.radius;
+ }
+}
+
+/// A [NowIndicatorShape] that draws a small triangle.
+///
+/// See also:
+///
+/// * [TriangleNowIndicatorShape], which draws a small triangle.
+/// * [EmptyNowIndicatorShape], which draws nothing.
+class TriangleNowIndicatorShape extends NowIndicatorShape {
+ TriangleNowIndicatorShape({required this.color, this.size = 8})
+ : _paint = Paint()..color = color;
+
+ final Color color;
+ final double size;
+ final Paint _paint;
+
+ @override
+ void paint(
+ Canvas canvas,
+ Size size,
+ double dateStartOffset,
+ double dateEndOffset,
+ double timeOffset,
+ ) {
+ final actualSize = interpolateSizeBasedOnVisibility(
+ this.size,
+ size,
+ dateStartOffset,
+ dateEndOffset,
+ );
+ final left = dateStartOffset.coerceAtLeast(0);
+ canvas.drawPath(
+ Path()
+ ..moveTo(left, timeOffset - actualSize / 2)
+ ..lineTo(left + actualSize, timeOffset)
+ ..lineTo(left, timeOffset + actualSize / 2)
+ ..close(),
+ _paint,
+ );
+ }
+
+ @override
+ TriangleNowIndicatorShape copyWith({Color? color, double? size}) {
+ return TriangleNowIndicatorShape(
+ color: color ?? this.color,
+ size: size ?? this.size,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(color, size);
+ @override
+ bool operator ==(Object other) {
+ return other is TriangleNowIndicatorShape &&
+ color == other.color &&
+ size == other.size;
+ }
+}
+
+// Painter
+
+class _NowIndicatorPainter extends CustomPainter {
+ _NowIndicatorPainter({
+ required this.controller,
+ required this.style,
+ required Listenable repaint,
+ }) : _paint = Paint()
+ ..color = style.lineColor
+ ..strokeWidth = style.lineWidth,
+ super(repaint: Listenable.merge([controller, repaint]));
+
+ final DateController controller;
+ final Paint _paint;
+ final NowIndicatorStyle style;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final pageValue = controller.value;
+ final dateWidth = size.width / pageValue.visibleDayCount;
+ final now = DateTime.now();
+ final temporalXOffset = now.toUtc().atStartOfDay.page - pageValue.page;
+ final left = temporalXOffset * dateWidth;
+ final right = left + dateWidth;
+
+ // The current date isn't visible so we don't have to paint anything.
+ if (right < 0 || left > size.width) return;
+
+ final actualLeft = left.coerceAtLeast(0);
+ final actualRight = right.coerceAtMost(size.width);
+
+ final y = now.timeOfDay / 1.days * size.height;
+ canvas.drawLine(Offset(actualLeft, y), Offset(actualRight, y), _paint);
+ style.shape.paint(canvas, size, left, right, y);
+ }
+
+ @override
+ bool shouldRepaint(_NowIndicatorPainter oldDelegate) =>
+ style != oldDelegate.style;
+}
diff --git a/lib/src/components/time_indicator.dart b/lib/src/components/time_indicator.dart
new file mode 100644
index 0000000..b3be898
--- /dev/null
+++ b/lib/src/components/time_indicator.dart
@@ -0,0 +1,118 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+
+import '../config.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+import 'time_indicators.dart';
+
+/// A widget that displays a label at the given time.
+///
+/// See also:
+///
+/// * [TimeIndicators], which positions [TimeIndicator] widgets.
+/// * [TimeIndicatorStyle], which defines visual properties (including the
+/// label) for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class TimeIndicator extends StatelessWidget {
+ TimeIndicator({
+ Key? key,
+ required this.time,
+ this.style,
+ }) : assert(time.isValidTimetableTimeOfDay),
+ super(key: key);
+
+ static String formatHour(Duration time) => _format(DateFormat.j(), time);
+ static String formatHourMinute(Duration time) =>
+ _format(DateFormat.jm(), time);
+ static String formatHourMinuteSecond(Duration time) =>
+ _format(DateFormat.jms(), time);
+
+ static String formatHour24(Duration time) => _format(DateFormat.H(), time);
+ static String formatHour24Minute(Duration time) =>
+ _format(DateFormat.Hm(), time);
+ static String formatHour24MinuteSecond(Duration time) =>
+ _format(DateFormat.Hms(), time);
+
+ static String _format(DateFormat format, Duration time) {
+ assert(time.isValidTimetableTimeOfDay);
+ return format.format(DateTime(0) + time);
+ }
+
+ final Duration time;
+ final TimeIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).timeIndicatorStyleProvider(time);
+
+ return Text(style.label, style: style.textStyle);
+ }
+}
+
+/// Defines visual properties for [TimeIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class TimeIndicatorStyle {
+ factory TimeIndicatorStyle(
+ BuildContext context,
+ Duration time, {
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ assert(time.isValidTimetableTimeOfDay);
+
+ final theme = context.theme;
+ final caption = theme.textTheme.caption!;
+ final proportionalFiguresFeature = FontFeature.proportionalFigures().value;
+ return TimeIndicatorStyle.raw(
+ textStyle: textStyle ??
+ caption.copyWith(
+ color: theme.colorScheme.background.disabledOnColor,
+ fontFeatures: [
+ ...?caption.fontFeatures
+ ?.where((it) => it.value != proportionalFiguresFeature),
+ FontFeature.tabularFigures(),
+ ],
+ ),
+ label: label ??
+ () {
+ context.dependOnTimetableLocalizations();
+ return TimeIndicator.formatHour(time);
+ }(),
+ );
+ }
+
+ const TimeIndicatorStyle.raw({
+ required this.textStyle,
+ required this.label,
+ });
+
+ final TextStyle textStyle;
+ final String label;
+
+ TimeIndicatorStyle copyWith({TextStyle? textStyle, String? label}) {
+ return TimeIndicatorStyle.raw(
+ textStyle: textStyle ?? this.textStyle,
+ label: label ?? this.label,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(textStyle, label);
+ @override
+ bool operator ==(Object other) {
+ return other is TimeIndicatorStyle &&
+ textStyle == other.textStyle &&
+ label == other.label;
+ }
+}
diff --git a/lib/src/components/time_indicators.dart b/lib/src/components/time_indicators.dart
new file mode 100644
index 0000000..e2fe88d
--- /dev/null
+++ b/lib/src/components/time_indicators.dart
@@ -0,0 +1,239 @@
+import 'dart:math' as math;
+
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+import '../theme.dart';
+import '../utils.dart';
+import 'time_indicator.dart';
+
+/// A widget that positions [TimeIndicator] widgets.
+///
+/// See also:
+///
+/// * [TimeIndicators.hours], which displays an indicator at every full hour.
+/// * [TimeIndicators.halfHours], which displays an indicator at every half
+/// hour.
+/// * [TimeIndicatorsChild], which wraps children of this layout.
+/// * [TimeIndicator], which is usually used inside a [TimeIndicatorsChild] to
+/// display a label.
+class TimeIndicators extends StatelessWidget {
+ const TimeIndicators({Key? key, required this.children}) : super(key: key);
+
+ factory TimeIndicators.hours({
+ Key? key,
+ TimeBasedStyleProvider? styleProvider,
+ AlignmentGeometry alignment = Alignment.centerRight,
+ }) =>
+ TimeIndicators(
+ key: key,
+ children: [
+ for (final hour in 1.until(Duration.hoursPerDay))
+ _buildChild(
+ hour.hours,
+ alignment,
+ styleProvider,
+ TimeIndicator.formatHour,
+ ),
+ ],
+ );
+
+ factory TimeIndicators.halfHours({
+ Key? key,
+ TimeBasedStyleProvider? styleProvider,
+ AlignmentGeometry alignment = Alignment.centerRight,
+ }) =>
+ TimeIndicators(
+ key: key,
+ children: [
+ for (final halfHour in 1.until(Duration.hoursPerDay * 2))
+ _buildChild(
+ 30.minutes * halfHour,
+ alignment,
+ styleProvider,
+ TimeIndicator.formatHourMinute,
+ ),
+ ],
+ );
+
+ static TimeIndicatorsChild _buildChild(
+ Duration time,
+ AlignmentGeometry alignment,
+ TimeBasedStyleProvider? styleProvider,
+ String Function(Duration time) defaultFormatter,
+ ) {
+ assert(time.isValidTimetableTimeOfDay);
+
+ return TimeIndicatorsChild(
+ time: time,
+ alignment: alignment,
+ child: styleProvider != null
+ ? TimeIndicator(time: time, style: styleProvider(time))
+ : Builder(
+ builder: (context) => TimeIndicator(
+ time: time,
+ style: TimetableTheme.orDefaultOf(context)
+ .timeIndicatorStyleProvider(time)
+ .copyWith(label: defaultFormatter(time)),
+ ),
+ ),
+ );
+ }
+
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ return DefaultTextStyle(
+ style: context.textTheme.caption!,
+ child: _TimeIndicators(children: children),
+ );
+ }
+}
+
+class _TimeIndicators extends MultiChildRenderObjectWidget {
+ _TimeIndicators({required List children})
+ : super(children: children);
+
+ @override
+ RenderObject createRenderObject(BuildContext context) =>
+ _TimeIndicatorsLayout(textDirection: context.directionality);
+}
+
+/// Wraps children of [TimeIndicators] and determines their position.
+class TimeIndicatorsChild extends ParentDataWidget<_TimeIndicatorParentData> {
+ TimeIndicatorsChild({
+ required this.time,
+ this.alignment = Alignment.centerRight,
+ required Widget child,
+ }) : assert(time.isValidTimetableTimeOfDay),
+ super(key: ValueKey(time), child: child);
+
+ /// The time of day that this widget positioned next to.
+ final Duration time;
+
+ /// How to align the widget to the [time].
+ ///
+ /// The horizontal alignment works as expected. A vertical alignment of top
+ /// places the widget so it sits on top of where the corresponding time is,
+ /// and a vertical alignment of bottom places it directly below that time.
+ final AlignmentGeometry alignment;
+
+ @override
+ Type get debugTypicalAncestorWidgetClass => TimeIndicators;
+
+ @override
+ void applyParentData(RenderObject renderObject) {
+ assert(renderObject.parentData is _TimeIndicatorParentData);
+ final parentData = renderObject.parentData! as _TimeIndicatorParentData;
+ if (parentData.time == time && parentData.alignment == alignment) return;
+
+ parentData.time = time;
+ parentData.alignment = alignment;
+ final targetParent = renderObject.parent;
+ if (targetParent is RenderObject) targetParent.markNeedsLayout();
+ }
+}
+
+class _TimeIndicatorParentData extends ContainerBoxParentData {
+ Duration? time;
+ AlignmentGeometry? alignment;
+}
+
+class _TimeIndicatorsLayout extends RenderBox
+ with
+ ContainerRenderObjectMixin,
+ RenderBoxContainerDefaultsMixin {
+ _TimeIndicatorsLayout({required TextDirection textDirection})
+ : _textDirection = textDirection;
+
+ TextDirection _textDirection;
+ TextDirection get textDirection => _textDirection;
+ set textDirection(TextDirection value) {
+ if (_textDirection == value) return;
+
+ _textDirection = value;
+ markNeedsLayout();
+ }
+
+ @override
+ void setupParentData(RenderObject child) {
+ if (child.parentData is! _TimeIndicatorParentData) {
+ child.parentData = _TimeIndicatorParentData();
+ }
+ }
+
+ @override
+ double computeMinIntrinsicWidth(double height) =>
+ children.map((it) => it.getMinIntrinsicWidth(height)).max() ?? 0;
+ @override
+ double computeMaxIntrinsicWidth(double height) =>
+ children.map((it) => it.getMaxIntrinsicWidth(height)).max() ?? 0;
+
+ @override
+ double computeMinIntrinsicHeight(double width) {
+ assert(_debugThrowIfNotCheckingIntrinsics());
+ return 0;
+ }
+
+ @override
+ double computeMaxIntrinsicHeight(double width) {
+ assert(_debugThrowIfNotCheckingIntrinsics());
+ return 0;
+ }
+
+ bool _debugThrowIfNotCheckingIntrinsics() {
+ assert(() {
+ if (!RenderObject.debugCheckingIntrinsics) {
+ throw Exception(
+ "_TimeIndicatorsLayout doesn't have an intrinsic height.",
+ );
+ }
+ return true;
+ }());
+ return true;
+ }
+
+ @override
+ void performLayout() {
+ assert(!sizedByParent);
+
+ if (children.isEmpty) {
+ size = Size(0, constraints.maxHeight);
+ return;
+ }
+
+ var width = 0.0;
+ final childConstraints = BoxConstraints.loose(constraints.biggest);
+ for (final child in children) {
+ child.layout(childConstraints, parentUsesSize: true);
+ width = math.max(width, child.size.width);
+ }
+
+ size = Size(width, constraints.maxHeight);
+ for (final child in children) {
+ final data = child.parentData! as _TimeIndicatorParentData;
+ final time = data.time!;
+ final alignment = data.alignment!.resolve(textDirection);
+
+ final yAnchor = time / 1.days * size.height;
+ final outerRect = Rect.fromLTRB(
+ 0,
+ yAnchor - child.size.height,
+ size.width,
+ yAnchor + child.size.height,
+ );
+ (child.parentData! as _TimeIndicatorParentData).offset =
+ alignment.inscribe(child.size, outerRect).topLeft;
+ }
+ }
+
+ @override
+ bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
+ defaultHitTestChildren(result, position: position);
+
+ @override
+ void paint(PaintingContext context, Offset offset) =>
+ defaultPaint(context, offset);
+}
diff --git a/lib/src/components/time_overlays.dart b/lib/src/components/time_overlays.dart
new file mode 100644
index 0000000..426396a
--- /dev/null
+++ b/lib/src/components/time_overlays.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+
+import '../event/event.dart';
+import '../time/overlay.dart';
+import '../utils.dart';
+import 'date_content.dart';
+
+/// A widget that displays the given [TimeOverlay]s.
+///
+/// This widget doesn't honor [TimeOverlay]'s `position` by itself, so you might
+/// have to split your [TimeOverlay]s and display them in two separate widgets.
+///
+/// See also:
+///
+/// * [DateContent], which displays [Event]s and [TimeOverlay]s and also honors
+/// the `position`s.
+class TimeOverlays extends StatelessWidget {
+ const TimeOverlays({required this.overlays});
+
+ final List overlays;
+
+ @override
+ Widget build(BuildContext context) {
+ return LayoutBuilder(builder: (context, constraints) {
+ final height = constraints.maxHeight;
+
+ return Stack(children: [
+ for (final overlay in overlays)
+ Positioned.fill(
+ top: (overlay.start / 1.days) * height,
+ bottom: (1 - overlay.end / 1.days) * height,
+ child: overlay.widget,
+ ),
+ ]);
+ });
+ }
+}
diff --git a/lib/src/components/week_indicator.dart b/lib/src/components/week_indicator.dart
new file mode 100644
index 0000000..eaf0540
--- /dev/null
+++ b/lib/src/components/week_indicator.dart
@@ -0,0 +1,244 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:tuple/tuple.dart';
+
+import '../callbacks.dart';
+import '../config.dart';
+import '../date/controller.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+import '../week.dart';
+
+/// A widget that displays the week number and possibly year for the given week.
+///
+/// If the [WeekIndicatorStyle] contains multiple labels, the longest one that
+/// fits the available width is chosen. This behavior can be changed via
+/// [alwaysUseNarrowestVariant].
+///
+/// If [onTap] is not supplied, [DefaultTimetableCallbacks]'s `onWeekTap` is
+/// used if it's provided above in the widget tree.
+///
+/// See also:
+///
+/// * [WeekIndicatorStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+/// * [DefaultTimetableCallbacks], which provides callbacks to descendant
+/// Timetable widgets.
+class WeekIndicator extends StatelessWidget {
+ const WeekIndicator(
+ this.week, {
+ Key? key,
+ this.alwaysUseNarrowestVariant = false,
+ this.onTap,
+ this.style,
+ }) : super(key: key);
+ WeekIndicator.forDate(
+ DateTime date, {
+ Key? key,
+ this.alwaysUseNarrowestVariant = false,
+ this.onTap,
+ this.style,
+ }) : assert(date.isValidTimetableDate),
+ week = date.week,
+ super(key: key);
+ static Widget forController(
+ DateController? controller, {
+ Key? key,
+ bool alwaysUseNarrowestVariant = false,
+ VoidCallback? onTap,
+ WeekIndicatorStyle? style,
+ }) =>
+ _WeekIndicatorForController(
+ controller,
+ key: key,
+ alwaysUseNarrowestVariant: alwaysUseNarrowestVariant,
+ onTap: onTap,
+ style: style,
+ );
+
+ final Week week;
+ final bool alwaysUseNarrowestVariant;
+ final VoidCallback? onTap;
+ final WeekIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).weekIndicatorStyleProvider(week);
+ final defaultOnTap = DefaultTimetableCallbacks.of(context)?.onWeekTap;
+
+ return InkResponse(
+ onTap: onTap ?? (defaultOnTap != null ? () => defaultOnTap(week) : null),
+ child: Tooltip(
+ message: style.tooltip,
+ child: DecoratedBox(
+ decoration: style.decoration,
+ child: Padding(
+ padding: style.padding,
+ child: _buildText(context, style),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildText(BuildContext context, WeekIndicatorStyle style) {
+ final textStyle = _getEffectiveTextStyle(context, style.textStyle);
+
+ final measuredLabels = style.labels.map((it) {
+ final textPainter = TextPainter(
+ text: TextSpan(text: it, style: textStyle),
+ maxLines: 1,
+ textDirection: TextDirection.ltr,
+ )..layout(minWidth: 0, maxWidth: double.infinity);
+ return Tuple2(it, textPainter.size.width);
+ });
+
+ final narrowestText =
+ measuredLabels.minBy((a, b) => a.item2.compareTo(b.item2))!.item1;
+ Widget build(String text) => Text(text, style: textStyle, maxLines: 1);
+
+ if (alwaysUseNarrowestVariant) return build(narrowestText);
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ // Select the first one that fits, or otherwise the narrowest one.
+ final text = measuredLabels
+ .where((it) => it.item2 >= constraints.minWidth)
+ .where((it) => it.item2 <= constraints.maxWidth)
+ .map((it) => it.item1)
+ .firstOrElse(() => narrowestText);
+
+ return build(text);
+ },
+ );
+ }
+
+ TextStyle _getEffectiveTextStyle(BuildContext context, TextStyle textStyle) {
+ var effectiveTextStyle = textStyle;
+ if (effectiveTextStyle.inherit) {
+ effectiveTextStyle =
+ context.defaultTextStyle.style.merge(effectiveTextStyle);
+ }
+ if (MediaQuery.boldTextOverride(context)) {
+ effectiveTextStyle = effectiveTextStyle
+ .merge(const TextStyle(fontWeight: FontWeight.bold));
+ }
+ return effectiveTextStyle;
+ }
+}
+
+/// Defines visual properties for [WeekIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class WeekIndicatorStyle {
+ factory WeekIndicatorStyle(
+ BuildContext context,
+ Week week, {
+ String? tooltip,
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ List? labels,
+ }) {
+ final colorScheme = context.theme.colorScheme;
+ final localizations = TimetableLocalizations.of(context);
+ return WeekIndicatorStyle.raw(
+ tooltip: tooltip ?? localizations.weekOfYear(week),
+ decoration: decoration ??
+ BoxDecoration(
+ color: colorScheme.brightness.contrastColor.withOpacity(0.05),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ padding: padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 2),
+ textStyle: textStyle ??
+ context.textTheme.bodyText2!
+ .copyWith(color: colorScheme.background.mediumEmphasisOnColor),
+ labels: labels ?? localizations.weekLabels(week),
+ );
+ }
+
+ const WeekIndicatorStyle.raw({
+ required this.tooltip,
+ required this.decoration,
+ required this.padding,
+ required this.textStyle,
+ required this.labels,
+ }) : assert(labels.length > 0);
+
+ final String tooltip;
+ final Decoration decoration;
+ final EdgeInsetsGeometry padding;
+ final TextStyle textStyle;
+ final List labels;
+
+ WeekIndicatorStyle copyWith({
+ String? tooltip,
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ List? labels,
+ }) {
+ return WeekIndicatorStyle.raw(
+ tooltip: tooltip ?? this.tooltip,
+ decoration: decoration ?? this.decoration,
+ padding: padding ?? this.padding,
+ textStyle: textStyle ?? this.textStyle,
+ labels: labels ?? this.labels,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(
+ tooltip,
+ decoration,
+ padding,
+ textStyle,
+ DeepCollectionEquality().hash(labels),
+ );
+ @override
+ bool operator ==(Object other) {
+ return other is WeekIndicatorStyle &&
+ tooltip == other.tooltip &&
+ decoration == other.decoration &&
+ padding == other.padding &&
+ textStyle == other.textStyle &&
+ DeepCollectionEquality().equals(labels, other.labels);
+ }
+}
+
+class _WeekIndicatorForController extends StatelessWidget {
+ const _WeekIndicatorForController(
+ this.controller, {
+ Key? key,
+ this.alwaysUseNarrowestVariant = false,
+ this.onTap,
+ this.style,
+ }) : super(key: key);
+
+ final DateController? controller;
+ final bool alwaysUseNarrowestVariant;
+ final VoidCallback? onTap;
+ final WeekIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ return ValueListenableBuilder(
+ valueListenable: (controller ?? DefaultDateController.of(context)!)
+ .date
+ .map((it) => it.week),
+ builder: (context, week, _) => WeekIndicator(
+ week,
+ alwaysUseNarrowestVariant: alwaysUseNarrowestVariant,
+ onTap: onTap,
+ style: style,
+ ),
+ );
+ }
+}
diff --git a/lib/src/components/weekday_indicator.dart b/lib/src/components/weekday_indicator.dart
new file mode 100644
index 0000000..1736ae4
--- /dev/null
+++ b/lib/src/components/weekday_indicator.dart
@@ -0,0 +1,114 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../config.dart';
+import '../localization.dart';
+import '../theme.dart';
+import '../utils.dart';
+
+/// A widget that displays the weekday for the given date.
+///
+/// See also:
+///
+/// * [WeekdayIndicatorStyle], which defines visual properties for this widget.
+/// * [TimetableTheme] (and [TimetableConfig]), which provide styles to
+/// descendant Timetable widgets.
+class WeekdayIndicator extends StatelessWidget {
+ WeekdayIndicator(
+ this.date, {
+ Key? key,
+ this.style,
+ }) : assert(date.isValidTimetableDate),
+ super(key: key);
+
+ final DateTime date;
+ final WeekdayIndicatorStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ??
+ TimetableTheme.orDefaultOf(context).weekdayIndicatorStyleProvider(date);
+
+ return DecoratedBox(
+ decoration: style.decoration,
+ child: Padding(
+ padding: style.padding,
+ child: Text(style.label, style: style.textStyle),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [WeekdayIndicator].
+///
+/// See also:
+///
+/// * [TimetableThemeData], which bundles the styles for all Timetable widgets.
+@immutable
+class WeekdayIndicatorStyle {
+ factory WeekdayIndicatorStyle(
+ BuildContext context,
+ DateTime date, {
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ assert(date.isValidTimetableDate);
+
+ final theme = context.theme;
+ return WeekdayIndicatorStyle.raw(
+ decoration: decoration ?? BoxDecoration(),
+ padding: padding ?? EdgeInsets.zero,
+ textStyle: textStyle ??
+ theme.textTheme.caption!.copyWith(
+ color: date.isToday
+ ? theme.colorScheme.primary
+ : theme.colorScheme.background.mediumEmphasisOnColor,
+ ),
+ label: label ??
+ () {
+ context.dependOnTimetableLocalizations();
+ return DateFormat('EEE').format(date);
+ }(),
+ );
+ }
+
+ const WeekdayIndicatorStyle.raw({
+ required this.decoration,
+ required this.padding,
+ required this.textStyle,
+ required this.label,
+ });
+
+ final Decoration decoration;
+ final EdgeInsetsGeometry padding;
+ final TextStyle textStyle;
+ final String label;
+
+ WeekdayIndicatorStyle copyWith({
+ Decoration? decoration,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ String? label,
+ }) {
+ return WeekdayIndicatorStyle.raw(
+ decoration: decoration ?? this.decoration,
+ padding: padding ?? this.padding,
+ textStyle: textStyle ?? this.textStyle,
+ label: label ?? this.label,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(decoration, padding, textStyle, label);
+ @override
+ bool operator ==(Object other) {
+ return other is WeekdayIndicatorStyle &&
+ decoration == other.decoration &&
+ padding == other.padding &&
+ textStyle == other.textStyle &&
+ label == other.label;
+ }
+}
diff --git a/lib/src/config.dart b/lib/src/config.dart
new file mode 100644
index 0000000..f3e1502
--- /dev/null
+++ b/lib/src/config.dart
@@ -0,0 +1,97 @@
+import 'package:flutter/material.dart';
+
+import 'callbacks.dart';
+import 'date/controller.dart';
+import 'event/all_day.dart';
+import 'event/builder.dart';
+import 'event/event.dart';
+import 'event/provider.dart';
+import 'theme.dart';
+import 'time/controller.dart';
+import 'time/overlay.dart';
+
+class TimetableConfig extends StatefulWidget {
+ TimetableConfig({
+ Key? key,
+ this.dateController,
+ this.timeController,
+ EventProvider? eventProvider,
+ this.eventBuilder,
+ this.allDayEventBuilder,
+ this.timeOverlayProvider,
+ this.callbacks,
+ this.theme,
+ required this.child,
+ }) : eventProvider = eventProvider?.debugChecked,
+ super(key: key);
+
+ final DateController? dateController;
+ final TimeController? timeController;
+ final EventProvider? eventProvider;
+ final EventBuilder? eventBuilder;
+ final AllDayEventBuilder? allDayEventBuilder;
+ final TimeOverlayProvider? timeOverlayProvider;
+ final TimetableCallbacks? callbacks;
+ final TimetableThemeData? theme;
+ final Widget child;
+
+ @override
+ _TimetableConfigState createState() => _TimetableConfigState();
+}
+
+class _TimetableConfigState extends State> {
+ late final _dateController = DateController();
+ late final _timeController = TimeController();
+
+ @override
+ void dispose() {
+ _dateController.dispose();
+ _timeController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ Widget child = DefaultTimetableCallbacks(
+ callbacks: widget.callbacks ??
+ DefaultTimetableCallbacks.of(context) ??
+ TimetableCallbacks(),
+ child: TimetableTheme(
+ data: widget.theme ??
+ TimetableTheme.of(context) ??
+ TimetableThemeData(context),
+ child: widget.child,
+ ),
+ );
+
+ child = DefaultTimeOverlayProvider(
+ overlayProvider: widget.timeOverlayProvider ??
+ DefaultTimeOverlayProvider.of(context) ??
+ emptyTimeOverlayProvider,
+ child: child,
+ );
+
+ child = DefaultEventProvider(
+ eventProvider: widget.eventProvider ??
+ DefaultEventProvider.of(context) ??
+ (_) => [],
+ child: DefaultEventBuilder(
+ builder: widget.eventBuilder ?? DefaultEventBuilder.of(context)!,
+ allDayBuilder: widget.allDayEventBuilder,
+ child: child,
+ ),
+ );
+
+ return DefaultDateController(
+ controller: widget.dateController ??
+ DefaultDateController.of(context) ??
+ _dateController,
+ child: DefaultTimeController(
+ controller: widget.timeController ??
+ DefaultTimeController.of(context) ??
+ _timeController,
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/lib/src/content/current_time_indicator_painter.dart b/lib/src/content/current_time_indicator_painter.dart
deleted file mode 100644
index 7f16442..0000000
--- a/lib/src/content/current_time_indicator_painter.dart
+++ /dev/null
@@ -1,62 +0,0 @@
-import 'dart:ui';
-
-import 'package:dartx/dartx.dart';
-import 'package:flutter/material.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-
-import '../controller.dart';
-import '../event.dart';
-
-class CurrentTimeIndicatorPainter extends CustomPainter {
- CurrentTimeIndicatorPainter({
- @required this.controller,
- @required Color color,
- this.circleRadius = 4,
- Listenable repaint,
- }) : assert(controller != null),
- assert(color != null),
- _paint = Paint()..color = color,
- assert(circleRadius != null),
- super(
- repaint: Listenable.merge([
- controller.scrollControllers.pageListenable,
- repaint,
- ]),
- );
-
- final TimetableController controller;
- final Paint _paint;
- final double circleRadius;
-
- @override
- void paint(Canvas canvas, Size size) {
- final dateWidth = size.width / controller.visibleRange.visibleDays;
-
- final temporalOffset =
- LocalDate.today().epochDay - controller.scrollControllers.page;
- final left = temporalOffset * dateWidth;
- final right = left + dateWidth;
-
- if (right < 0 || left > size.width) {
- // The current date isn't visible so we don't have to paint anything.
- return;
- }
-
- final actualLeft = left.coerceAtLeast(0);
- final actualRight = right.coerceAtMost(size.width);
-
- final time = LocalTime.currentClockTime().timeSinceMidnight.inSeconds;
- final y = (time / TimeConstants.secondsPerDay) * size.height;
-
- final radius = lerpDouble(circleRadius, 0, (actualLeft - left) / dateWidth);
- canvas
- ..drawCircle(Offset(actualLeft, y), radius, _paint)
- ..drawLine(
- Offset(actualLeft + radius, y), Offset(actualRight, y), _paint);
- }
-
- @override
- bool shouldRepaint(CurrentTimeIndicatorPainter oldDelegate) =>
- _paint.color != oldDelegate._paint.color ||
- circleRadius != oldDelegate.circleRadius;
-}
diff --git a/lib/src/content/date_events.dart b/lib/src/content/date_events.dart
deleted file mode 100644
index 1291e49..0000000
--- a/lib/src/content/date_events.dart
+++ /dev/null
@@ -1,325 +0,0 @@
-import 'dart:ui';
-
-import 'package:collection/collection.dart';
-import 'package:dartx/dartx.dart';
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-
-import '../event.dart';
-import '../theme.dart';
-import '../timetable.dart';
-import '../utils/utils.dart';
-
-class DateEvents extends StatelessWidget {
- DateEvents({
- Key key,
- @required this.date,
- @required Iterable events,
- @required this.eventBuilder,
- }) : assert(date != null),
- assert(events != null),
- assert(
- events.every((e) => e.intersectsDate(date)),
- 'All events must intersect the given date',
- ),
- assert(
- events.map((e) => e.id).toSet().length == events.length,
- 'Events may not contain duplicate IDs',
- ),
- events = events.sortedByStartLength(),
- assert(eventBuilder != null),
- super(key: key);
-
- static final _defaultMinEventDuration = Period(minutes: 30);
- static const _defaultMinEventHeight = 16.0;
- static const _defaultEventSpacing = 1.0;
- static const _defaultStackedEventSpacing = 4.0;
- static final _defaultPartDayEventMinimumDeltaForStacking =
- Period(minutes: 15);
- @Deprecated('This is now configurable via '
- '[TimetableThemeData.partDayEventMinimumDeltaForStacking].')
- static Period get minStackOverlap =>
- _defaultPartDayEventMinimumDeltaForStacking;
-
- final LocalDate date;
- final List events;
- final EventBuilder eventBuilder;
-
- @override
- Widget build(BuildContext context) {
- final timetableTheme = context.timetableTheme;
-
- return CustomMultiChildLayout(
- delegate: _DayEventsLayoutDelegate(
- date: date,
- events: events,
- minEventDuration: timetableTheme?.partDayEventMinimumDuration ??
- _defaultMinEventDuration,
- minEventHeight:
- timetableTheme?.partDayEventMinimumHeight ?? _defaultMinEventHeight,
- eventSpacing:
- timetableTheme?.partDayEventSpacing ?? _defaultEventSpacing,
- enableStacking: timetableTheme?.enablePartDayEventStacking ?? true,
- minimumDeltaForStacking:
- timetableTheme?.partDayEventMinimumDeltaForStacking ??
- _defaultPartDayEventMinimumDeltaForStacking,
- stackedEventSpacing: timetableTheme?.partDayStackedEventSpacing ??
- _defaultStackedEventSpacing,
- ),
- children: [
- for (final event in events)
- LayoutId(
- key: ValueKey(event.id),
- id: event.id,
- child: eventBuilder(event),
- ),
- ],
- );
- }
-}
-
-class _DayEventsLayoutDelegate
- extends MultiChildLayoutDelegate {
- _DayEventsLayoutDelegate({
- @required this.date,
- @required this.events,
- @required this.minEventDuration,
- @required this.minEventHeight,
- @required this.eventSpacing,
- @required this.enableStacking,
- @required this.minimumDeltaForStacking,
- @required this.stackedEventSpacing,
- }) : assert(date != null),
- assert(events != null),
- assert(minEventDuration != null),
- assert(minEventHeight != null),
- assert(eventSpacing != null),
- assert(enableStacking != null),
- assert(minimumDeltaForStacking != null),
- assert(stackedEventSpacing != null);
-
- static const minWidth = 4.0;
-
- final LocalDate date;
- final List events;
-
- final Period minEventDuration;
- final double minEventHeight;
- final double eventSpacing;
- final bool enableStacking;
- final Period minimumDeltaForStacking;
- final double stackedEventSpacing;
-
- @override
- void performLayout(Size size) {
- final positions = _calculatePositions(size.height);
-
- double timeToY(LocalDateTime dateTime) {
- if (dateTime.calendarDate < date) {
- return 0;
- } else if (dateTime.calendarDate > date) {
- return size.height;
- } else {
- final progress = dateTime.clockTime.timeSinceMidnight.inMilliseconds /
- TimeConstants.millisecondsPerDay;
- return lerpDouble(0, size.height, progress);
- }
- }
-
- double periodToY(Period period) =>
- timeToY(date.at(LocalTime.midnight) + period);
-
- for (final event in events) {
- final position = positions.eventPositions[event];
- final top = timeToY(event.start)
- .coerceAtMost(size.height - periodToY(minEventDuration))
- .coerceAtMost(size.height - minEventHeight);
- final height = periodToY(_durationOn(event, date, size.height))
- .clamp(0, size.height - top);
-
- final columnWidth = (size.width - eventSpacing) /
- positions.groupColumnCounts[position.group];
- final columnLeft = columnWidth * position.column;
- final left = columnLeft + position.index * stackedEventSpacing;
- final width = columnWidth * position.columnSpan -
- position.index * stackedEventSpacing -
- eventSpacing;
-
- final childSize = Size(width.coerceAtLeast(minWidth), height);
- layoutChild(event.id, BoxConstraints.tight(childSize));
- positionChild(event.id, Offset(left, top));
- }
- }
-
- _EventPositions _calculatePositions(double height) {
- // How this layout algorithm works:
- // We first divide all events into groups, whereas a group contains all
- // events that intersect one another.
- // Inside a group, events with very close start times are split into
- // multiple columns.
- final positions = _EventPositions();
-
- var currentGroup = [];
- var currentEnd = TimetableLocalDateTime.minIsoValue;
- for (final event in events) {
- if (event.start >= currentEnd) {
- _endGroup(positions, currentGroup, height);
- currentGroup = [];
- currentEnd = TimetableLocalDateTime.minIsoValue;
- }
-
- currentGroup.add(event);
- currentEnd = currentEnd.coerceAtLeast(_actualEnd(event, height));
- }
- _endGroup(positions, currentGroup, height);
-
- return positions;
- }
-
- void _endGroup(
- _EventPositions positions, List currentGroup, double height) {
- if (currentGroup.isEmpty) {
- return;
- }
- if (currentGroup.length == 1) {
- positions.eventPositions[currentGroup.first] =
- _SingleEventPosition(positions.groupColumnCounts.length, 0, 0);
- positions.groupColumnCounts.add(1);
- return;
- }
-
- final columns = >[];
- for (final event in currentGroup) {
- var minColumn = -1;
- var minIndex = 1 << 31;
- var minEnd = TimetableLocalDateTime.minIsoValue;
- var columnFound = false;
- for (var columnIndex = 0; columnIndex < columns.length; columnIndex++) {
- final column = columns[columnIndex];
- final other = column.last;
-
- // No space in current column
- if (!enableStacking && event.start < _actualEnd(other, height) ||
- enableStacking &&
- event.start < other.start + minimumDeltaForStacking) {
- continue;
- }
-
- final index = column
- .where((e) => _actualEnd(e, height) >= event.start)
- .map((e) => positions.eventPositions[e].index)
- .max() ??
- -1;
- final previousEnd = column.fold(
- TimetableLocalDateTime.maxIsoValue,
- (max, e) => LocalDateTime.max(max, e.end),
- );
- // Further at the top and hence wider
- if (index < minIndex || (index == minIndex && previousEnd < minEnd)) {
- minColumn = columnIndex;
- minIndex = index;
- minEnd = previousEnd;
- columnFound = true;
- }
- }
-
- // If no column fits
- if (!columnFound) {
- positions.eventPositions[event] = _SingleEventPosition(
- positions.groupColumnCounts.length, columns.length, 0);
- columns.add([event]);
- continue;
- }
-
- positions.eventPositions[event] = _SingleEventPosition(
- positions.groupColumnCounts.length, minColumn, minIndex + 1);
- columns[minColumn].add(event);
- }
-
- // Expand events to multiple columns if possible.
- for (final event in currentGroup) {
- final position = positions.eventPositions[event];
- if (position.column == columns.length - 1) {
- continue;
- }
-
- var columnSpan = 1;
- for (var i = position.column + 1; i < columns.length; i++) {
- final hasOverlapInColumn = currentGroup
- .where((e) => positions.eventPositions[e].column == i)
- .where((e) =>
- event.start < _actualEnd(e, height) &&
- e.start < _actualEnd(event, height))
- .isNotEmpty;
- if (hasOverlapInColumn) {
- break;
- }
-
- columnSpan++;
- }
- positions.eventPositions[event] = position.copyWith(
- columnSpan: columnSpan,
- );
- }
-
- positions.groupColumnCounts.add(columns.length);
- }
-
- LocalDateTime _actualEnd(E event, double height) {
- final minDurationForHeight = Period(
- milliseconds:
- (minEventHeight / height * TimeConstants.millisecondsPerDay).toInt(),
- );
- return event.end
- .coerceAtLeast(event.start + minEventDuration)
- .coerceAtLeast(event.start + minDurationForHeight);
- }
-
- Period _durationOn(E event, LocalDate date, double height) {
- final todayStart = event.start.coerceAtLeast(date.atMidnight());
- final todayEnd =
- _actualEnd(event, height).coerceAtMost(date.addDays(1).atMidnight());
- return todayStart.periodUntil(todayEnd);
- }
-
- @override
- bool shouldRelayout(_DayEventsLayoutDelegate oldDelegate) {
- return date != oldDelegate.date ||
- minEventDuration != oldDelegate.minEventDuration ||
- minEventHeight != oldDelegate.minEventHeight ||
- eventSpacing != oldDelegate.eventSpacing ||
- stackedEventSpacing != oldDelegate.stackedEventSpacing ||
- !DeepCollectionEquality().equals(events, oldDelegate.events);
- }
-}
-
-class _EventPositions {
- final List groupColumnCounts = [];
- final Map eventPositions = {};
-}
-
-class _SingleEventPosition {
- _SingleEventPosition(
- this.group,
- this.column,
- this.index, {
- this.columnSpan = 1,
- }) : assert(group != null),
- assert(column != null),
- assert(columnSpan != null),
- assert(index != null);
-
- final int group;
- final int column;
- final int columnSpan;
- final int index;
-
- _SingleEventPosition copyWith({int columnSpan}) {
- return _SingleEventPosition(
- group,
- column,
- index,
- columnSpan: columnSpan ?? this.columnSpan,
- );
- }
-}
diff --git a/lib/src/content/date_hours_painter.dart b/lib/src/content/date_hours_painter.dart
deleted file mode 100644
index 2e026e9..0000000
--- a/lib/src/content/date_hours_painter.dart
+++ /dev/null
@@ -1,54 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-import 'package:time_machine/time_machine_text_patterns.dart';
-
-import '../utils/utils.dart';
-
-class DateHoursPainter extends CustomPainter {
- DateHoursPainter({
- @required this.textStyle,
- @required this.textDirection,
- }) : assert(textStyle != null),
- assert(textDirection != null),
- _painters = [
- for (final h in innerDateHours)
- TextPainter(
- text: TextSpan(
- text: _pattern.format(LocalTime(h, 0, 0)),
- style: textStyle,
- ),
- textDirection: textDirection,
- textAlign: TextAlign.right,
- ),
- ];
-
- static final _pattern = LocalTimePattern.createWithCurrentCulture('HH:mm');
-
- final TextStyle textStyle;
- final TextDirection textDirection;
- final List _painters;
-
- double _lastWidth;
-
- @override
- void paint(Canvas canvas, Size size) {
- if (size.width != _lastWidth) {
- for (final painter in _painters) {
- painter.layout(minWidth: size.width, maxWidth: size.width);
- }
- _lastWidth = size.width;
- }
-
- final hourHeight = size.height / TimeConstants.hoursPerDay;
- for (final h in innerDateHours) {
- final painter = _painters[h - 1];
- final y = h * hourHeight - painter.height / 2;
- painter.paint(canvas, Offset(0, y));
- }
- }
-
- @override
- bool shouldRepaint(DateHoursPainter oldDelegate) =>
- textStyle != oldDelegate.textStyle ||
- textDirection != oldDelegate.textDirection;
-}
diff --git a/lib/src/content/multi_date_background_painter.dart b/lib/src/content/multi_date_background_painter.dart
deleted file mode 100644
index d39b972..0000000
--- a/lib/src/content/multi_date_background_painter.dart
+++ /dev/null
@@ -1,49 +0,0 @@
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-
-import '../controller.dart';
-import '../event.dart';
-import '../utils/utils.dart';
-
-class MultiDateBackgroundPainter extends CustomPainter {
- MultiDateBackgroundPainter({
- @required this.controller,
- @required Color dividerColor,
- }) : assert(controller != null),
- assert(dividerColor != null),
- dividerPaint = Paint()..color = dividerColor,
- super(repaint: controller.scrollControllers.pageListenable);
-
- final TimetableController controller;
- final Paint dividerPaint;
-
- @override
- void paint(Canvas canvas, Size size) {
- _drawDateDividers(canvas, size);
- _drawHourDividers(canvas, size);
- }
-
- void _drawDateDividers(Canvas canvas, Size size) {
- canvas.drawLine(Offset(0, 0), Offset(0, size.height), dividerPaint);
-
- final initialOffset = 1 - controller.scrollControllers.page % 1;
- final dateCount = controller.visibleRange.visibleDays;
- final widthPerDate = size.width / dateCount;
- for (var i = 0; i + initialOffset < dateCount; i++) {
- final x = (initialOffset + i) * widthPerDate;
- canvas.drawLine(Offset(x, 0), Offset(x, size.height), dividerPaint);
- }
- }
-
- void _drawHourDividers(Canvas canvas, Size size) {
- final heightPerHour = size.height / TimeConstants.hoursPerDay;
- for (final h in innerDateHours) {
- final y = h * heightPerHour;
- canvas.drawLine(Offset(-8, y), Offset(size.width, y), dividerPaint);
- }
- }
-
- @override
- bool shouldRepaint(MultiDateBackgroundPainter oldDelegate) =>
- dividerPaint.color != oldDelegate.dividerPaint.color;
-}
diff --git a/lib/src/content/multi_date_content.dart b/lib/src/content/multi_date_content.dart
deleted file mode 100644
index 8b40585..0000000
--- a/lib/src/content/multi_date_content.dart
+++ /dev/null
@@ -1,94 +0,0 @@
-import 'package:black_hole_flutter/black_hole_flutter.dart';
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart';
-
-import '../controller.dart';
-import '../date_page_view.dart';
-import '../event.dart';
-import '../theme.dart';
-import '../timetable.dart';
-import '../utils/stream_change_notifier.dart';
-import 'current_time_indicator_painter.dart';
-import 'multi_date_background_painter.dart';
-import 'streamed_date_events.dart';
-
-class MultiDateContent extends StatefulWidget {
- const MultiDateContent({
- Key key,
- @required this.controller,
- @required this.eventBuilder,
- this.onEventBackgroundTap,
- }) : assert(controller != null),
- assert(eventBuilder != null),
- super(key: key);
-
- final TimetableController controller;
- final EventBuilder eventBuilder;
- final OnEventBackgroundTapCallback onEventBackgroundTap;
-
- @override
- _MultiDateContentState createState() => _MultiDateContentState();
-}
-
-class _MultiDateContentState
- extends State> {
- final _timeListenable =
- StreamChangeNotifier(Stream.periodic(Duration(seconds: 10)));
-
- @override
- void dispose() {
- _timeListenable.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final theme = context.theme;
- final timetableTheme = context.timetableTheme;
- return CustomPaint(
- painter: MultiDateBackgroundPainter(
- controller: widget.controller,
- dividerColor: timetableTheme?.dividerColor ?? theme.dividerColor,
- ),
- foregroundPainter: CurrentTimeIndicatorPainter(
- controller: widget.controller,
- color: timetableTheme?.timeIndicatorColor ??
- theme.highEmphasisOnBackground,
- ),
- child: DatePageView(
- controller: widget.controller,
- builder: (_, date) {
- return LayoutBuilder(
- builder: (context, constraints) {
- return GestureDetector(
- behavior: HitTestBehavior.translucent,
- onTapUp: widget.onEventBackgroundTap != null
- ? (details) {
- _callOnEventBackgroundTap(details, date, constraints);
- }
- : null,
- child: StreamedDateEvents(
- date: date,
- controller: widget.controller,
- eventBuilder: widget.eventBuilder,
- ),
- );
- },
- );
- },
- ),
- );
- }
-
- void _callOnEventBackgroundTap(
- TapUpDetails details,
- LocalDate date,
- BoxConstraints constraints,
- ) {
- final millis = details.localPosition.dy /
- constraints.maxHeight *
- TimeConstants.millisecondsPerDay;
- final time = LocalTime.sinceMidnight(Time(milliseconds: millis.floor()));
- widget.onEventBackgroundTap(date.at(time), false);
- }
-}
diff --git a/lib/src/content/streamed_date_events.dart b/lib/src/content/streamed_date_events.dart
deleted file mode 100644
index 97c94be..0000000
--- a/lib/src/content/streamed_date_events.dart
+++ /dev/null
@@ -1,38 +0,0 @@
-import 'package:flutter/widgets.dart';
-import 'package:meta/meta.dart';
-import 'package:time_machine/time_machine.dart';
-
-import '../controller.dart';
-import '../event.dart';
-import '../timetable.dart';
-import 'date_events.dart';
-
-class StreamedDateEvents extends StatelessWidget {
- const StreamedDateEvents({
- @required this.date,
- @required this.controller,
- @required this.eventBuilder,
- }) : assert(date != null),
- assert(controller != null),
- assert(eventBuilder != null);
-
- final LocalDate date;
- final TimetableController controller;
- final EventBuilder eventBuilder;
-
- @override
- Widget build(BuildContext context) {
- return StreamBuilder>(
- key: ValueKey(date),
- stream: controller.eventProvider.getPartDayEventsIntersecting(date),
- builder: (context, snapshot) {
- final events = snapshot.data ?? [];
- return DateEvents(
- date: date,
- events: events,
- eventBuilder: eventBuilder,
- );
- },
- );
- }
-}
diff --git a/lib/src/content/timetable_content.dart b/lib/src/content/timetable_content.dart
deleted file mode 100644
index 769a6fd..0000000
--- a/lib/src/content/timetable_content.dart
+++ /dev/null
@@ -1,65 +0,0 @@
-import 'package:black_hole_flutter/black_hole_flutter.dart';
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart';
-
-import '../controller.dart';
-import '../event.dart';
-import '../theme.dart';
-import '../timetable.dart';
-import '../utils/vertical_zoom.dart';
-import 'date_hours_painter.dart';
-import 'multi_date_content.dart';
-
-class TimetableContent extends StatelessWidget {
- const TimetableContent({
- Key key,
- @required this.controller,
- @required this.eventBuilder,
- this.onEventBackgroundTap,
- }) : assert(controller != null),
- assert(eventBuilder != null),
- super(key: key);
-
- final TimetableController controller;
- final EventBuilder eventBuilder;
- final OnEventBackgroundTapCallback onEventBackgroundTap;
-
- @override
- Widget build(BuildContext context) {
- final theme = context.theme;
- final timetableTheme = context.timetableTheme;
-
- return VerticalZoom(
- initialZoom: controller.initialTimeRange.asInitialZoom(),
- minChildHeight:
- (timetableTheme?.minimumHourHeight ?? 16) * TimeConstants.hoursPerDay,
- maxChildHeight:
- (timetableTheme?.maximumHourHeight ?? 64) * TimeConstants.hoursPerDay,
- child: Row(
- children: [
- Container(
- width: hourColumnWidth,
- padding: EdgeInsets.only(right: 12),
- child: CustomPaint(
- painter: DateHoursPainter(
- textStyle: timetableTheme?.hourTextStyle ??
- theme.textTheme.caption.copyWith(
- color: context.theme.disabledOnBackground,
- ),
- textDirection: context.directionality,
- ),
- size: Size.infinite,
- ),
- ),
- Expanded(
- child: MultiDateContent(
- controller: controller,
- eventBuilder: eventBuilder,
- onEventBackgroundTap: onEventBackgroundTap,
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/src/controller.dart b/lib/src/controller.dart
deleted file mode 100644
index f6887f6..0000000
--- a/lib/src/controller.dart
+++ /dev/null
@@ -1,122 +0,0 @@
-import 'package:flutter/foundation.dart';
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart';
-
-import 'event.dart';
-import 'event_provider.dart';
-import 'header/timetable_header.dart';
-import 'initial_time_range.dart';
-import 'timetable.dart';
-import 'utils/scrolling.dart';
-import 'utils/utils.dart';
-import 'visible_range.dart';
-
-/// Controls a [Timetable] and manages its state.
-class TimetableController {
- TimetableController({
- @required this.eventProvider,
- LocalDate initialDate,
- this.initialTimeRange = const InitialTimeRange.zoom(1),
- this.visibleRange = const VisibleRange.week(),
- this.firstDayOfWeek = DayOfWeek.monday,
- }) : assert(eventProvider != null),
- initialDate = initialDate ?? LocalDate.today(),
- assert(initialTimeRange != null),
- assert(firstDayOfWeek != null),
- assert(visibleRange != null) {
- _scrollControllers = LinkedScrollControllerGroup(
- initialPage: visibleRange.getTargetPageForFocusDate(
- this.initialDate, firstDayOfWeek),
- viewportFraction: 1 / visibleRange.visibleDays,
- );
-
- _dateListenable = scrollControllers.pageListenable
- .map((page) => LocalDate.fromEpochDay(page.floor()));
- _currentlyVisibleDatesListenable = scrollControllers.pageListenable
- .map((page) {
- return DateInterval(
- LocalDate.fromEpochDay(page.floor()),
- LocalDate.fromEpochDay(page.ceil() + visibleRange.visibleDays - 1),
- );
- })
- ..addListener(
- () => eventProvider.onVisibleDatesChanged(currentlyVisibleDates));
- eventProvider.onVisibleDatesChanged(currentlyVisibleDates);
- }
-
- /// The [EventProvider] used for populating [Timetable] with events.
- final EventProvider eventProvider;
-
- /// The initially visible time range.
- ///
- /// This defaults to the full day.
- final InitialTimeRange initialTimeRange;
-
- /// The initially focused date.
- ///
- /// This defaults to [LocalDate.today];
- final LocalDate initialDate;
-
- /// Determines how many days are visible and how these snap to the viewport.
- ///
- /// This defaults to [VisibleRange.week].
- final VisibleRange visibleRange;
-
- /// The [DayOfWeek] on which a week starts.
- ///
- /// This defaults to [DayOfWeek.monday].
- ///
- /// It is used e.g. by [VisibleRange.week] to snap to the correct range and by
- /// [TimetableHeader] to calculate the current week number.
- final DayOfWeek firstDayOfWeek;
-
- LinkedScrollControllerGroup _scrollControllers;
- LinkedScrollControllerGroup get scrollControllers => _scrollControllers;
-
- ValueNotifier _dateListenable;
- ValueListenable get dateListenable => _dateListenable;
-
- ValueNotifier _currentlyVisibleDatesListenable;
- ValueListenable get currentlyVisibleDatesListenable =>
- _currentlyVisibleDatesListenable;
- DateInterval get currentlyVisibleDates =>
- currentlyVisibleDatesListenable.value;
-
- /// Animates today into view.
- ///
- /// The alignment of today inside the viewport depends on [visibleRange].
- Future animateToToday({
- Curve curve = Curves.easeInOut,
- Duration duration = const Duration(milliseconds: 200),
- }) =>
- animateTo(LocalDate.today(), curve: curve, duration: duration);
-
- /// Animates the given [date] into view.
- ///
- /// The alignment of today inside the viewport depends on [visibleRange].
- Future animateTo(
- LocalDate date, {
- Curve curve = Curves.easeInOut,
- Duration duration = const Duration(milliseconds: 200),
- }) async {
- await scrollControllers.animateTo(
- visibleRange.getTargetPageForFocusDate(date, firstDayOfWeek),
- curve: curve,
- duration: duration,
- );
- }
-
- /// Discards any resources used by the controller.
- ///
- /// After this is called, the controller is not in a usable state and should
- /// be discarded.
- ///
- /// This method should only be called by the object's owner, usually in
- /// [State.dispose].
- void dispose() {
- eventProvider.dispose();
-
- _dateListenable.dispose();
- _currentlyVisibleDatesListenable.dispose();
- }
-}
diff --git a/lib/src/date/controller.dart b/lib/src/date/controller.dart
new file mode 100644
index 0000000..88f8991
--- /dev/null
+++ b/lib/src/date/controller.dart
@@ -0,0 +1,190 @@
+import 'dart:ui';
+
+import 'package:flutter/animation.dart' hide Interval;
+import 'package:flutter/foundation.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/widgets.dart';
+
+import '../config.dart';
+import '../utils.dart';
+import 'visible_date_range.dart';
+
+/// Controls the visible dates in Timetable widgets.
+///
+/// You can read (and listen to) the currently visible dates via [date].
+///
+/// To programmatically change the visible dates, use any of the following
+/// functions:
+/// * [animateToToday], [animateTo], or [animateToPage] if you want an animation
+/// * [jumpToToday], [jumpTo], or [jumpToPage] if you don't want an animation
+///
+/// You can also get and update the [VisibleDateRange] via [visibleRange].
+class DateController extends ValueNotifier {
+ DateController({
+ DateTime? initialDate,
+ VisibleDateRange? visibleRange,
+ }) : assert(initialDate.isValidTimetableDate),
+ // We set the correct value in the body below.
+ super(DatePageValue(
+ visibleRange ?? VisibleDateRange.week(),
+ 0,
+ )) {
+ // The correct value is set via the listener when we assign to our value.
+ _date = _DateValueNotifier(DateTimeTimetable.dateFromPage(0));
+ addListener(() => _date.value = value.date);
+
+ final rawStartPage = initialDate?.page ?? DateTimeTimetable.today().page;
+ value = value.copyWith(
+ page: value.visibleRange.getTargetPageForFocus(rawStartPage),
+ );
+ }
+
+ late final ValueNotifier _date;
+ ValueListenable get date => _date;
+
+ VisibleDateRange get visibleRange => value.visibleRange;
+ set visibleRange(VisibleDateRange visibleRange) {
+ value = value.copyWith(
+ page: visibleRange.getTargetPageForFocus(value.page),
+ visibleRange: visibleRange,
+ );
+ }
+
+ // Animation
+ AnimationController? _animationController;
+
+ Future animateToToday({
+ Curve curve = Curves.easeInOut,
+ Duration duration = const Duration(milliseconds: 200),
+ required TickerProvider vsync,
+ }) {
+ return animateTo(
+ DateTimeTimetable.today(),
+ curve: curve,
+ duration: duration,
+ vsync: vsync,
+ );
+ }
+
+ Future animateTo(
+ DateTime date, {
+ Curve curve = Curves.easeInOut,
+ Duration duration = const Duration(milliseconds: 200),
+ required TickerProvider vsync,
+ }) {
+ return animateToPage(
+ date.page,
+ curve: curve,
+ duration: duration,
+ vsync: vsync,
+ );
+ }
+
+ Future animateToPage(
+ double page, {
+ Curve curve = Curves.easeInOut,
+ Duration duration = const Duration(milliseconds: 200),
+ required TickerProvider vsync,
+ }) async {
+ _animationController?.dispose();
+ final controller =
+ AnimationController(debugLabel: 'DateController', vsync: vsync);
+ _animationController = controller;
+
+ final previousPage = value.page;
+ final targetPage = value.visibleRange.getTargetPageForFocus(page);
+ controller.addListener(() {
+ value = value.copyWith(
+ page: lerpDouble(previousPage, targetPage, controller.value)!,
+ );
+ });
+
+ controller.addStatusListener((status) {
+ if (status != AnimationStatus.completed) return;
+ controller.dispose();
+ _animationController = null;
+ });
+
+ await controller.animateTo(1, duration: duration, curve: curve);
+ }
+
+ void jumpToToday() => jumpTo(DateTimeTimetable.today());
+ void jumpTo(DateTime date) {
+ assert(date.isValidTimetableDate);
+ jumpToPage(date.page);
+ }
+
+ void jumpToPage(double page) {
+ value =
+ value.copyWith(page: value.visibleRange.getTargetPageForFocus(page));
+ }
+
+ bool _isDisposed = false;
+ bool get isDisposed => _isDisposed;
+ @override
+ void dispose() {
+ _date.dispose();
+ super.dispose();
+ _isDisposed = true;
+ }
+}
+
+class _DateValueNotifier extends ValueNotifier {
+ _DateValueNotifier(DateTime date)
+ : assert(date.isValidTimetableDate),
+ super(date);
+}
+
+/// The value held by [DateController].
+@immutable
+class DatePageValue {
+ const DatePageValue(this.visibleRange, this.page);
+
+ final VisibleDateRange visibleRange;
+ int get visibleDayCount => visibleRange.visibleDayCount;
+
+ final double page;
+ DateTime get date => DateTimeTimetable.dateFromPage(page.floor());
+
+ DatePageValue copyWith({VisibleDateRange? visibleRange, double? page}) {
+ return DatePageValue(visibleRange ?? this.visibleRange, page ?? this.page);
+ }
+
+ @override
+ int get hashCode => hashValues(visibleRange, page);
+ @override
+ bool operator ==(Object other) {
+ return other is DatePageValue &&
+ visibleRange == other.visibleRange &&
+ page == other.page;
+ }
+
+ @override
+ String toString() =>
+ 'DatePageValue(visibleRange = $visibleRange, page = $page)';
+}
+
+/// Provides the [DateController] for Timetable widgets below it.
+///
+/// See also:
+///
+/// * [TimetableConfig], which bundles multiple configuration widgets for
+/// Timetable.
+class DefaultDateController extends InheritedWidget {
+ const DefaultDateController({
+ required this.controller,
+ required Widget child,
+ }) : super(child: child);
+
+ final DateController controller;
+
+ @override
+ bool updateShouldNotify(DefaultDateController oldWidget) =>
+ controller != oldWidget.controller;
+
+ static DateController? of(BuildContext context) {
+ return context
+ .dependOnInheritedWidgetOfExactType()
+ ?.controller;
+ }
+}
diff --git a/lib/src/date/date_page_view.dart b/lib/src/date/date_page_view.dart
new file mode 100644
index 0000000..6c56d65
--- /dev/null
+++ b/lib/src/date/date_page_view.dart
@@ -0,0 +1,304 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+import '../utils.dart';
+import 'controller.dart';
+import 'scroll_physics.dart';
+import 'visible_date_range.dart';
+
+/// "DateTimes can represent time values that are at a distance of at most
+/// 100,000,000 days from epoch […]".
+const _minPage = -100000000;
+
+/// A page view for displaying dates that supports shrink-wrapping in the cross
+/// axis.
+///
+/// A controller has to be provided, either directly via the constructor, or via
+/// a [DefaultDateController] above in the widget tree.
+class DatePageView extends StatefulWidget {
+ const DatePageView({
+ Key? key,
+ this.controller,
+ this.shrinkWrapInCrossAxis = false,
+ required this.builder,
+ }) : super(key: key);
+
+ final DateController? controller;
+ final bool shrinkWrapInCrossAxis;
+ final DateWidgetBuilder builder;
+
+ @override
+ _DatePageViewState createState() => _DatePageViewState();
+}
+
+class _DatePageViewState extends State {
+ DateController? _controller;
+ _MultiDateScrollController? _scrollController;
+ final _heights = {};
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (_controller != null && !_controller!.isDisposed) {
+ _controller!.date.removeListener(_onDateChanged);
+ _scrollController!.dispose();
+ }
+ _controller = widget.controller ?? DefaultDateController.of(context)!;
+ _scrollController = _MultiDateScrollController(_controller!);
+ _controller!.date.addListener(_onDateChanged);
+ }
+
+ @override
+ void dispose() {
+ _controller!.date.removeListener(_onDateChanged);
+ _scrollController!.dispose();
+ super.dispose();
+ }
+
+ void _onDateChanged() {
+ final datePageValue = _controller!.value;
+ final firstPage = datePageValue.page.round();
+ final lastPage = datePageValue.page.round() + datePageValue.visibleDayCount;
+ _heights.removeWhere((key, _) => key < firstPage - 5 || key > lastPage + 5);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ Widget child = ValueListenableBuilder(
+ valueListenable: _controller!.map((it) => it.visibleRange.canScroll),
+ builder: (context, canScroll, _) =>
+ canScroll ? _buildScrollingChild() : _buildNonScrollingChild(),
+ );
+
+ if (widget.shrinkWrapInCrossAxis) {
+ child = ValueListenableBuilder(
+ valueListenable: _controller!,
+ builder: (context, pageValue, child) => ImmediateSizedBox(
+ heightGetter: () => _getHeight(pageValue),
+ child: child!,
+ ),
+ child: child,
+ );
+ }
+ return child;
+ }
+
+ Widget _buildScrollingChild() {
+ return Scrollable(
+ axisDirection: AxisDirection.right,
+ physics: DateScrollPhysics(_controller!.map((it) => it.visibleRange)),
+ controller: _scrollController!,
+ viewportBuilder: (context, position) {
+ return Viewport(
+ axisDirection: AxisDirection.right,
+ offset: position,
+ slivers: [
+ ValueListenableBuilder(
+ valueListenable: _controller!.map((it) => it.visibleDayCount),
+ builder: (context, visibleDayCount, _) => SliverFillViewport(
+ padEnds: false,
+ viewportFraction: 1 / visibleDayCount,
+ delegate: SliverChildBuilderDelegate(
+ (context, index) => _buildPage(context, _minPage + index),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ Widget _buildNonScrollingChild() {
+ return ValueListenableBuilder(
+ valueListenable: _controller!,
+ builder: (context, value, _) => Row(
+ children: [
+ for (var i = 0; i < value.visibleDayCount; i++)
+ Expanded(child: _buildPage(context, value.page.toInt() + i)),
+ ],
+ ),
+ );
+ }
+
+ double _getHeight(DatePageValue pageValue) {
+ double maxHeightFrom(int page) {
+ return page
+ .until(page + pageValue.visibleDayCount)
+ .map((it) => _heights[it] ?? 0)
+ .max()!;
+ }
+
+ final oldMaxHeight = maxHeightFrom(pageValue.page.floor());
+ final newMaxHeight = maxHeightFrom(pageValue.page.ceil());
+ final t = pageValue.page - pageValue.page.floorToDouble();
+ return lerpDouble(oldMaxHeight, newMaxHeight, t)!;
+ }
+
+ Widget _buildPage(BuildContext context, int page) {
+ var child = widget.builder(context, DateTimeTimetable.dateFromPage(page));
+ if (widget.shrinkWrapInCrossAxis) {
+ child = ImmediateSizeReportingOverflowPage(
+ onSizeChanged: (size) {
+ if (_heights[page] == size.height) return;
+ _heights[page] = size.height;
+ WidgetsBinding.instance!.addPostFrameCallback((_) => setState(() {}));
+ },
+ child: child,
+ );
+ }
+ return child;
+ }
+}
+
+class _MultiDateScrollController extends ScrollController {
+ _MultiDateScrollController(this.controller)
+ : super(initialScrollOffset: controller.value.page) {
+ controller.addListener(_listenToController);
+ }
+
+ final DateController controller;
+ int get visibleDayCount => controller.value.visibleDayCount;
+
+ double get page => position.page;
+
+ void _listenToController() {
+ if (hasClients) position.forcePage(controller.value.page);
+ }
+
+ @override
+ void dispose() {
+ controller.removeListener(_listenToController);
+ super.dispose();
+ }
+
+ @override
+ void attach(ScrollPosition position) {
+ assert(
+ position is MultiDateScrollPosition,
+ '_MultiDateScrollControllers can only be used with '
+ 'MultiDateScrollPositions.',
+ );
+ final linkedPosition = position as MultiDateScrollPosition;
+ assert(
+ linkedPosition.owner == this,
+ 'MultiDateScrollPosition cannot change controllers once created.',
+ );
+ super.attach(position);
+ }
+
+ @override
+ MultiDateScrollPosition createScrollPosition(
+ ScrollPhysics physics,
+ ScrollContext context,
+ ScrollPosition? oldPosition,
+ ) {
+ return MultiDateScrollPosition(
+ this,
+ physics: physics,
+ context: context,
+ initialPage: initialScrollOffset,
+ oldPosition: oldPosition,
+ );
+ }
+
+ @override
+ MultiDateScrollPosition get position =>
+ super.position as MultiDateScrollPosition;
+}
+
+class MultiDateScrollPosition extends ScrollPositionWithSingleContext {
+ MultiDateScrollPosition(
+ this.owner, {
+ required ScrollPhysics physics,
+ required ScrollContext context,
+ required this.initialPage,
+ ScrollPosition? oldPosition,
+ }) : super(
+ physics: physics,
+ context: context,
+ initialPixels: null,
+ oldPosition: oldPosition,
+ );
+
+ final _MultiDateScrollController owner;
+ DateController get controller => owner.controller;
+ double initialPage;
+
+ double get page => pixelsToPage(pixels);
+
+ @override
+ bool applyViewportDimension(double viewportDimension) {
+ final hadViewportDimension = hasViewportDimension;
+ final isInitialLayout = !hasPixels || !hadViewportDimension;
+ final oldPixels = hasPixels ? pixels : null;
+ final page = isInitialLayout ? initialPage : this.page;
+
+ final result = super.applyViewportDimension(viewportDimension);
+ final newPixels = pageToPixels(page);
+ if (newPixels != oldPixels) {
+ correctPixels(newPixels);
+ return false;
+ }
+ return result;
+ }
+
+ bool _isApplyingNewDimensions = false;
+ @override
+ void applyNewDimensions() {
+ _isApplyingNewDimensions = true;
+ super.applyNewDimensions();
+ _isApplyingNewDimensions = false;
+ }
+
+ @override
+ void goBallistic(double velocity) {
+ if (_isApplyingNewDimensions) {
+ assert(velocity == 0);
+ return;
+ }
+ super.goBallistic(velocity);
+ }
+
+ @override
+ double setPixels(double newPixels) {
+ if (newPixels == pixels) return 0;
+
+ _updateUserScrollDirectionFromDelta(newPixels - pixels);
+ final overscroll = super.setPixels(newPixels);
+ controller.value = controller.value.copyWith(page: pixelsToPage(pixels));
+ return overscroll;
+ }
+
+ void forcePage(double page) => forcePixels(pageToPixels(page));
+ @override
+ void forcePixels(double value) {
+ if (value == pixels) return;
+
+ _updateUserScrollDirectionFromDelta(value - pixels);
+ super.forcePixels(value);
+ }
+
+ void _updateUserScrollDirectionFromDelta(double delta) {
+ final direction =
+ delta > 0 ? ScrollDirection.forward : ScrollDirection.reverse;
+ updateUserScrollDirection(direction);
+ }
+
+ double pixelsToPage(double pixels) =>
+ _minPage + pixelDeltaToPageDelta(pixels);
+ double pageToPixels(double page) => pageDeltaToPixelDelta(page - _minPage);
+
+ double pixelDeltaToPageDelta(double pixels) =>
+ pixels * owner.visibleDayCount / viewportDimension;
+ double pageDeltaToPixelDelta(double page) =>
+ page / owner.visibleDayCount * viewportDimension;
+
+ @override
+ void debugFillDescription(List description) {
+ super.debugFillDescription(description);
+ description.add('owner: $owner');
+ }
+}
diff --git a/lib/src/date/month_page_view.dart b/lib/src/date/month_page_view.dart
new file mode 100644
index 0000000..90c6204
--- /dev/null
+++ b/lib/src/date/month_page_view.dart
@@ -0,0 +1,163 @@
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import '../utils.dart';
+
+/// A page view for displaying months that supports shrink-wrapping in the cross
+/// axis.
+class MonthPageView extends StatefulWidget {
+ const MonthPageView({
+ this.monthPageController,
+ this.shrinkWrapInCrossAxis = false,
+ required this.builder,
+ });
+
+ final MonthPageController? monthPageController;
+ final bool shrinkWrapInCrossAxis;
+ final MonthWidgetBuilder builder;
+
+ @override
+ _MonthPageViewState createState() => _MonthPageViewState();
+}
+
+class _MonthPageViewState extends State {
+ late final MonthPageController _controller;
+ final _heights = {};
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = widget.monthPageController ??
+ MonthPageController(initialMonth: DateTimeTimetable.currentMonth());
+ _controller.addListener(_onMonthChanged);
+ }
+
+ @override
+ void dispose() {
+ _controller.removeListener(_onMonthChanged);
+ if (widget.monthPageController == null) _controller.dispose();
+ super.dispose();
+ }
+
+ void _onMonthChanged() {
+ final page = _controller._pageController.page!.round();
+ _heights.removeWhere((key, _) => (key - page).abs() > 5);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ Widget child = PageView.builder(
+ controller: _controller._pageController,
+ itemBuilder: (context, page) {
+ final month = MonthPageController._monthFromPage(page);
+
+ var child = widget.builder(context, month);
+ if (widget.shrinkWrapInCrossAxis) {
+ child = ImmediateSizeReportingOverflowPage(
+ onSizeChanged: (size) {
+ if (_heights[page] == size.height) return;
+ _heights[page] = size.height;
+ WidgetsBinding.instance!
+ .addPostFrameCallback((_) => setState(() {}));
+ },
+ child: child,
+ );
+ }
+ return child;
+ },
+ );
+
+ if (widget.shrinkWrapInCrossAxis) {
+ child = AnimatedBuilder(
+ animation: _controller._pageController,
+ builder: (context, child) =>
+ ImmediateSizedBox(heightGetter: _getHeight, child: child!),
+ child: child,
+ );
+ }
+ return child;
+ }
+
+ double _getHeight() {
+ final pageController = _controller._pageController;
+ if (!pageController.hasClients) return 0;
+
+ final page = pageController.page;
+ if (page == null) return 0;
+ final oldMaxHeight = _heights[page.floor()];
+ final newMaxHeight = _heights[page.ceil()];
+
+ // When swiping, the next page might not have been measured yet.
+ if (oldMaxHeight == null) return newMaxHeight!;
+ if (newMaxHeight == null) return oldMaxHeight;
+
+ return lerpDouble(oldMaxHeight, newMaxHeight, page - page.floorToDouble())!;
+ }
+}
+
+/// Controls a [MonthPageView].
+class MonthPageController extends ChangeNotifier
+ implements ValueListenable {
+ MonthPageController({required DateTime initialMonth})
+ : assert(initialMonth.isValidTimetableMonth),
+ _pageController =
+ PageController(initialPage: _pageFromMonth(initialMonth)) {
+ _pageController.addListener(notifyListeners);
+ }
+
+ final PageController _pageController;
+
+ @override
+ DateTime get value => _monthFromPage(_pageController.page!.round());
+
+ late DateTime _previousValue = value;
+ @override
+ void notifyListeners() {
+ final newValue = value;
+ if (newValue == _previousValue) return;
+ _previousValue = newValue;
+ super.notifyListeners();
+ }
+
+ @override
+ void dispose() {
+ _pageController.removeListener(notifyListeners);
+ super.dispose();
+ }
+
+ // "DateTimes can represent time values that are at a distance of at most
+ // 100,000,000 days from epoch […]", which would be -271821-04-20.
+ static final _minMonth = DateTime.utc(-271821, 6, 1);
+ static final _minPage =
+ (_minMonth.year * DateTime.monthsPerYear) + (_minMonth.month - 1);
+ static DateTime _monthFromPage(int page) {
+ page = _minPage + page;
+ final year = (page < 0 ? page - DateTime.monthsPerYear + 1 : page) ~/
+ DateTime.monthsPerYear;
+ final month = page % DateTime.monthsPerYear + 1;
+ return DateTimeTimetable.month(year, month);
+ }
+
+ static int _pageFromMonth(DateTime month) {
+ assert(month.isValidTimetableMonth);
+ return (month.year * DateTime.monthsPerYear) + (month.month - 1) - _minPage;
+ }
+
+ // Animation
+ Future animateTo(
+ DateTime month, {
+ Curve curve = Curves.easeInOut,
+ Duration duration = const Duration(milliseconds: 200),
+ }) async {
+ await _pageController.animateToPage(
+ _pageFromMonth(month),
+ duration: duration,
+ curve: curve,
+ );
+ }
+
+ void jumpTo(DateTime month) =>
+ _pageController.jumpToPage(_pageFromMonth(month));
+}
diff --git a/lib/src/date/scroll_physics.dart b/lib/src/date/scroll_physics.dart
new file mode 100644
index 0000000..c49daa9
--- /dev/null
+++ b/lib/src/date/scroll_physics.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'date_page_view.dart';
+import 'visible_date_range.dart';
+
+class DateScrollPhysics extends ScrollPhysics {
+ const DateScrollPhysics(this.visibleRangeListenable, {ScrollPhysics? parent})
+ : super(parent: parent);
+
+ final ValueListenable visibleRangeListenable;
+ VisibleDateRange get visibleRange => visibleRangeListenable.value;
+
+ @override
+ DateScrollPhysics applyTo(ScrollPhysics? ancestor) =>
+ DateScrollPhysics(visibleRangeListenable, parent: buildParent(ancestor));
+
+ @override
+ double applyBoundaryConditions(ScrollMetrics position, double value) {
+ if (position is! MultiDateScrollPosition) {
+ throw ArgumentError(
+ 'DateScrollPhysics must be used with MultiDateScrollPosition.',
+ );
+ }
+
+ final page = position.pixelsToPage(value);
+ final overscrollPages = visibleRange.applyBoundaryConditions(page);
+ final overscroll = position.pageDeltaToPixelDelta(overscrollPages);
+
+ // Flutter doesn't allow boundary conditions to apply greater differences
+ // than the actual delta. Due to numbers having a limited precision, this
+ // occurs fairly often after conversion between pixels and pages, hence we
+ // clamp the final value.
+ final maximumDelta = (value - position.pixels).abs();
+ return overscroll.clamp(-maximumDelta, maximumDelta);
+ }
+
+ @override
+ Simulation? createBallisticSimulation(
+ ScrollMetrics position,
+ double velocity,
+ ) {
+ if (position is! MultiDateScrollPosition) {
+ throw ArgumentError(
+ 'DateScrollPhysics must be used with MultiDateScrollPosition.',
+ );
+ }
+
+ // If we're out of range and not headed back in range, defer to the parent
+ // ballistics, which should put us back in range at a page boundary.
+ if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
+ (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
+ return super.createBallisticSimulation(position, velocity);
+ }
+
+ final targetPage = visibleRange.getTargetPageForCurrent(
+ position.page,
+ velocity: position.pixelDeltaToPageDelta(velocity),
+ tolerance: Tolerance(
+ distance: position.pixelDeltaToPageDelta(tolerance.distance),
+ time: tolerance.time,
+ velocity: position.pixelDeltaToPageDelta(tolerance.velocity),
+ ),
+ );
+ final target = position.pageToPixels(targetPage);
+
+ if (target != position.pixels) {
+ return ScrollSpringSimulation(
+ spring,
+ position.pixels,
+ target,
+ velocity,
+ tolerance: tolerance,
+ );
+ }
+ return null;
+ }
+
+ @override
+ bool get allowImplicitScrolling => false;
+}
diff --git a/lib/src/date/visible_date_range.dart b/lib/src/date/visible_date_range.dart
new file mode 100644
index 0000000..66bcc01
--- /dev/null
+++ b/lib/src/date/visible_date_range.dart
@@ -0,0 +1,207 @@
+import 'package:flutter/physics.dart';
+
+import '../layouts/recurring_multi_date.dart';
+import '../utils.dart';
+
+/// Defines how many days are visible at once and whether they, e.g., snap to
+/// weeks.
+abstract class VisibleDateRange {
+ const VisibleDateRange({
+ required this.visibleDayCount,
+ required this.canScroll,
+ }) : assert(visibleDayCount > 0);
+
+ /// A visible range that shows [visibleDayCount] consecutive days.
+ ///
+ /// This range snapps to every `swipeRange` days (defaults to every day) that
+ /// are aligned to `alignmentDate` (defaults to today).
+ ///
+ /// When set, swiping is limited from `minDate` to `maxDate` so that both can
+ /// still be seen.
+ factory VisibleDateRange.days(
+ int visibleDayCount, {
+ int swipeRange,
+ DateTime? alignmentDate,
+ DateTime? minDate,
+ DateTime? maxDate,
+ }) = DaysVisibleDateRange;
+
+ /// A visible range that shows seven consecutive days, aligned to
+ /// [startOfWeek].
+ ///
+ /// When set, swiping is limited from `minDate` to `maxDate` so that both can
+ /// still be seen.
+ factory VisibleDateRange.week({
+ int startOfWeek = DateTime.monday,
+ DateTime? minDate,
+ DateTime? maxDate,
+ }) {
+ return VisibleDateRange.weekAligned(
+ DateTime.daysPerWeek,
+ firstDay: startOfWeek,
+ minDate: minDate,
+ maxDate: maxDate,
+ );
+ }
+
+ /// A visible range that shows [visibleDayCount] consecutive days, aligned to
+ /// [firstDay].
+ ///
+ /// When set, swiping is limited from `minDate` to `maxDate` so that both can
+ /// still be seen.
+ factory VisibleDateRange.weekAligned(
+ int visibleDayCount, {
+ int firstDay = DateTime.monday,
+ DateTime? minDate,
+ DateTime? maxDate,
+ }) {
+ return VisibleDateRange.days(
+ visibleDayCount,
+ swipeRange: DateTime.daysPerWeek,
+ // This just has to be any date fitting `firstDay`. The addition results
+ // in a correct value because 2021-01-03 was a Sunday and
+ // `DateTime.monday = 1`.
+ alignmentDate: DateTimeTimetable.date(2021, 1, 3) + firstDay.days,
+ minDate: minDate,
+ maxDate: maxDate,
+ );
+ }
+
+ /// A non-scrollable visible range.
+ ///
+ /// This is useful for, e.g., [RecurringMultiDateTimetable].
+ factory VisibleDateRange.fixed(DateTime startDate, int visibleDayCount) =>
+ FixedDaysVisibleDateRange(startDate, visibleDayCount);
+
+ final int visibleDayCount;
+ final bool canScroll;
+
+ double getTargetPageForFocus(double focusPage);
+
+ double getTargetPageForCurrent(
+ double currentPage, {
+ double velocity = 0,
+ Tolerance tolerance = Tolerance.defaultTolerance,
+ });
+
+ double applyBoundaryConditions(double page) {
+ if (!canScroll) {
+ throw StateError(
+ 'A non-scrollable `$runtimeType` was used in a scrollable view.',
+ );
+ }
+ return 0;
+ }
+}
+
+/// The implementation for [VisibleDateRange.days], [VisibleDateRange.week], and
+/// [VisibleDateRange.weekAligned].
+class DaysVisibleDateRange extends VisibleDateRange {
+ DaysVisibleDateRange(
+ int visibleDayCount, {
+ this.swipeRange = 1,
+ DateTime? alignmentDate,
+ this.minDate,
+ this.maxDate,
+ }) : alignmentDate = alignmentDate ?? DateTimeTimetable.today(),
+ assert(minDate.isValidTimetableDate),
+ assert(maxDate.isValidTimetableDate),
+ assert(minDate == null || maxDate == null || minDate <= maxDate),
+ super(visibleDayCount: visibleDayCount, canScroll: true) {
+ minPage = minDate == null ? null : getTargetPageForFocus(minDate!.page);
+ maxPage = maxDate == null
+ ? null
+ : _getMinimumPageForFocus(maxDate!.page)
+ .coerceAtLeast(minPage ?? double.negativeInfinity);
+ }
+
+ final int swipeRange;
+ final DateTime alignmentDate;
+
+ final DateTime? minDate;
+ late final double? minPage;
+ final DateTime? maxDate;
+ late final double? maxPage;
+
+ @override
+ double getTargetPageForFocus(
+ double focusPage, {
+ double velocity = 0,
+ Tolerance tolerance = Tolerance.defaultTolerance,
+ }) {
+ // Taken from [_InteractiveViewerState._kDrag].
+ const _kDrag = 0.0000135;
+ final simulation =
+ FrictionSimulation(_kDrag, focusPage, velocity, tolerance: tolerance);
+ final targetFocusPage = simulation.finalX;
+
+ final alignmentOffset = alignmentDate.datePage % swipeRange;
+ final alignmentDifference =
+ (targetFocusPage.floor() - alignmentDate.datePage) % swipeRange;
+ final alignmentCorrectedTargetPage = targetFocusPage - alignmentDifference;
+ final swipeAlignedTargetPage =
+ (alignmentCorrectedTargetPage / swipeRange).floor() * swipeRange;
+ return (alignmentOffset + swipeAlignedTargetPage).toDouble();
+ }
+
+ double _getMinimumPageForFocus(double focusPage) {
+ var page = focusPage;
+ while (true) {
+ final target = getTargetPageForFocus(page);
+ if (target + visibleDayCount > page) return target;
+ page -= swipeRange;
+ }
+ }
+
+ @override
+ double getTargetPageForCurrent(
+ double currentPage, {
+ double velocity = 0,
+ Tolerance tolerance = Tolerance.defaultTolerance,
+ }) {
+ return getTargetPageForFocus(
+ currentPage + swipeRange / 2,
+ velocity: velocity,
+ tolerance: tolerance,
+ );
+ }
+
+ @override
+ double applyBoundaryConditions(double page) {
+ final targetPage = page.coerceIn(
+ minPage ?? double.negativeInfinity,
+ maxPage ?? double.infinity,
+ );
+ return page - targetPage;
+ }
+}
+
+/// A non-scrollable [VisibleDateRange], used by [VisibleDateRange.fixed].
+///
+/// This is useful for, e.g., [RecurringMultiDateTimetable].
+class FixedDaysVisibleDateRange extends VisibleDateRange {
+ FixedDaysVisibleDateRange(
+ this.startDate,
+ int visibleDayCount,
+ ) : assert(startDate.isValidTimetableDate),
+ super(visibleDayCount: visibleDayCount, canScroll: false);
+
+ final DateTime startDate;
+ double get page => startDate.page;
+
+ @override
+ double getTargetPageForFocus(
+ double focusPage, {
+ double velocity = 0,
+ Tolerance tolerance = Tolerance.defaultTolerance,
+ }) =>
+ page;
+
+ @override
+ double getTargetPageForCurrent(
+ double currentPage, {
+ double velocity = 0,
+ Tolerance tolerance = Tolerance.defaultTolerance,
+ }) =>
+ page;
+}
diff --git a/lib/src/date_page_view.dart b/lib/src/date_page_view.dart
deleted file mode 100644
index 9a1a032..0000000
--- a/lib/src/date_page_view.dart
+++ /dev/null
@@ -1,70 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:time_machine/time_machine.dart';
-
-import 'controller.dart';
-import 'event.dart';
-import 'scroll_physics.dart';
-
-typedef DateWidgetBuilder = Widget Function(
- BuildContext context, LocalDate date);
-
-class DatePageView extends StatefulWidget {
- const DatePageView({
- Key key,
- @required this.controller,
- @required this.builder,
- }) : assert(controller != null),
- assert(builder != null),
- super(key: key);
-
- final TimetableController controller;
- final DateWidgetBuilder builder;
-
- @override
- _DatePageViewState createState() => _DatePageViewState();
-}
-
-class _DatePageViewState extends State {
- ScrollController _controller;
-
- @override
- void initState() {
- super.initState();
- _controller = widget.controller.scrollControllers.addAndGet();
- }
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final visibleDays = widget.controller.visibleRange.visibleDays;
-
- return Scrollable(
- axisDirection: AxisDirection.right,
- physics: TimetableScrollPhysics(widget.controller),
- controller: _controller,
- viewportBuilder: (context, position) {
- return Viewport(
- axisDirection: AxisDirection.right,
- offset: position,
- anchor: 0,
- slivers: [
- SliverFillViewport(
- viewportFraction: 1 / visibleDays,
- delegate: SliverChildBuilderDelegate(
- (context, index) => widget.builder(
- context,
- LocalDate.fromEpochDay(index + visibleDays ~/ 2),
- ),
- ),
- ),
- ],
- );
- },
- );
- }
-}
diff --git a/lib/src/event.dart b/lib/src/event.dart
deleted file mode 100644
index eaf38e2..0000000
--- a/lib/src/event.dart
+++ /dev/null
@@ -1,82 +0,0 @@
-import 'dart:ui';
-
-import 'package:dartx/dartx.dart';
-import 'package:meta/meta.dart';
-import 'package:time_machine/time_machine.dart';
-
-import 'basic.dart';
-
-/// The base class of all events.
-///
-/// See also:
-/// - [BasicEvent], which provides a basic implementation to get you started.
-abstract class Event {
- const Event({
- @required this.id,
- @required this.start,
- @required this.end,
- }) : assert(id != null),
- assert(start != null),
- assert(end != null),
- assert(start <= end);
-
- /// A unique ID, used e.g. for animating events.
- final Object id;
-
- /// Start of the event.
- final LocalDateTime start;
-
- // End of the event; exclusive.
- final LocalDateTime end;
-
- bool get isAllDay => start.periodUntil(end).normalize().days >= 1;
- bool get isPartDay => !isAllDay;
-
- @override
- bool operator ==(dynamic other) {
- return runtimeType == other.runtimeType &&
- id == other.id &&
- start == other.start &&
- end == other.end;
- }
-
- @override
- int get hashCode => hashList([runtimeType, id, start, end]);
-
- @override
- String toString() => id.toString();
-}
-
-extension TimetableEvent on Event {
- bool intersectsDate(LocalDate date) =>
- intersectsInterval(DateInterval(date, date));
-
- bool intersectsInterval(DateInterval interval) {
- return start.calendarDate <= interval.end &&
- endDateInclusive >= interval.start;
- }
-
- LocalDate get endDateInclusive {
- if (start.calendarDate == end.calendarDate) {
- return end.calendarDate;
- }
-
- return (end - Period(nanoseconds: 1)).calendarDate;
- }
-
- DateInterval get intersectingDates =>
- DateInterval(start.calendarDate, endDateInclusive);
-}
-
-extension TimetableEventIterable on Iterable {
- Iterable get allDayEvents => where((e) => e.isAllDay);
- Iterable get partDayEvents => where((e) => e.isPartDay);
-
- Iterable intersectingInterval(DateInterval interval) =>
- where((e) => e.intersectsInterval(interval));
- Iterable intersectingDate(LocalDate date) =>
- where((e) => e.intersectsDate(date));
-
- List sortedByStartLength() =>
- sortedBy((e) => e.start).thenByDescending((e) => e.end);
-}
diff --git a/lib/src/event/all_day.dart b/lib/src/event/all_day.dart
new file mode 100644
index 0000000..497c8a7
--- /dev/null
+++ b/lib/src/event/all_day.dart
@@ -0,0 +1,268 @@
+import 'dart:math' as math;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+import 'package:meta/meta.dart';
+
+import '../utils.dart';
+import 'event.dart';
+
+typedef AllDayEventBuilder = Widget Function(
+ BuildContext context,
+ E event,
+ AllDayEventLayoutInfo info,
+);
+
+/// Information about how an all-day event was laid out.
+@immutable
+class AllDayEventLayoutInfo {
+ const AllDayEventLayoutInfo({
+ required this.hiddenStartDays,
+ required this.hiddenEndDays,
+ }) : assert(hiddenStartDays >= 0),
+ assert(hiddenEndDays >= 0);
+
+ /// How many days of this event are hidden before the viewport starts.
+ final double hiddenStartDays;
+
+ /// How many days of this event are hidden after the viewport ends.
+ final double hiddenEndDays;
+
+ @override
+ bool operator ==(dynamic other) {
+ return other is AllDayEventLayoutInfo &&
+ hiddenStartDays == other.hiddenStartDays &&
+ hiddenEndDays == other.hiddenEndDays;
+ }
+
+ @override
+ int get hashCode => hashValues(hiddenStartDays, hiddenEndDays);
+}
+
+class AllDayEventBackgroundPainter extends CustomPainter {
+ AllDayEventBackgroundPainter({
+ required this.info,
+ required this.color,
+ required this.radii,
+ }) : _paint = Paint()..color = color;
+
+ final AllDayEventLayoutInfo info;
+ final Color color;
+ final AllDayEventBorderRadii radii;
+ final Paint _paint;
+
+ @override
+ void paint(Canvas canvas, Size size) =>
+ canvas.drawPath(radii.getPath(size, info), _paint);
+
+ @override
+ bool shouldRepaint(covariant AllDayEventBackgroundPainter oldDelegate) {
+ return info != oldDelegate.info ||
+ color != oldDelegate.color ||
+ radii != oldDelegate.radii;
+ }
+}
+
+/// A modified [RoundedRectangleBorder] that morphs to triangular left and/or
+/// right borders if not all of the event is currently visible.
+class AllDayEventBorder extends ShapeBorder {
+ const AllDayEventBorder({
+ required this.info,
+ this.side = BorderSide.none,
+ required this.radii,
+ });
+
+ final AllDayEventLayoutInfo info;
+ final BorderSide side;
+ final AllDayEventBorderRadii radii;
+
+ @override
+ EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width);
+
+ @override
+ ShapeBorder scale(double t) {
+ return AllDayEventBorder(
+ info: info,
+ side: side.scale(t),
+ radii: AllDayEventBorderRadii(
+ cornerRadius: radii.cornerRadius * t,
+ leftTipRadius: radii.leftTipRadius * t,
+ rightTipRadius: radii.rightTipRadius * t,
+ ),
+ );
+ }
+
+ @override
+ Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
+ return radii
+ .getPath(rect.deflate(side.width).size, info)
+ .shift(Offset(side.width, side.width));
+ }
+
+ @override
+ Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
+ radii.getPath(rect.size, info);
+
+ @override
+ void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
+ // For some reason, when we paint the background in this shape directly, it
+ // lags while scrolling. Hence, we only use it to provide the outer path
+ // used for clipping.
+ }
+
+ @override
+ int get hashCode => hashValues(info, side, radii);
+ @override
+ bool operator ==(Object other) {
+ if (other.runtimeType != runtimeType) return false;
+ return other is AllDayEventBorder &&
+ other.info == info &&
+ other.side == side &&
+ other.radii == radii;
+ }
+
+ @override
+ String toString() =>
+ '${objectRuntimeType(this, 'RoundedRectangleBorder')}($side, $radii)';
+}
+
+@immutable
+class AllDayEventBorderRadii {
+ const AllDayEventBorderRadii({
+ required this.cornerRadius,
+ required this.leftTipRadius,
+ required this.rightTipRadius,
+ });
+
+ final BorderRadius cornerRadius;
+ final double leftTipRadius;
+ final double rightTipRadius;
+
+ Path getPath(Size size, AllDayEventLayoutInfo info) {
+ final maxTipWidth = size.height / 4;
+ final leftTipWidth = info.hiddenStartDays.coerceAtMost(1) * maxTipWidth;
+ final rightTipWidth = info.hiddenEndDays.coerceAtMost(1) * maxTipWidth;
+
+ final maximumRadius = size.height / 2;
+ final radii = AllDayEventBorderRadii(
+ cornerRadius: BorderRadius.only(
+ topLeft: Radius.elliptical(
+ cornerRadius.topLeft.x,
+ cornerRadius.topLeft.y.coerceAtMost(maximumRadius),
+ ),
+ bottomLeft: Radius.elliptical(
+ cornerRadius.bottomLeft.x,
+ cornerRadius.bottomLeft.y.coerceAtMost(maximumRadius),
+ ),
+ topRight: Radius.elliptical(
+ cornerRadius.topRight.x,
+ cornerRadius.topRight.y.coerceAtMost(maximumRadius),
+ ),
+ bottomRight: Radius.elliptical(
+ cornerRadius.bottomRight.x,
+ cornerRadius.bottomRight.y.coerceAtMost(maximumRadius),
+ ),
+ ),
+ leftTipRadius: leftTipRadius.coerceAtMost(maximumRadius),
+ rightTipRadius: rightTipRadius.coerceAtMost(maximumRadius),
+ );
+
+ final minWidth = radii.leftTipRadius +
+ leftTipWidth +
+ radii.rightTipRadius +
+ rightTipWidth +
+ math.min(
+ radii.cornerRadius.topLeft.x + radii.cornerRadius.topRight.x,
+ radii.cornerRadius.bottomLeft.x + radii.cornerRadius.bottomRight.x,
+ );
+
+ // ignore: omit_local_variable_types
+ final double left =
+ info.hiddenStartDays == 0 ? 0 : math.min(0, size.width - minWidth);
+ // ignore: omit_local_variable_types
+ final double right =
+ info.hiddenEndDays == 0 ? size.width : math.max(size.width, minWidth);
+
+ // no tip: 0 ≈ 0°
+ // full tip: PI / 4 ≈ 45°
+ final leftTipAngle =
+ math.pi / 2 - math.atan2(size.height / 2, leftTipWidth);
+ final rightTipAngle =
+ math.pi / 2 - math.atan2(size.height / 2, rightTipWidth);
+
+ Size toSize(Radius radius) => Size(radius.x, radius.y) * 2;
+
+ final topLeftTipBase = left + leftTipWidth + radii.cornerRadius.topLeft.x;
+
+ return Path()
+ ..moveTo(topLeftTipBase, 0)
+ // Right top
+ ..arcTo(
+ Offset(right - rightTipWidth - radii.cornerRadius.topRight.x * 2, 0) &
+ toSize(radii.cornerRadius.topRight),
+ math.pi * 3 / 2,
+ math.pi / 2 - rightTipAngle,
+ false,
+ )
+ // Right tip
+ ..arcTo(
+ Offset(
+ right - radii.rightTipRadius * 2,
+ size.height / 2 - radii.rightTipRadius,
+ ) &
+ Size.square(radii.rightTipRadius * 2),
+ -rightTipAngle,
+ 2 * rightTipAngle,
+ false,
+ )
+ // Right bottom
+ ..arcTo(
+ Offset(
+ right - rightTipWidth - radii.cornerRadius.bottomRight.x * 2,
+ size.height - radii.cornerRadius.bottomRight.y * 2,
+ ) &
+ toSize(radii.cornerRadius.bottomRight),
+ rightTipAngle,
+ math.pi / 2 - rightTipAngle,
+ false,
+ )
+ // Left bottom
+ ..arcTo(
+ Offset(
+ left + leftTipWidth,
+ size.height - radii.cornerRadius.bottomLeft.y * 2,
+ ) &
+ toSize(radii.cornerRadius.bottomLeft),
+ math.pi / 2,
+ math.pi / 2 - leftTipAngle,
+ false,
+ )
+ // Left tip
+ ..arcTo(
+ Offset(left, size.height / 2 - radii.leftTipRadius) &
+ Size.square(radii.leftTipRadius * 2),
+ math.pi - leftTipAngle,
+ 2 * leftTipAngle,
+ false,
+ )
+ // Left top
+ ..arcTo(
+ Offset(topLeftTipBase - radii.cornerRadius.topLeft.x, 0) &
+ toSize(radii.cornerRadius.topLeft),
+ math.pi + leftTipAngle,
+ math.pi / 2 - leftTipAngle,
+ false,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(cornerRadius, leftTipRadius, rightTipRadius);
+ @override
+ bool operator ==(Object other) {
+ return other is AllDayEventBorderRadii &&
+ cornerRadius == other.cornerRadius &&
+ leftTipRadius == other.leftTipRadius &&
+ rightTipRadius == other.rightTipRadius;
+ }
+}
diff --git a/lib/src/event/basic.dart b/lib/src/event/basic.dart
new file mode 100644
index 0000000..08d9974
--- /dev/null
+++ b/lib/src/event/basic.dart
@@ -0,0 +1,234 @@
+import 'package:black_hole_flutter/black_hole_flutter.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import 'all_day.dart';
+import 'event.dart';
+
+/// A basic implementation of [Event] to get you started.
+///
+/// See also:
+///
+/// * [BasicEventWidget], which can display instances of [BasicEvent].
+class BasicEvent extends Event {
+ const BasicEvent({
+ required this.id,
+ required this.title,
+ required this.backgroundColor,
+ required DateTime start,
+ required DateTime end,
+ }) : super(start: start, end: end);
+
+ /// An ID for this event.
+ ///
+ /// This is not used by Timetable itself, but can be handy, e.g., when
+ /// implementing drag & drop.
+ final Object id;
+
+ /// A title displayed to the user.
+ ///
+ /// This is currently used by [BasicEventWidget] and [BasicAllDayEventWidget].
+ final String title;
+
+ /// The background color used for displaying this event.
+ ///
+ /// This is currently used by [BasicEventWidget] and [BasicAllDayEventWidget].
+ final Color backgroundColor;
+
+ BasicEvent copyWith({
+ Object? id,
+ String? title,
+ Color? backgroundColor,
+ bool? showOnTop,
+ DateTime? start,
+ DateTime? end,
+ }) {
+ return BasicEvent(
+ id: id ?? this.id,
+ title: title ?? this.title,
+ backgroundColor: backgroundColor ?? this.backgroundColor,
+ start: start ?? this.start,
+ end: end ?? this.end,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(super.hashCode, title, backgroundColor);
+ @override
+ bool operator ==(dynamic other) =>
+ super == other &&
+ title == other.title &&
+ backgroundColor == other.backgroundColor;
+}
+
+/// A simple [Widget] for displaying a [BasicEvent].
+class BasicEventWidget extends StatelessWidget {
+ const BasicEventWidget(
+ this.event, {
+ Key? key,
+ this.onTap,
+ this.margin = const EdgeInsets.only(right: 1),
+ }) : super(key: key);
+
+ /// The event to be displayed.
+ final BasicEvent event;
+
+ /// An optional callback that will be invoked when the user taps this widget.
+ final VoidCallback? onTap;
+
+ final EdgeInsetsGeometry margin;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: margin,
+ child: Material(
+ shape: RoundedRectangleBorder(
+ side: BorderSide(
+ color: context.theme.scaffoldBackgroundColor,
+ width: 0.75,
+ ),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ clipBehavior: Clip.hardEdge,
+ color: event.backgroundColor,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(4, 2, 4, 0),
+ child: DefaultTextStyle(
+ style: context.textTheme.bodyText2!.copyWith(
+ fontSize: 12,
+ color: event.backgroundColor.highEmphasisOnColor,
+ ),
+ child: Text(event.title),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// A simple [Widget] for displaying a [BasicEvent] as an all-day event.
+class BasicAllDayEventWidget extends StatelessWidget {
+ const BasicAllDayEventWidget(
+ this.event, {
+ Key? key,
+ required this.info,
+ this.onTap,
+ this.style,
+ }) : super(key: key);
+
+ /// The event to be displayed.
+ final BasicEvent event;
+ final AllDayEventLayoutInfo info;
+
+ /// An optional callback that will be invoked when the user taps this widget.
+ final VoidCallback? onTap;
+ final BasicAllDayEventWidgetStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ?? BasicAllDayEventWidgetStyle(context, event);
+
+ return Padding(
+ padding: style.margin,
+ child: CustomPaint(
+ painter: AllDayEventBackgroundPainter(
+ info: info,
+ color: event.backgroundColor,
+ radii: style.radii,
+ ),
+ child: Material(
+ shape: AllDayEventBorder(
+ info: info,
+ side: BorderSide.none,
+ radii: style.radii,
+ ),
+ clipBehavior: Clip.antiAlias,
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: style.padding,
+ child: Text(
+ event.title,
+ style: style.textStyle,
+ maxLines: 1,
+ overflow: TextOverflow.visible,
+ softWrap: false,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Defines visual properties for [BasicAllDayEventWidget].
+@immutable
+class BasicAllDayEventWidgetStyle {
+ factory BasicAllDayEventWidgetStyle(
+ BuildContext context,
+ BasicEvent event, {
+ EdgeInsetsGeometry? margin,
+ AllDayEventBorderRadii? radii,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ }) {
+ return BasicAllDayEventWidgetStyle.raw(
+ margin: margin ?? EdgeInsets.all(2),
+ radii: radii ??
+ AllDayEventBorderRadii(
+ cornerRadius: BorderRadius.circular(4),
+ leftTipRadius: 4,
+ rightTipRadius: 4,
+ ),
+ padding: padding ?? EdgeInsets.fromLTRB(4, 2, 0, 2),
+ textStyle: textStyle ??
+ context.theme.textTheme.bodyText2!.copyWith(
+ fontSize: 14,
+ color: event.backgroundColor.highEmphasisOnColor,
+ ),
+ );
+ }
+
+ const BasicAllDayEventWidgetStyle.raw({
+ required this.margin,
+ required this.radii,
+ required this.padding,
+ required this.textStyle,
+ });
+
+ final EdgeInsetsGeometry margin;
+ final AllDayEventBorderRadii radii;
+ final EdgeInsetsGeometry padding;
+ final TextStyle textStyle;
+
+ BasicAllDayEventWidgetStyle copyWith({
+ EdgeInsetsGeometry? margin,
+ AllDayEventBorderRadii? radii,
+ EdgeInsetsGeometry? padding,
+ TextStyle? textStyle,
+ }) {
+ return BasicAllDayEventWidgetStyle.raw(
+ margin: margin ?? this.margin,
+ radii: radii ?? this.radii,
+ padding: padding ?? this.padding,
+ textStyle: textStyle ?? this.textStyle,
+ );
+ }
+
+ @override
+ int get hashCode => hashValues(margin, radii, padding, textStyle);
+ @override
+ bool operator ==(Object other) {
+ return other is BasicAllDayEventWidgetStyle &&
+ margin == other.margin &&
+ radii == other.radii &&
+ padding == other.padding &&
+ textStyle == other.textStyle;
+ }
+}
diff --git a/lib/src/event/builder.dart b/lib/src/event/builder.dart
new file mode 100644
index 0000000..8252e0e
--- /dev/null
+++ b/lib/src/event/builder.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/widgets.dart' hide Interval;
+
+import 'all_day.dart';
+import 'event.dart';
+
+typedef EventBuilder = Widget Function(
+ BuildContext context,
+ E event,
+);
+
+class DefaultEventBuilder extends InheritedWidget {
+ DefaultEventBuilder({
+ required this.builder,
+ AllDayEventBuilder? allDayBuilder,
+ required Widget child,
+ }) : allDayBuilder =
+ allDayBuilder ?? ((context, event, _) => builder(context, event)),
+ super(child: child);
+
+ final EventBuilder builder;
+ final AllDayEventBuilder allDayBuilder;
+
+ @override
+ bool updateShouldNotify(DefaultEventBuilder oldWidget) =>
+ builder != oldWidget.builder || allDayBuilder != oldWidget.allDayBuilder;
+
+ static EventBuilder? of(BuildContext context) {
+ return context
+ .dependOnInheritedWidgetOfExactType>()
+ ?.builder;
+ }
+
+ static AllDayEventBuilder? allDayOf(
+ BuildContext context,
+ ) {
+ return context
+ .dependOnInheritedWidgetOfExactType>()
+ ?.allDayBuilder;
+ }
+}
diff --git a/lib/src/event/event.dart b/lib/src/event/event.dart
new file mode 100644
index 0000000..7252d77
--- /dev/null
+++ b/lib/src/event/event.dart
@@ -0,0 +1,63 @@
+import 'dart:ui';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart' hide Interval;
+
+import '../utils.dart';
+import 'basic.dart';
+
+/// The base class of all events.
+///
+/// See also:
+///
+/// * [BasicEvent], which provides a basic implementation to get you started.
+abstract class Event with Diagnosticable {
+ const Event({
+ required this.start,
+ required this.end,
+ }) : assert(start <= end);
+
+ /// Start of the event; inclusive.
+ final DateTime start;
+
+ /// End of the event; exclusive.
+ final DateTime end;
+
+ bool get isAllDay => end.difference(start).inDays >= 1;
+
+ @override
+ bool operator ==(dynamic other) {
+ return runtimeType == other.runtimeType &&
+ start == other.start &&
+ end == other.end;
+ }
+
+ @override
+ int get hashCode => hashValues(runtimeType, start, end);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('start', start));
+ properties.add(DiagnosticsProperty('end', end));
+ }
+}
+
+extension EventExtension on Event {
+ DateTime get endInclusive => start == end ? end : end - 1.milliseconds;
+ Interval get interval => Interval(start, endInclusive);
+ Duration get duration => end.difference(start);
+
+ bool get isPartDay => !isAllDay;
+}
+
+extension TimetableEventIterable on Iterable {
+ List sortedByStartLength() {
+ return sorted((a, b) {
+ final result = a.start.compareTo(b.start);
+ if (result != 0) return result;
+ return a.end.compareTo(b.end);
+ });
+ }
+}
diff --git a/lib/src/event/provider.dart b/lib/src/event/provider.dart
new file mode 100644
index 0000000..82b569f
--- /dev/null
+++ b/lib/src/event/provider.dart
@@ -0,0 +1,96 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart' hide Interval;
+
+import '../utils.dart';
+import 'event.dart';
+
+/// Provides [Event]s to Timetable widgets.
+///
+/// [EventProvider]s may only return events that intersect the given
+/// [visibleRange].
+///
+/// See also:
+///
+/// * [eventProviderFromFixedList], which creates an [EventProvider] from a
+/// fixed list of events.
+/// * [mergeEventProviders], which merges multiple [EventProvider]s.
+/// * [DefaultEventProvider], which provides [EventProvider]s to Timetable
+/// widgets below it.
+typedef EventProvider = List Function(
+ Interval visibleRange,
+);
+
+EventProvider eventProviderFromFixedList(List events) {
+ return (visibleRange) =>
+ events.where((it) => it.interval.intersects(visibleRange)).toList();
+}
+
+EventProvider mergeEventProviders(
+ List> eventProviders,
+) {
+ return (visibleRange) =>
+ eventProviders.expand((it) => it(visibleRange)).toList();
+}
+
+extension EventProviderTimetable on EventProvider {
+ EventProvider get debugChecked {
+ return (visibleRange) {
+ final events = this(visibleRange);
+ assert(() {
+ final invalidEvents = events
+ .where((it) => !it.interval.intersects(visibleRange))
+ .toList();
+ if (invalidEvents.isNotEmpty) {
+ throw FlutterError.fromParts([
+ ErrorSummary(
+ 'EventProvider returned events not intersecting the provided '
+ 'visible range.',
+ ),
+ ErrorDescription(
+ 'For the visible range ${visibleRange.start} – ${visibleRange.end}, '
+ "${invalidEvents.length} out of ${events.length} events don't "
+ 'intersect this range: $invalidEvents',
+ ),
+ ErrorDescription(
+ "This property is enforced so that you don't accidentally, e.g., "
+ 'load thousands of events spread over multiple years when only a '
+ 'single week is visible.',
+ ),
+ ErrorHint(
+ 'If you only have a fixed list of events, use '
+ '`eventProviderFromFixedList(myListOfEvents)`.',
+ ),
+ ]);
+ }
+ return true;
+ }());
+
+ assert(
+ events.toSet().length == events.length,
+ 'Events may not contain duplicates.',
+ );
+
+ return events;
+ };
+ }
+}
+
+class DefaultEventProvider extends InheritedWidget {
+ DefaultEventProvider({
+ required EventProvider eventProvider,
+ required Widget child,
+ }) : eventProvider = eventProvider.debugChecked,
+ super(child: child);
+
+ final EventProvider eventProvider;
+
+ @override
+ bool updateShouldNotify(DefaultEventProvider oldWidget) =>
+ eventProvider != oldWidget.eventProvider;
+
+ static EventProvider? of(BuildContext context) {
+ return context
+ .dependOnInheritedWidgetOfExactType>()
+ ?.eventProvider;
+ }
+}
diff --git a/lib/src/event_provider.dart b/lib/src/event_provider.dart
deleted file mode 100644
index f660d42..0000000
--- a/lib/src/event_provider.dart
+++ /dev/null
@@ -1,157 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter/foundation.dart';
-import 'package:rxdart/rxdart.dart';
-import 'package:time_machine/time_machine.dart';
-
-import 'controller.dart';
-import 'event.dart';
-
-/// Provides [Event]s to a [TimetableController].
-///
-/// We provide the following implementations:
-/// - [EventProvider.list], if you have a non-changing list of [Event]s.
-/// - [EventProvider.simpleStream], if you have a changing list of [Event]s.
-/// - [EventProvider.stream], if your events may change or you have many events
-/// and only want to load a relevant subset.
-abstract class EventProvider {
- const EventProvider();
-
- /// Creates an [EventProvider] based on a fixed list of [Event]s.
- ///
- /// See also:
- /// - [EventProvider]'s class comment for an overview of provided
- /// implementations.
- factory EventProvider.list(List events) = ListEventProvider;
-
- /// Creates an [EventProvider] accepting a [Stream] of [Event]s.
- ///
- /// See also:
- /// - [EventProvider]'s class comment for an overview of provided
- /// implementations.
- factory EventProvider.simpleStream(Stream> eventStream) {
- assert(eventStream != null);
-
- final baseStream = eventStream.publishValue();
- final subscription = baseStream.connect();
- return EventProvider.stream(
- eventGetter: (dates) {
- return baseStream.map((e) {
- return e.intersectingInterval(dates);
- });
- },
- onDispose: subscription.cancel,
- );
- }
-
- /// Creates an [EventProvider] accepting a [Stream] of [Event]s based on the
- /// currently visible range.
- ///
- /// See also:
- /// - [EventProvider]'s class comment for an overview of provided
- /// implementations.
- factory EventProvider.stream({
- @required StreamedEventGetter eventGetter,
- VoidCallback onDispose,
- }) = StreamEventProvider;
-
- void onVisibleDatesChanged(DateInterval visibleRange) {}
-
- Stream> getAllDayEventsIntersecting(DateInterval interval);
- Stream> getPartDayEventsIntersecting(LocalDate date);
-
- /// Discards any resources used by the object.
- ///
- /// After this is called, the object is not in a usable state and should be
- /// discarded.
- ///
- /// This method is usually called by [TimetableController].
- void dispose() {}
-}
-
-/// An [EventProvider] accepting a single, non-changing list of [Event]s.
-///
-/// See also:
-/// - [EventProvider.simpleStream], if you have a few events, but they may
-/// change.
-/// - [EventProvider.stream], if your events change or you have lots of them.
-class ListEventProvider extends EventProvider {
- ListEventProvider(List events)
- : assert(events != null),
- _events = events;
-
- final List _events;
-
- @override
- Stream> getAllDayEventsIntersecting(DateInterval interval) {
- final events = _events.allDayEvents.intersectingInterval(interval);
- return Stream.value(events);
- }
-
- @override
- Stream> getPartDayEventsIntersecting(LocalDate date) {
- final events = _events.partDayEvents.intersectingDate(date);
- return Stream.value(events);
- }
-}
-
-mixin VisibleDatesStreamEventProviderMixin
- on EventProvider {
- final _visibleDates = BehaviorSubject();
- ValueStream get visibleDates => _visibleDates.stream;
-
- @mustCallSuper
- @override
- void onVisibleDatesChanged(DateInterval visibleRange) {
- _visibleDates.add(visibleRange);
- }
-
- @mustCallSuper
- @override
- void dispose() {
- _visibleDates.close();
- }
-}
-
-typedef StreamedEventGetter = Stream> Function(
- DateInterval dates);
-
-/// An [EventProvider] accepting a [Stream] of [Event]s based on the currently
-/// visible range.
-///
-/// See also:
-/// - [EventProvider.list], if you only have a few static [Event]s.
-/// - [EventProvider.simpleStream], if you only have a few events that may
-/// change.
-class StreamEventProvider extends EventProvider
- with VisibleDatesStreamEventProviderMixin {
- StreamEventProvider({@required this.eventGetter, this.onDispose})
- : assert(eventGetter != null) {
- _events = visibleDates.switchMap(eventGetter).publishValue();
- _eventsSubscription = _events.connect();
- }
-
- final StreamedEventGetter eventGetter;
- final VoidCallback onDispose;
-
- ValueConnectableStream> _events;
- StreamSubscription> _eventsSubscription;
-
- @override
- Stream> getAllDayEventsIntersecting(DateInterval interval) {
- return _events
- .map((events) => events.allDayEvents.intersectingInterval(interval));
- }
-
- @override
- Stream> getPartDayEventsIntersecting(LocalDate date) {
- return _events.map((events) => events.partDayEvents.intersectingDate(date));
- }
-
- @override
- void dispose() {
- _eventsSubscription.cancel();
- onDispose?.call();
- super.dispose();
- }
-}
diff --git a/lib/src/header/all_day_events.dart b/lib/src/header/all_day_events.dart
deleted file mode 100644
index 6ea595a..0000000
--- a/lib/src/header/all_day_events.dart
+++ /dev/null
@@ -1,428 +0,0 @@
-import 'dart:math' as math;
-import 'dart:ui';
-
-import 'package:black_hole_flutter/black_hole_flutter.dart';
-import 'package:dartx/dartx.dart';
-import 'package:flutter/rendering.dart';
-import 'package:flutter/widgets.dart';
-import 'package:time_machine/time_machine.dart' hide Offset;
-
-import '../all_day.dart';
-import '../controller.dart';
-import '../event.dart';
-import '../theme.dart';
-import '../timetable.dart';
-import '../visible_range.dart';
-
-class AllDayEvents extends StatelessWidget {
- const AllDayEvents({
- Key key,
- @required this.controller,
- @required this.allDayEventBuilder,
- this.onEventBackgroundTap,
- }) : assert(controller != null),
- assert(allDayEventBuilder != null),
- super(key: key);
-
- final TimetableController controller;
- final AllDayEventBuilder allDayEventBuilder;
- final OnEventBackgroundTapCallback onEventBackgroundTap;
-
- @override
- Widget build(BuildContext context) {
- return ClipRect(
- child: LayoutBuilder(
- builder: (context, constraints) {
- return ValueListenableBuilder