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 +Drag and Drop demo -[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 +Drag and Drop demo -- [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( - valueListenable: controller.currentlyVisibleDatesListenable, - builder: (_, visibleDates, __) { - return StreamBuilder>( - stream: controller.eventProvider - .getAllDayEventsIntersecting(visibleDates), - builder: (_, snapshot) { - var events = snapshot.data ?? []; - // The StreamBuilder gets recycled and initially still has a list of - // old events. - events = - events.where((e) => e.intersectsInterval(visibleDates)); - - return ValueListenableBuilder( - valueListenable: - controller.scrollControllers.pageListenable, - builder: (context, page, __) => GestureDetector( - behavior: HitTestBehavior.translucent, - onTapUp: onEventBackgroundTap != null - ? (details) { - _callOnAllDayEventBackgroundTap( - details, page, constraints); - } - : null, - child: _buildEventLayout(context, events, page), - ), - ); - }, - ); - }, - ); - }, - ), - ); - } - - void _callOnAllDayEventBackgroundTap( - TapUpDetails details, - double page, - BoxConstraints constraints, - ) { - final tappedCell = details.localPosition.dx / - (constraints.maxWidth / controller.visibleRange.visibleDays); - final date = LocalDate.fromEpochDay((page + tappedCell).floor()); - onEventBackgroundTap(date.atMidnight(), true); - } - - Widget _buildEventLayout( - BuildContext context, - Iterable events, - double page, - ) { - return _EventsWidget( - visibleRange: controller.visibleRange, - currentlyVisibleDates: controller.currentlyVisibleDates, - page: page, - children: [ - for (final event in events) - _EventParentDataWidget( - key: ValueKey(event.id), - event: event, - child: _buildEvent(context, event, page), - ), - ], - ); - } - - Widget _buildEvent(BuildContext context, E event, double page) { - final visibleDays = controller.visibleRange.visibleDays; - final eventStartPage = event.start.calendarDate.epochDay; - final eventEndPage = (event.endDateInclusive + Period(days: 1)).epochDay; - final hiddenStartDays = (page - eventStartPage).coerceAtLeast(0); - return allDayEventBuilder( - context, - event, - AllDayEventLayoutInfo( - hiddenStartDays: hiddenStartDays, - hiddenEndDays: (eventEndPage - page - visibleDays).coerceAtLeast(0), - ), - ); - } -} - -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 _EventParentData parentData = renderObject.parentData; - - 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 List<_EventParentDataWidget> children, - }) : assert(visibleRange != null), - assert(currentlyVisibleDates != null), - assert(page != null), - assert(children != null), - super(children: children); - - static const _defaultEventHeight = 24.0; - - final VisibleRange visibleRange; - final DateInterval currentlyVisibleDates; - final double page; - - @override - RenderObject createRenderObject(BuildContext context) { - return _EventsLayout( - visibleRange: visibleRange, - currentlyVisibleDates: currentlyVisibleDates, - page: page, - eventHeight: - context.timetableTheme?.allDayEventHeight ?? _defaultEventHeight, - ); - } - - @override - void updateRenderObject(BuildContext context, _EventsLayout renderObject) { - renderObject - ..visibleRange = visibleRange - ..currentlyVisibleDates = currentlyVisibleDates - ..page = page - ..eventHeight = - context.timetableTheme?.allDayEventHeight ?? _defaultEventHeight; - } -} - -class _EventParentData - extends ContainerBoxParentData { - E event; -} - -class _EventsLayout extends RenderBox - with - ContainerRenderObjectMixin>, - RenderBoxContainerDefaultsMixin> { - _EventsLayout({ - @required VisibleRange visibleRange, - @required DateInterval currentlyVisibleDates, - @required double page, - @required double eventHeight, - }) : assert(visibleRange != null), - _visibleRange = visibleRange, - assert(currentlyVisibleDates != null), - _currentlyVisibleDates = currentlyVisibleDates, - assert(page != null), - _page = page, - assert(eventHeight != null), - _eventHeight = eventHeight; - - VisibleRange _visibleRange; - - VisibleRange get visibleRange => _visibleRange; - - set visibleRange(VisibleRange value) { - assert(value != null); - if (_visibleRange == value) { - return; - } - - _visibleRange = value; - markNeedsLayout(); - } - - DateInterval _currentlyVisibleDates; - - DateInterval get currentlyVisibleDates => _currentlyVisibleDates; - - set currentlyVisibleDates(DateInterval value) { - assert(value != null); - if (_currentlyVisibleDates == value) { - return; - } - - _currentlyVisibleDates = value; - markNeedsLayout(); - } - - double _page; - - double get page => _page; - - set page(double value) { - assert(value != null); - if (_page == value) { - return; - } - - _page = value; - markNeedsLayout(); - } - - double _eventHeight; - - double get eventHeight => _eventHeight; - - set eventHeight(double value) { - assert(value != null); - 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(); - } - } - - bool _debugThrowIfNotCheckingIntrinsics() { - assert(() { - if (!RenderObject.debugCheckingIntrinsics) { - throw Exception("_EventsLayout doesn't have an intrinsic width."); - } - return true; - }()); - return true; - } - - @override - double computeMinIntrinsicWidth(double height) { - assert(_debugThrowIfNotCheckingIntrinsics()); - return 0; - } - - @override - double computeMaxIntrinsicWidth(double height) { - assert(_debugThrowIfNotCheckingIntrinsics()); - return 0; - } - - double _parallelEventCount() { - int parallelEventsFrom(int page) { - final startDate = LocalDate.fromEpochDay(page); - final interval = DateInterval( - startDate, - startDate + Period(days: visibleRange.visibleDays - 1), - ); - - final maxEventPosition = _yPositions.entries - .where((e) => e.key.intersectsInterval(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 - 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(); - _setSize(); - _positionEvents(); - } - - void _updateEventPositions() { - // Remove old events. - _yPositions.removeWhere((e, _) { - final distance = math.max( - e.start.calendarDate.periodSince(currentlyVisibleDates.end).days, - e.endDateInclusive.periodUntil(currentlyVisibleDates.start).days, - ); - return distance >= visibleRange.visibleDays; - }); - - // Insert new events. - final sortedEvents = - events.whereNot(_yPositions.containsKey).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.intersectingDates; - - // ignore: literal_only_boolean_expressions - while (true) { - final intersectingEvents = eventsWithPosition(y); - if (intersectingEvents.none((e) => e.intersectsInterval(interval))) { - _yPositions[event] = y; - continue outer; - } - - y++; - } - } - } - - bool _hasOverflow = false; - - void _setSize() { - final parallelEvents = _parallelEventCount(); - size = Size(constraints.maxWidth, parallelEvents * eventHeight); - _hasOverflow = parallelEvents.floorToDouble() != parallelEvents; - } - - void _positionEvents() { - final dateWidth = size.width / visibleRange.visibleDays; - for (final child in children) { - final event = child.data.event; - - final startDate = event.start.calendarDate; - final left = ((startDate.epochDay - page) * dateWidth).coerceAtLeast(0); - final endDate = event.endDateInclusive; - final right = - ((endDate.epochDay + 1 - page) * dateWidth).coerceAtMost(size.width); - - child.layout(BoxConstraints.tightFor( - width: right - left, - height: eventHeight, - )); - - child.data.offset = Offset(left, _yPositions[event] * eventHeight); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - @override - void paint(PaintingContext context, Offset offset) { - if (!_hasOverflow) { - defaultPaint(context, offset); - return; - } - - context.pushClipRect( - needsCompositing, offset, Offset.zero & size, defaultPaint); - } -} - -extension _ParentData on RenderBox { - _EventParentData get data => parentData as _EventParentData; -} diff --git a/lib/src/header/date_header.dart b/lib/src/header/date_header.dart deleted file mode 100644 index 2f650a5..0000000 --- a/lib/src/header/date_header.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:time_machine/time_machine.dart'; - -import 'date_indicator.dart'; -import 'weekday_indicator.dart'; - -class DateHeader extends StatelessWidget { - const DateHeader(this.date, {Key key}) : super(key: key); - - final LocalDate date; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - WeekdayIndicator(date), - SizedBox(height: 4), - DateIndicator(date), - ], - ); - } -} diff --git a/lib/src/header/date_indicator.dart b/lib/src/header/date_indicator.dart deleted file mode 100644 index c8a400f..0000000 --- a/lib/src/header/date_indicator.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/material.dart'; -import 'package:time_machine/time_machine.dart'; -import 'package:time_machine/time_machine_text_patterns.dart'; - -import '../theme.dart'; -import '../utils/utils.dart'; - -class DateIndicator extends StatelessWidget { - const DateIndicator(this.date, {Key key}) : super(key: key); - - final LocalDate date; - - @override - Widget build(BuildContext context) { - final theme = context.theme; - final timetableTheme = context.timetableTheme; - - final states = statesFor(date); - final pattern = timetableTheme?.dateIndicatorPattern?.resolve(states) ?? - LocalDatePattern.createWithCurrentCulture('%d'); - final primaryColor = timetableTheme?.primaryColor ?? theme.primaryColor; - final decoration = - timetableTheme?.dateIndicatorDecoration?.resolve(states) ?? - BoxDecoration( - shape: BoxShape.circle, - color: date.isToday ? primaryColor : Colors.transparent, - ); - final textStyle = timetableTheme?.dateIndicatorTextStyle?.resolve(states) ?? - TextStyle( - color: date.isToday - ? primaryColor.highEmphasisOnColor - : theme.highEmphasisOnBackground, - ); - - return DecoratedBox( - decoration: decoration, - child: Padding( - padding: EdgeInsets.all(8), - child: Text(pattern.format(date), style: textStyle), - ), - ); - } - - static Set statesFor(LocalDate date) { - return { - if (date < LocalDate.today()) MaterialState.disabled, - if (date.isToday) MaterialState.selected, - }; - } -} diff --git a/lib/src/header/multi_date_header.dart b/lib/src/header/multi_date_header.dart deleted file mode 100644 index dd23bb9..0000000 --- a/lib/src/header/multi_date_header.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../controller.dart'; -import '../date_page_view.dart'; -import '../event.dart'; -import '../timetable.dart'; -import 'date_header.dart'; - -class MultiDateHeader extends StatelessWidget { - const MultiDateHeader({ - Key key, - @required this.controller, - this.builder, - }) : assert(controller != null), - super(key: key); - - final TimetableController controller; - final HeaderWidgetBuilder builder; - - @override - Widget build(BuildContext context) { - return DatePageView( - controller: controller, - builder: (context, date) { - return builder?.call(context, date) ?? Center(child: DateHeader(date)); - }, - ); - } -} diff --git a/lib/src/header/timetable_header.dart b/lib/src/header/timetable_header.dart deleted file mode 100644 index c0d50ec..0000000 --- a/lib/src/header/timetable_header.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:time_machine/time_machine.dart'; - -import '../controller.dart'; -import '../event.dart'; -import '../theme.dart'; -import '../timetable.dart'; -import 'all_day_events.dart'; -import 'multi_date_header.dart'; -import 'week_indicator.dart'; - -class TimetableHeader extends StatelessWidget { - const TimetableHeader({ - Key key, - @required this.controller, - @required this.allDayEventBuilder, - this.onEventBackgroundTap, - this.leadingHeaderBuilder, - this.dateHeaderBuilder, - }) : assert(controller != null), - assert(allDayEventBuilder != null), - super(key: key); - - final TimetableController controller; - final AllDayEventBuilder allDayEventBuilder; - final OnEventBackgroundTapCallback onEventBackgroundTap; - final HeaderWidgetBuilder leadingHeaderBuilder; - final HeaderWidgetBuilder dateHeaderBuilder; - - @override - Widget build(BuildContext context) { - // Like [WeekYearRules.iso], but with a variable first day of week. - final weekYearRule = - WeekYearRules.forMinDaysInFirstWeek(4, controller.firstDayOfWeek); - - return Row( - children: [ - SizedBox( - width: hourColumnWidth, - child: ValueListenableBuilder( - valueListenable: controller.dateListenable, - builder: (context, date, _) { - final customHeader = leadingHeaderBuilder?.call(context, date); - if (customHeader != null) { - return customHeader; - } - - return Center( - child: WeekIndicator(weekYearRule.getWeekOfWeekYear(date)), - ); - }, - ), - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: context.timetableTheme?.totalDateIndicatorHeight ?? 72, - child: MultiDateHeader( - controller: controller, - builder: dateHeaderBuilder, - ), - ), - AllDayEvents( - controller: controller, - onEventBackgroundTap: onEventBackgroundTap, - allDayEventBuilder: allDayEventBuilder, - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/src/header/week_indicator.dart b/lib/src/header/week_indicator.dart deleted file mode 100644 index 5ef1bc3..0000000 --- a/lib/src/header/week_indicator.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/material.dart'; - -import '../theme.dart'; - -class WeekIndicator extends StatelessWidget { - const WeekIndicator(this.week, {Key key}) : super(key: key); - - final int week; - - @override - Widget build(BuildContext context) { - final theme = context.theme; - final timetableTheme = context.timetableTheme; - - final defaultBackgroundColor = theme.contrastColor.withOpacity(0.12); - - final decoration = timetableTheme?.weekIndicatorDecoration ?? - BoxDecoration( - color: defaultBackgroundColor, - borderRadius: BorderRadius.circular(2), - ); - final textStyle = timetableTheme?.weekIndicatorTextStyle ?? - TextStyle( - color: defaultBackgroundColor - .alphaBlendOn(theme.scaffoldBackgroundColor) - .mediumEmphasisOnColor, - ); - - return DecoratedBox( - decoration: decoration, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Text( - week.toString(), - style: textStyle, - ), - ), - ); - } -} diff --git a/lib/src/header/weekday_indicator.dart b/lib/src/header/weekday_indicator.dart deleted file mode 100644 index 787d425..0000000 --- a/lib/src/header/weekday_indicator.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:black_hole_flutter/black_hole_flutter.dart'; -import 'package:flutter/material.dart'; -import 'package:time_machine/time_machine.dart'; -import 'package:time_machine/time_machine_text_patterns.dart'; - -import '../theme.dart'; -import '../utils/utils.dart'; -import 'date_indicator.dart'; - -class WeekdayIndicator extends StatelessWidget { - const WeekdayIndicator(this.date, {Key key}) : super(key: key); - - final LocalDate date; - - @override - Widget build(BuildContext context) { - final theme = context.theme; - final timetableTheme = context.timetableTheme; - - final states = DateIndicator.statesFor(date); - final pattern = timetableTheme?.weekDayIndicatorPattern?.resolve(states) ?? - LocalDatePattern.createWithCurrentCulture('ddd'); - final decoration = - timetableTheme?.weekDayIndicatorDecoration?.resolve(states) ?? - BoxDecoration(); - final textStyle = - timetableTheme?.weekDayIndicatorTextStyle?.resolve(states) ?? - TextStyle( - color: date.isToday - ? timetableTheme?.primaryColor ?? theme.primaryColor - : theme.highEmphasisOnBackground, - ); - - return DecoratedBox( - decoration: decoration, - child: Padding( - padding: EdgeInsets.all(8), - child: Text(pattern.format(date), style: textStyle), - ), - ); - } -} diff --git a/lib/src/initial_time_range.dart b/lib/src/initial_time_range.dart deleted file mode 100644 index 2b17109..0000000 --- a/lib/src/initial_time_range.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:time_machine/time_machine.dart'; - -import 'utils/vertical_zoom.dart'; - -@immutable -abstract class InitialTimeRange { - const InitialTimeRange(); - - const factory InitialTimeRange.zoom(double zoom) = _FactorInitialTimeRange; - factory InitialTimeRange.range({ - LocalTime startTime, - LocalTime endTime, - }) = _RangeInitialTimeRange; - - InitialZoom asInitialZoom(); -} - -class _FactorInitialTimeRange extends InitialTimeRange { - const _FactorInitialTimeRange(this.zoom) - : assert(zoom != null), - assert(zoom > 0); - - final double zoom; - - @override - InitialZoom asInitialZoom() => InitialZoom.zoom(zoom); -} - -class _RangeInitialTimeRange extends InitialTimeRange { - _RangeInitialTimeRange({ - LocalTime startTime, - LocalTime endTime, - }) : startTime = startTime ?? LocalTime.minValue, - endTime = endTime ?? LocalTime.maxValue, - assert((startTime ?? LocalTime.minValue) < - (endTime ?? LocalTime.maxValue)); - - final LocalTime startTime; - final LocalTime endTime; - - static double _timeToFraction(LocalTime time) => - time.timeSinceMidnight.inNanoseconds / TimeConstants.nanosecondsPerDay; - - @override - InitialZoom asInitialZoom() => InitialZoom.range( - startFraction: _timeToFraction(startTime), - endFraction: _timeToFraction(endTime), - ); -} diff --git a/lib/src/layouts/compact_month.dart b/lib/src/layouts/compact_month.dart new file mode 100644 index 0000000..029c4e6 --- /dev/null +++ b/lib/src/layouts/compact_month.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../components/month_widget.dart'; +import '../date/controller.dart'; +import '../date/month_page_view.dart'; +import '../utils.dart'; + +/// A Timetable widget that displays [MonthWidget]s in a page view. +/// +/// When a [DefaultDateController] is placed above in the widget tree, the +/// visible month is synced to it and swiping between months also updates that +/// [DateController]. +class CompactMonthTimetable extends StatefulWidget { + CompactMonthTimetable({ + MonthWidgetBuilder? monthBuilder, + }) : monthBuilder = monthBuilder ?? ((context, month) => MonthWidget(month)); + + final MonthWidgetBuilder monthBuilder; + + @override + _CompactMonthTimetableState createState() => _CompactMonthTimetableState(); +} + +class _CompactMonthTimetableState extends State + with TickerProviderStateMixin { + DateController? dateController; + late final MonthPageController _monthPageController; + + @override + void initState() { + super.initState(); + + _monthPageController = MonthPageController( + initialMonth: dateController?.date.value.firstDayOfMonth ?? + DateTimeTimetable.currentMonth(), + ); + _monthPageController.addListener(_onMonthPageControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + dateController?.date.removeListener(_onDateControllerChanged); + dateController = DefaultDateController.of(context); + dateController?.date.addListener(_onDateControllerChanged); + } + + @override + void dispose() { + dateController?.date.removeListener(_onDateControllerChanged); + _monthPageController.removeListener(_onMonthPageControllerChanged); + _monthPageController.dispose(); + super.dispose(); + } + + int _dateControllerDriverCount = 0; + int _monthPageControllerDriverCount = 0; + Future _onDateControllerChanged() async { + if (_dateControllerDriverCount > 0) return; + final dateControllerMonth = dateController!.date.value.firstDayOfMonth; + if (dateControllerMonth == _monthPageController.value) return; + + _monthPageControllerDriverCount++; + await _monthPageController.animateTo(dateControllerMonth); + _monthPageControllerDriverCount--; + } + + Future _onMonthPageControllerChanged() async { + if (_monthPageControllerDriverCount > 0) return; + + _dateControllerDriverCount++; + await dateController?.animateTo(_monthPageController.value, vsync: this); + _dateControllerDriverCount--; + } + + @override + Widget build(BuildContext context) { + return MonthPageView( + monthPageController: _monthPageController, + shrinkWrapInCrossAxis: true, + builder: widget.monthBuilder, + ); + } +} diff --git a/lib/src/layouts/multi_date.dart b/lib/src/layouts/multi_date.dart new file mode 100644 index 0000000..f640e33 --- /dev/null +++ b/lib/src/layouts/multi_date.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import '../components/date_header.dart'; +import '../components/multi_date_content.dart'; +import '../components/multi_date_event_header.dart'; +import '../components/time_indicators.dart'; +import '../components/week_indicator.dart'; +import '../config.dart'; +import '../date/controller.dart'; +import '../date/date_page_view.dart'; +import '../event/builder.dart'; +import '../event/event.dart'; +import '../event/provider.dart'; +import '../theme.dart'; +import '../time/controller.dart'; +import '../time/zoom.dart'; +import '../utils.dart'; +import 'recurring_multi_date.dart'; + +typedef MultiDateTimetableHeaderBuilder = Widget Function( + BuildContext context, + double? leadingWidth, +); +typedef MultiDateTimetableContentBuilder = Widget Function( + BuildContext context, + ValueChanged onLeadingWidthChanged, +); + +/// A Timetable widget that displays multiple consecutive days. +/// +/// To configure it, provide a [DateController], [TimeController], +/// [EventProvider], and [EventBuilder] via a [TimetableConfig] widget above in +/// the widget tree. (You can also provide these via `DefaultFoo` widgets +/// directly, like [DefaultDateController].) +/// +/// See also: +/// +/// * [RecurringMultiDateTimetable], which is a customized variation without +/// scrolling and specific dates – e.g., to show a generic week from Monday to +/// Sunday without dates. +class MultiDateTimetable extends StatefulWidget { + MultiDateTimetable({ + Key? key, + MultiDateTimetableHeaderBuilder? headerBuilder, + MultiDateTimetableContentBuilder? contentBuilder, + }) : headerBuilder = headerBuilder ?? _defaultHeaderBuilder(), + contentBuilder = contentBuilder ?? _defaultContentBuilder(), + super(key: key); + + final MultiDateTimetableHeaderBuilder headerBuilder; + static MultiDateTimetableHeaderBuilder + _defaultHeaderBuilder() { + return (context, leadingWidth) => MultiDateTimetableHeader( + leading: SizedBox( + width: leadingWidth, + child: Center(child: WeekIndicator.forController(null)), + ), + ); + } + + final MultiDateTimetableContentBuilder contentBuilder; + static MultiDateTimetableContentBuilder + _defaultContentBuilder() { + return (context, onLeadingWidthChanged) => MultiDateTimetableContent( + leading: SizeReportingWidget( + onSizeChanged: (size) => onLeadingWidthChanged(size.width), + child: _defaultContentLeading, + ), + ); + } + + @override + _MultiDateTimetableState createState() => _MultiDateTimetableState(); +} + +class _MultiDateTimetableState + extends State> { + double? _leadingWidth; + + @override + Widget build(BuildContext context) { + final eventProvider = DefaultEventProvider.of(context) ?? (_) => []; + + return Column(children: [ + DefaultEventProvider( + eventProvider: (visibleDates) => + eventProvider(visibleDates).where((it) => it.isAllDay).toList(), + child: Builder( + builder: (context) => widget.headerBuilder(context, _leadingWidth), + ), + ), + Expanded( + child: DefaultEventProvider( + eventProvider: (visibleDates) => + eventProvider(visibleDates).where((it) => it.isPartDay).toList(), + child: Builder( + builder: (contxt) => widget.contentBuilder( + context, + (newWidth) => setState(() => _leadingWidth = newWidth), + ), + ), + ), + ), + ]); + } +} + +class MultiDateTimetableHeader extends StatelessWidget { + MultiDateTimetableHeader({ + Key? key, + Widget? leading, + DateWidgetBuilder? dateHeaderBuilder, + Widget? bottom, + }) : leading = leading ?? Center(child: WeekIndicator.forController(null)), + dateHeaderBuilder = + dateHeaderBuilder ?? ((context, date) => DateHeader(date)), + bottom = bottom ?? MultiDateEventHeader(), + super(key: key); + + final Widget leading; + final DateWidgetBuilder dateHeaderBuilder; + final Widget bottom; + + @override + Widget build(BuildContext context) { + return Row(children: [ + leading, + Expanded( + child: Column(children: [ + DatePageView(shrinkWrapInCrossAxis: true, builder: dateHeaderBuilder), + bottom, + ]), + ), + ]); + } +} + +class MultiDateTimetableContent extends StatelessWidget { + MultiDateTimetableContent({ + Key? key, + Widget? leading, + Widget? divider, + Widget? content, + }) : leading = leading ?? _defaultContentLeading, + divider = divider ?? VerticalDivider(width: 0), + content = content ?? MultiDateContent(), + super(key: key); + + final Widget leading; + final Widget divider; + final Widget content; + + @override + Widget build(BuildContext context) { + return Row(children: [ + leading, + divider, + Expanded(child: content), + ]); + } +} + +// TODO(JonasWanke): Explicitly disable the scrollbar when they're shown by +// default on desktop: https://flutter.dev/docs/release/breaking-changes/default-desktop-scrollbars +// Builder( +// builder:(context) => ScrollConfiguration( +// behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), +// ) + +Widget _defaultContentLeading = Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: TimeZoom( + child: Builder( + builder: (context) => TimeIndicators.hours( + // `TimeIndicators.hours` overwrites the style provider's labels by + // default, but here we want the user's style provider from the ambient + // theme to take precedence. + styleProvider: TimetableTheme.of(context)?.timeIndicatorStyleProvider, + ), + ), + ), +); diff --git a/lib/src/layouts/recurring_multi_date.dart b/lib/src/layouts/recurring_multi_date.dart new file mode 100644 index 0000000..6af0f60 --- /dev/null +++ b/lib/src/layouts/recurring_multi_date.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../config.dart'; +import '../date/controller.dart'; +import '../date/visible_date_range.dart'; +import '../event/builder.dart'; +import '../event/event.dart'; +import '../event/provider.dart'; +import '../theme.dart'; +import '../time/controller.dart'; +import 'multi_date.dart'; + +/// A Timetable widget that displays multiple consecutive days without their +/// dates and without a week indicator. +/// +/// To configure it, provide a [DateController] (with a +/// [VisibleDateRange.fixed]), [TimeController], [EventProvider], and +/// [EventBuilder] via a [TimetableConfig] widget above in the widget tree. (You +/// can also provide these via `DefaultFoo` widgets directly, like +/// [DefaultDateController].) +/// +/// See also: +/// +/// * [MultiDateTimetable], which is used under the hood and can also display +/// concrete dates and be swipeable. +class RecurringMultiDateTimetable extends StatelessWidget { + RecurringMultiDateTimetable({ + Key? key, + WidgetBuilder? timetableBuilder, + }) : timetableBuilder = timetableBuilder ?? _defaultTimetableBuilder(), + super(key: key); + + final WidgetBuilder timetableBuilder; + static WidgetBuilder _defaultTimetableBuilder() { + return (context) => MultiDateTimetable( + headerBuilder: (header, leadingWidth) => MultiDateTimetableHeader( + leading: SizedBox(width: leadingWidth)), + ); + } + + @override + Widget build(BuildContext context) { + final theme = TimetableTheme.orDefaultOf(context); + + return TimetableTheme( + data: theme.copyWith( + dateHeaderStyleProvider: (date) => theme + .dateHeaderStyleProvider(date) + .copyWith(showDateIndicator: false), + ), + child: Builder(builder: timetableBuilder), + ); + } +} diff --git a/lib/src/localization.dart b/lib/src/localization.dart new file mode 100644 index 0000000..9a33525 --- /dev/null +++ b/lib/src/localization.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'week.dart'; + +/// Provides localized strings for Timetable widgets. +/// +/// Supported [Locale.languageCode]s: +/// +/// * `de` –German +/// * `en` – English +/// +/// By default, this delegate also configures [Intl] whenever Flutter's locale +/// changes. This behavior can be disabled via [setIntlLocale]. +/// +/// See also: +/// +/// * [TimetableLocalizations], which contains all strings for one locale. +class TimetableLocalizationsDelegate + extends LocalizationsDelegate { + const TimetableLocalizationsDelegate({this.setIntlLocale = true}); + + final bool setIntlLocale; + + @override + bool isSupported(Locale locale) => _getLocalization(locale) != null; + + @override + Future load(Locale locale) { + assert(isSupported(locale)); + if (setIntlLocale) Intl.defaultLocale = locale.toLanguageTag(); + return SynchronousFuture(_getLocalization(locale)!); + } + + @override + bool shouldReload(TimetableLocalizationsDelegate old) => false; + + TimetableLocalizations? _getLocalization(Locale locale) { + switch (locale.languageCode) { + case 'de': + return const TimetableLocalizationDe(); + case 'en': + return const TimetableLocalizationEn(); + default: + return null; + } + } +} + +// Modified version of `debugCheckHasMaterialLocalizations`. +bool debugCheckHasTimetableLocalizations(BuildContext context) { + assert(() { + if (Localizations.of( + context, TimetableLocalizations) == + null) { + throw FlutterError.fromParts([ + ErrorSummary('No TimetableLocalization found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require TimetableLocalization ' + 'to be provided by a Localizations widget ancestor.', + ), + ErrorDescription( + 'The timetable library uses Localizations to generate messages, ' + 'labels, and abbreviations.', + ), + ErrorHint( + 'To introduce a TimetableLocalization, add a ' + 'TimetableLocalizationsDelegate() to your ' + "Material-/Cupertino-/WidgetApp's localizationsDelegates.", + ), + ...context.describeMissingAncestor( + expectedAncestorType: TimetableLocalizations, + ) + ]); + } + return true; + }()); + return true; +} + +/// Contains localized strings for Timetable widgets in one locale. +/// +/// See also: +/// +/// * [TimetableLocalizationsDelegate], which makes localization info available +/// to Timetable widgets. +@immutable +abstract class TimetableLocalizations { + const TimetableLocalizations(); + + static TimetableLocalizations of(BuildContext context) { + assert(debugCheckHasTimetableLocalizations(context)); + return Localizations.of( + context, + TimetableLocalizations, + )!; + } + + List weekLabels(Week week); + String weekOfYear(Week week); +} + +extension BuildContextTimetableLocalizations on BuildContext { + TimetableLocalizations get timetableLocalizations => + TimetableLocalizations.of(this); + void dependOnTimetableLocalizations() { + // By accessing the localizations, this widget calling this method will get + // rebuilt when the locale changes. + TimetableLocalizations.of(this); + } +} + +class TimetableLocalizationDe extends TimetableLocalizations { + const TimetableLocalizationDe(); + + @override + List weekLabels(Week week) { + return [ + weekOfYear(week), + 'Woche ${week.weekOfYear}', + 'KW ${week.weekOfYear}', + '${week.weekOfYear}', + ]; + } + + @override + String weekOfYear(Week week) => + 'Kalenderwoche ${week.weekOfYear}, ${week.weekBasedYear}'; +} + +class TimetableLocalizationEn extends TimetableLocalizations { + const TimetableLocalizationEn(); + + @override + List weekLabels(Week week) { + return [ + weekOfYear(week), + 'Week ${week.weekOfYear}', + 'W ${week.weekOfYear}', + '${week.weekOfYear}', + ]; + } + + @override + String weekOfYear(Week week) => + 'Week ${week.weekOfYear}, ${week.weekBasedYear}'; +} diff --git a/lib/src/scroll_physics.dart b/lib/src/scroll_physics.dart deleted file mode 100644 index 9c199b1..0000000 --- a/lib/src/scroll_physics.dart +++ /dev/null @@ -1,61 +0,0 @@ -// Inspired by [PageScrollPhysics] -import 'package:flutter/widgets.dart'; - -import 'controller.dart'; - -class TimetableScrollPhysics extends ScrollPhysics { - const TimetableScrollPhysics(this.controller, {ScrollPhysics parent}) - : assert(controller != null), - super(parent: parent); - - final TimetableController controller; - - @override - TimetableScrollPhysics applyTo(ScrollPhysics ancestor) { - return TimetableScrollPhysics(controller, parent: buildParent(ancestor)); - } - - double _getTargetPixels( - ScrollPosition position, - Tolerance tolerance, - double velocity, - ) { - final pixelsToPage = - controller.visibleRange.visibleDays / position.viewportDimension; - final currentPage = position.pixels * pixelsToPage; - - final targetPage = controller.visibleRange.getTargetPageForCurrent( - currentPage, - controller.firstDayOfWeek, - velocity: velocity, - tolerance: tolerance, - ); - return targetPage / pixelsToPage; - } - - @override - Simulation createBallisticSimulation( - ScrollMetrics position, double velocity) { - // 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 tolerance = this.tolerance; - final target = _getTargetPixels(position, tolerance, velocity); - 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/theme.dart b/lib/src/theme.dart index 99fef80..fa53efc 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -1,306 +1,202 @@ import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; -import 'package:time_machine/time_machine.dart'; -import 'package:time_machine/time_machine_text_patterns.dart'; -import 'timetable.dart'; - -/// Defines visual properties for [Timetable] and related widgets. +import 'components/date_dividers.dart'; +import 'components/date_events.dart'; +import 'components/date_header.dart'; +import 'components/date_indicator.dart'; +import 'components/hour_dividers.dart'; +import 'components/month_indicator.dart'; +import 'components/month_widget.dart'; +import 'components/multi_date_event_header.dart'; +import 'components/now_indicator.dart'; +import 'components/time_indicator.dart'; +import 'components/week_indicator.dart'; +import 'components/weekday_indicator.dart'; +import 'utils.dart'; +import 'week.dart'; + +typedef MonthBasedStyleProvider = T Function(DateTime month); +typedef WeekBasedStyleProvider = T Function(Week week); +typedef DateBasedStyleProvider = T Function(DateTime date); +typedef TimeBasedStyleProvider = T Function(Duration time); + +/// Bundles styles for all Timetable widgets. +/// +/// See also: +/// +/// * [TimetableTheme], which makes the theme data available to nested widgets. +@immutable class TimetableThemeData { - const TimetableThemeData({ - this.primaryColor, - this.weekIndicatorDecoration, - this.weekIndicatorTextStyle, - this.totalDateIndicatorHeight, - this.weekDayIndicatorPattern, - this.weekDayIndicatorDecoration, - this.weekDayIndicatorTextStyle, - this.dateIndicatorPattern, - this.dateIndicatorDecoration, - this.dateIndicatorTextStyle, - this.allDayEventHeight, - this.hourTextStyle, - this.timeIndicatorColor, - this.dividerColor, - this.minimumHourHeight, - this.maximumHourHeight, - this.minimumHourZoom, - this.maximumHourZoom, - this.partDayEventMinimumDuration, - this.partDayEventMinimumHeight, - this.partDayEventSpacing, - this.enablePartDayEventStacking, - this.partDayEventMinimumDeltaForStacking, - this.partDayStackedEventSpacing, - }) : assert(allDayEventHeight == null || allDayEventHeight > 0), - assert(minimumHourHeight == null || minimumHourHeight > 0), - assert(maximumHourHeight == null || maximumHourHeight > 0), - assert(minimumHourHeight == null || - maximumHourHeight == null || - minimumHourHeight <= maximumHourHeight), - assert(minimumHourZoom == null || minimumHourZoom > 0), - assert(maximumHourZoom == null || maximumHourZoom > 0), - assert(minimumHourZoom == null || - minimumHourZoom == null || - minimumHourZoom <= minimumHourZoom); - - /// Used by default for indicating the current date. - /// - /// The default value is [ThemeData.primaryColor]. - final Color primaryColor; - - // Header: - - /// [Decoration] to show around the week indicator. - final Decoration weekIndicatorDecoration; - - /// [TextStyle] used to display the current week number. - final TextStyle weekIndicatorTextStyle; - - /// Total (combined) height of both the day-of-week- and - /// date-of-month-indicators. - /// - /// > **Note:** This will soon be determined automatically based on the actual - /// > height. - @experimental - final double totalDateIndicatorHeight; - - /// [LocalDatePattern] for formatting the day-of-week. - /// - /// See also: - /// - [dateIndicatorTextStyle] for a list of possible states. - final MaterialStateProperty weekDayIndicatorPattern; - - /// [Decoration] to show around the day-of-week-indicator. - /// - /// See also: - /// - [dateIndicatorTextStyle] for a list of possible states. - final MaterialStateProperty weekDayIndicatorDecoration; - - /// [TextStyle] used to display the day of week. - /// - /// See also: - /// - [dateIndicatorTextStyle] for a list of possible states. - final MaterialStateProperty weekDayIndicatorTextStyle; - - /// [LocalDatePattern] for formatting the date (of month). - /// - /// See also: - /// - [dateIndicatorTextStyle] for a list of possible states. - final MaterialStateProperty dateIndicatorPattern; - - /// [Decoration] to show around the date (of month) indicator. - /// - /// See also: - /// - [dateIndicatorTextStyle] for a list of possible states. - final MaterialStateProperty dateIndicatorDecoration; - - /// [TextStyle] used to display the date (of month). - /// - /// States: - /// - past days: [MaterialState.disabled] - /// - today: [MaterialState.selected] - /// - future days: none - final MaterialStateProperty dateIndicatorTextStyle; - - /// Height of a single all-day event. - /// - /// Defaults to 24. - final double allDayEventHeight; - - // Content: - - /// [TextStyle] used to display the hours of the day. - final TextStyle hourTextStyle; - - /// [Color] for painting the current time indicator. - final Color timeIndicatorColor; - - /// [Color] for painting hour and day dividers in the part-day event area. - final Color dividerColor; - - /// Minimum height of a single hour when zooming in. - /// - /// Defaults to 16. - final double minimumHourHeight; - - /// Maximum height of a single hour when zooming in. - /// - /// [double.infinity] is supported! - /// - /// Defaults to 64. - final double maximumHourHeight; - - /// Minimum time zoom factor. - /// - /// `1` means that the hours content is exactly as high as the parent. Larger - /// values mean zooming in, and smaller values mean zooming out. - /// - /// If both hour height limits ([minimumHourHeight] or [maximumHourHeight]) - /// and hour zoom limits (this property or [maximumHourZoom]) are set, zoom - /// limits take precedence. - /// - /// Defaults to 0. - final double minimumHourZoom; - - /// Maximum time zoom factor. - /// - /// Defaults to [double.infinity]. - /// - /// See also: - /// - [minimumHourZoom] for an explanation of zoom values. - final double maximumHourZoom; - - /// Minimum [Period] to size a part-day event. - /// - /// Can be used together with [partDayEventMinimumHeight]. - final Period partDayEventMinimumDuration; - - /// Minimum height to size a part-day event. - /// - /// Can be used together with [partDayEventMinimumDuration]. - final double partDayEventMinimumHeight; - - /// Horizontal space between two parallel events shown next to each other. - final double partDayEventSpacing; - - /// 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 [partDayEventMinimumDeltaForStacking]. If set to - /// `false`, intersecting events will always be shown next to each other and - /// not overlap. - /// - /// Defaults to `true`. - final bool enablePartDayEventStacking; - - /// 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. - /// - /// Defaults to 15 min. - /// - /// See also: - /// - [enablePartDayEventStacking], which can disable the stacking behavior - /// completely. - final Period partDayEventMinimumDeltaForStacking; - - /// Horizontal space between two parallel events stacked on top of each other. - final double partDayStackedEventSpacing; + factory TimetableThemeData( + BuildContext context, { + int? startOfWeek, + DateDividersStyle? dateDividersStyle, + DateBasedStyleProvider? dateEventsStyleProvider, + DateBasedStyleProvider? dateHeaderStyleProvider, + DateBasedStyleProvider? dateIndicatorStyleProvider, + HourDividersStyle? hourDividersStyle, + MonthBasedStyleProvider? monthIndicatorStyleProvider, + MonthBasedStyleProvider? monthWidgetStyleProvider, + MultiDateEventHeaderStyle? multiDateEventHeaderStyle, + NowIndicatorStyle? nowIndicatorStyle, + TimeBasedStyleProvider? timeIndicatorStyleProvider, + DateBasedStyleProvider? + weekdayIndicatorStyleProvider, + WeekBasedStyleProvider? weekIndicatorStyleProvider, + }) { + return TimetableThemeData.raw( + startOfWeek: startOfWeek ?? DateTime.monday, + dateDividersStyle: dateDividersStyle ?? DateDividersStyle(context), + dateEventsStyleProvider: + dateEventsStyleProvider ?? (date) => DateEventsStyle(context, date), + dateHeaderStyleProvider: + dateHeaderStyleProvider ?? (date) => DateHeaderStyle(context, date), + dateIndicatorStyleProvider: dateIndicatorStyleProvider ?? + (date) => DateIndicatorStyle(context, date), + hourDividersStyle: hourDividersStyle ?? HourDividersStyle(context), + monthIndicatorStyleProvider: monthIndicatorStyleProvider ?? + (month) => MonthIndicatorStyle(context, month), + monthWidgetStyleProvider: monthWidgetStyleProvider ?? + (month) => MonthWidgetStyle(context, month, startOfWeek: startOfWeek), + multiDateEventHeaderStyle: + multiDateEventHeaderStyle ?? MultiDateEventHeaderStyle(context), + nowIndicatorStyle: nowIndicatorStyle ?? NowIndicatorStyle(context), + timeIndicatorStyleProvider: timeIndicatorStyleProvider ?? + (time) => TimeIndicatorStyle(context, time), + weekdayIndicatorStyleProvider: weekdayIndicatorStyleProvider ?? + (date) => WeekdayIndicatorStyle(context, date), + weekIndicatorStyleProvider: weekIndicatorStyleProvider ?? + (week) => WeekIndicatorStyle(context, week), + ); + } - @override - int get hashCode { - return hashList([ - primaryColor, - weekIndicatorDecoration, - weekIndicatorTextStyle, - totalDateIndicatorHeight, - weekDayIndicatorPattern, - weekDayIndicatorDecoration, - weekDayIndicatorTextStyle, - dateIndicatorPattern, - dateIndicatorDecoration, - dateIndicatorTextStyle, - allDayEventHeight, - hourTextStyle, - timeIndicatorColor, - dividerColor, - minimumHourHeight, - maximumHourHeight, - minimumHourZoom, - maximumHourZoom, - partDayEventMinimumDuration, - partDayEventMinimumHeight, - partDayEventSpacing, - enablePartDayEventStacking, - partDayEventMinimumDeltaForStacking, - partDayStackedEventSpacing, - ]); + TimetableThemeData.raw({ + required this.startOfWeek, + required this.dateDividersStyle, + required this.dateEventsStyleProvider, + required this.dateHeaderStyleProvider, + required this.dateIndicatorStyleProvider, + required this.hourDividersStyle, + required this.monthIndicatorStyleProvider, + required this.monthWidgetStyleProvider, + required this.multiDateEventHeaderStyle, + required this.nowIndicatorStyle, + required this.timeIndicatorStyleProvider, + required this.weekdayIndicatorStyleProvider, + required this.weekIndicatorStyleProvider, + }) : assert(startOfWeek.isValidTimetableDayOfWeek); + + final int startOfWeek; + final DateDividersStyle dateDividersStyle; + final DateBasedStyleProvider dateEventsStyleProvider; + final DateBasedStyleProvider dateHeaderStyleProvider; + final DateBasedStyleProvider dateIndicatorStyleProvider; + final HourDividersStyle hourDividersStyle; + final MonthBasedStyleProvider + monthIndicatorStyleProvider; + final MonthBasedStyleProvider monthWidgetStyleProvider; + final MultiDateEventHeaderStyle multiDateEventHeaderStyle; + final NowIndicatorStyle nowIndicatorStyle; + final TimeBasedStyleProvider timeIndicatorStyleProvider; + final DateBasedStyleProvider + weekdayIndicatorStyleProvider; + final WeekBasedStyleProvider weekIndicatorStyleProvider; + + TimetableThemeData copyWith({ + int? startOfWeek, + DateDividersStyle? dateDividersStyle, + DateBasedStyleProvider? dateEventsStyleProvider, + DateBasedStyleProvider? dateHeaderStyleProvider, + DateBasedStyleProvider? dateIndicatorStyleProvider, + HourDividersStyle? hourDividersStyle, + MonthBasedStyleProvider? monthIndicatorStyleProvider, + MonthBasedStyleProvider? monthWidgetStyleProvider, + MultiDateEventHeaderStyle? multiDateEventHeaderStyle, + NowIndicatorStyle? nowIndicatorStyle, + TimeBasedStyleProvider? timeIndicatorStyleProvider, + DateBasedStyleProvider? + weekdayIndicatorStyleProvider, + WeekBasedStyleProvider? weekIndicatorStyleProvider, + }) { + return TimetableThemeData.raw( + startOfWeek: startOfWeek ?? this.startOfWeek, + dateDividersStyle: dateDividersStyle ?? this.dateDividersStyle, + dateEventsStyleProvider: + dateEventsStyleProvider ?? this.dateEventsStyleProvider, + dateHeaderStyleProvider: + dateHeaderStyleProvider ?? this.dateHeaderStyleProvider, + dateIndicatorStyleProvider: + dateIndicatorStyleProvider ?? this.dateIndicatorStyleProvider, + hourDividersStyle: hourDividersStyle ?? this.hourDividersStyle, + monthIndicatorStyleProvider: + monthIndicatorStyleProvider ?? this.monthIndicatorStyleProvider, + monthWidgetStyleProvider: + monthWidgetStyleProvider ?? this.monthWidgetStyleProvider, + multiDateEventHeaderStyle: + multiDateEventHeaderStyle ?? this.multiDateEventHeaderStyle, + nowIndicatorStyle: nowIndicatorStyle ?? this.nowIndicatorStyle, + timeIndicatorStyleProvider: + timeIndicatorStyleProvider ?? this.timeIndicatorStyleProvider, + weekdayIndicatorStyleProvider: + weekdayIndicatorStyleProvider ?? this.weekdayIndicatorStyleProvider, + weekIndicatorStyleProvider: + weekIndicatorStyleProvider ?? this.weekIndicatorStyleProvider, + ); } + @override + int get hashCode => hashValues( + startOfWeek, + dateDividersStyle, + dateEventsStyleProvider, + dateHeaderStyleProvider, + dateIndicatorStyleProvider, + hourDividersStyle, + monthIndicatorStyleProvider, + monthWidgetStyleProvider, + multiDateEventHeaderStyle, + nowIndicatorStyle, + timeIndicatorStyleProvider, + weekdayIndicatorStyleProvider, + weekIndicatorStyleProvider, + ); @override bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other.runtimeType != runtimeType) { - return false; - } return other is TimetableThemeData && - other.primaryColor == primaryColor && - other.weekIndicatorDecoration == weekIndicatorDecoration && - other.weekIndicatorTextStyle == weekIndicatorTextStyle && - other.totalDateIndicatorHeight == totalDateIndicatorHeight && - other.weekDayIndicatorPattern == weekDayIndicatorPattern && - other.weekDayIndicatorDecoration == weekDayIndicatorDecoration && - other.weekDayIndicatorTextStyle == weekDayIndicatorTextStyle && - other.dateIndicatorPattern == dateIndicatorPattern && - other.dateIndicatorDecoration == dateIndicatorDecoration && - other.dateIndicatorTextStyle == dateIndicatorTextStyle && - other.allDayEventHeight == allDayEventHeight && - other.hourTextStyle == hourTextStyle && - other.timeIndicatorColor == timeIndicatorColor && - other.dividerColor == dividerColor && - other.minimumHourHeight == minimumHourHeight && - other.maximumHourHeight == maximumHourHeight && - other.minimumHourZoom == minimumHourZoom && - other.maximumHourZoom == maximumHourZoom && - other.partDayEventMinimumDuration == partDayEventMinimumDuration && - other.partDayEventMinimumHeight == partDayEventMinimumHeight && - other.partDayEventSpacing == partDayEventSpacing && - other.enablePartDayEventStacking == enablePartDayEventStacking && - other.partDayEventMinimumDeltaForStacking == - partDayEventMinimumDeltaForStacking && - other.partDayStackedEventSpacing == partDayStackedEventSpacing; + startOfWeek == other.startOfWeek && + dateDividersStyle == other.dateDividersStyle && + dateEventsStyleProvider == other.dateEventsStyleProvider && + dateHeaderStyleProvider == other.dateHeaderStyleProvider && + dateIndicatorStyleProvider == other.dateIndicatorStyleProvider && + hourDividersStyle == other.hourDividersStyle && + monthIndicatorStyleProvider == other.monthIndicatorStyleProvider && + monthWidgetStyleProvider == other.monthWidgetStyleProvider && + multiDateEventHeaderStyle == other.multiDateEventHeaderStyle && + nowIndicatorStyle == other.nowIndicatorStyle && + timeIndicatorStyleProvider == other.timeIndicatorStyleProvider && + weekdayIndicatorStyleProvider == other.weekdayIndicatorStyleProvider && + weekIndicatorStyleProvider == other.weekIndicatorStyleProvider; } } -/// An inherited widget that defines visual properties for [Timetable]s and -/// related widgets in this widget's subtree. -class TimetableTheme extends InheritedTheme { - /// Creates a timetable theme that controls the [TimetableThemeData] - /// properties for a [Timetable]. - /// - /// [data] must not be null. +/// Provides styles for nested Timetable widgets. +/// +/// See also: +/// +/// * [TimetableThemeData], which bundles the actual styles. +class TimetableTheme extends InheritedWidget { const TimetableTheme({ - Key key, - @required this.data, - @required Widget child, - }) : assert(data != null), - super(key: key, child: child); + required this.data, + required Widget child, + }) : super(child: child); final TimetableThemeData data; - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [TimetableTheme] widget, `null` is returned. - /// - /// It's recommended to use the extension property on [BuildContext]: - /// - /// ```dart - /// TimetableThemeData theme = context.timetableTheme; - /// ``` - static TimetableThemeData of(BuildContext context) { - final timetableTheme = - context.dependOnInheritedWidgetOfExactType(); - return timetableTheme?.data; - } - - @override - Widget wrap(BuildContext context, Widget child) { - final ancestorTheme = - context.findAncestorWidgetOfExactType(); - return identical(this, ancestorTheme) - ? child - : TimetableTheme(data: data, child: child); - } - @override bool updateShouldNotify(TimetableTheme oldWidget) => data != oldWidget.data; -} -extension TimetableThemeBuildContext on BuildContext { - /// Shortcut for `TimetableTheme.of(context)`. - TimetableThemeData get timetableTheme => TimetableTheme.of(this); + static TimetableThemeData? of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; + static TimetableThemeData orDefaultOf(BuildContext context) => + of(context) ?? TimetableThemeData(context); } diff --git a/lib/src/time/controller.dart b/lib/src/time/controller.dart new file mode 100644 index 0000000..96304cf --- /dev/null +++ b/lib/src/time/controller.dart @@ -0,0 +1,119 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import '../config.dart'; +import '../utils.dart'; +import 'time_range.dart'; + +/// Controls the visible time range and zoom factor in a [`MultiDateTimetable`] +/// (or [`RecurringMultiDateTimetable`]). +/// +/// You can programmatically change those via [animateToShowFullDay], +/// [animateTo], [jumpToShowFullDay], or by directly setting the [value]. +class TimeController extends ValueNotifier { + TimeController({ + this.minDuration = const Duration(minutes: 1), + TimeRange? initialRange, + TimeRange? maxRange, + }) : assert(!minDuration.isNegative), + assert(minDuration <= 1.days), + assert(initialRange == null || initialRange.duration >= minDuration), + maxRange = maxRange ?? TimeRange.fullDay, + assert(maxRange == null || maxRange.duration >= minDuration), + assert( + initialRange == null || + _isValidRange( + initialRange, + minDuration, + maxRange ?? TimeRange.fullDay, + ), + ), + super(initialRange ?? maxRange ?? TimeRange.fullDay); + + static bool _isValidRange( + TimeRange range, + Duration minDuration, + TimeRange maxRange, + ) => + range.duration >= minDuration && maxRange.contains(range); + + /// The minimum visible duration when zooming in. + final Duration minDuration; + + /// The maximum range that can be revealed when zooming out. + final TimeRange maxRange; + + @override + set value(TimeRange value) { + assert(value.duration >= minDuration); + assert(maxRange.contains(value)); + super.value = value; + } + + // Animation + AnimationController? _animationController; + + Future animateToShowFullDay({ + Curve curve = Curves.easeInOut, + Duration duration = const Duration(milliseconds: 200), + required TickerProvider vsync, + }) { + return animateTo( + TimeRange.fullDay, + curve: curve, + duration: duration, + vsync: vsync, + ); + } + + Future animateTo( + TimeRange newValue, { + Curve curve = Curves.easeInOut, + Duration duration = const Duration(milliseconds: 200), + required TickerProvider vsync, + }) async { + assert(_isValidRange(newValue, minDuration, maxRange)); + + _animationController?.dispose(); + final previousRange = value; + _animationController = + AnimationController(debugLabel: 'TimeController', vsync: vsync) + ..addListener(() { + value = TimeRange.lerp( + previousRange, + newValue, + _animationController!.value, + ); + }) + ..animateTo(1, duration: duration, curve: curve); + } + + void jumpToShowFullDay() => value = TimeRange.fullDay; +} + +/// Provides the [TimeController] for Timetable widgets below it. +/// +/// See also: +/// +/// * [TimetableConfig], which bundles multiple configuration widgets for +/// Timetable. +class DefaultTimeController extends InheritedWidget { + const DefaultTimeController({ + required this.controller, + required Widget child, + }) : super(child: child); + + final TimeController controller; + + @override + bool updateShouldNotify(DefaultTimeController oldWidget) => + controller != oldWidget.controller; + + static TimeController? of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.controller; + } +} diff --git a/lib/src/time/overlay.dart b/lib/src/time/overlay.dart new file mode 100644 index 0000000..e4dba9a --- /dev/null +++ b/lib/src/time/overlay.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../event/event.dart'; +import '../utils.dart'; + +@immutable +class TimeOverlay { + TimeOverlay({ + required this.start, + required this.end, + required this.widget, + this.position = TimeOverlayPosition.behindEvents, + }) : assert(start.isValidTimetableTimeOfDay), + assert(end.isValidTimetableTimeOfDay), + assert(start < end); + + final Duration start; + final Duration end; + + /// The widget that will be shown as an overlay. + final Widget widget; + + /// Whether to paint this overlay behind or in front of events. + final TimeOverlayPosition position; +} + +enum TimeOverlayPosition { behindEvents, inFrontOfEvents } + +/// Provides [TimeOverlay]s to Timetable widgets. +/// +/// [TimeOverlayProvider]s may only return overlays for the given [date]. +/// +/// See also: +/// +/// * [emptyTimeOverlayProvider], which returns an empty list for all dates. +/// * [mergeTimeOverlayProviders], which merges multiple [TimeOverlayProvider]s. +typedef TimeOverlayProvider = List Function( + BuildContext context, + DateTime date, +); + +List emptyTimeOverlayProvider( + BuildContext context, + DateTime date, +) { + assert(date.isValidTimetableDate); + return []; +} + +TimeOverlayProvider mergeTimeOverlayProviders( + List overlayProviders, +) { + return (context, date) => + overlayProviders.expand((it) => it(context, date)).toList(); +} + +class DefaultTimeOverlayProvider extends InheritedWidget { + const DefaultTimeOverlayProvider({ + required this.overlayProvider, + required Widget child, + }) : super(child: child); + + final TimeOverlayProvider overlayProvider; + + @override + bool updateShouldNotify(DefaultTimeOverlayProvider oldWidget) => + overlayProvider != oldWidget.overlayProvider; + + static TimeOverlayProvider? of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.overlayProvider; + } +} + +extension EventToTimeOverlay on Event { + TimeOverlay? toTimeOverlay({ + required DateTime date, + required Widget widget, + TimeOverlayPosition position = TimeOverlayPosition.inFrontOfEvents, + }) { + assert(date.isValidTimetableDate); + + if (!interval.intersects(date.fullDayInterval)) return null; + + return TimeOverlay( + start: start.difference(date).coerceAtLeast(Duration.zero), + end: endInclusive.difference(date).coerceAtMost(1.days), + widget: widget, + position: position, + ); + } +} diff --git a/lib/src/time/time_range.dart b/lib/src/time/time_range.dart new file mode 100644 index 0000000..8a0a79e --- /dev/null +++ b/lib/src/time/time_range.dart @@ -0,0 +1,67 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; + +import '../utils.dart'; +import 'controller.dart'; + +/// The value held by [TimeController]. +@immutable +class TimeRange { + TimeRange(this.startTime, this.endTime) + : assert(startTime.isValidTimetableTimeOfDay), + assert(endTime.isValidTimetableTimeOfDay), + assert(startTime <= endTime); + factory TimeRange.fromStartAndDuration( + Duration startTime, Duration duration) => + TimeRange(startTime, startTime + duration); + + factory TimeRange.centeredAround( + Duration center, + Duration duration, { + bool canShiftIfDoesntFit = true, + }) { + assert(duration <= 1.days); + + final halfDuration = duration * (1 / 2); + if (center - halfDuration < 0.days) { + assert(canShiftIfDoesntFit); + return TimeRange(0.days, duration); + } else if (center + halfDuration > 1.days) { + assert(canShiftIfDoesntFit); + return TimeRange(1.days - duration, 1.days); + } else { + return TimeRange(center - halfDuration, center + halfDuration); + } + } + + static final fullDay = TimeRange(0.days, 1.days); + + final Duration startTime; + final Duration endTime; + Duration get duration => endTime - startTime; + + bool contains(TimeRange other) => + startTime <= other.startTime && other.endTime <= endTime; + + // ignore: prefer_constructors_over_static_methods + static TimeRange lerp(TimeRange a, TimeRange b, double t) { + return TimeRange( + lerpDuration(a.startTime, b.startTime, t), + lerpDuration(a.endTime, b.endTime, t), + ); + } + + @override + int get hashCode => hashValues(startTime, endTime); + @override + bool operator ==(Object other) { + return other is TimeRange && + startTime == other.startTime && + endTime == other.endTime; + } + + @override + String toString() => 'TimeRange(startTime = $startTime, endTime = $endTime)'; +} diff --git a/lib/src/time/zoom.dart b/lib/src/time/zoom.dart new file mode 100644 index 0000000..0930465 --- /dev/null +++ b/lib/src/time/zoom.dart @@ -0,0 +1,379 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +import '../utils.dart'; +import 'controller.dart'; +import 'time_range.dart'; + +/// A widget that allows the user to scroll and zoom into a single day. +/// +/// This uses a [TimeController] to maintain its state, which has to be supplied +/// by a [DefaultTimeController] above in the widget tree. +class TimeZoom extends StatefulWidget { + const TimeZoom({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + _TimeZoomState createState() => _TimeZoomState(); +} + +class _TimeZoomState extends State + with SingleTickerProviderStateMixin { + // Taken from [_InteractiveViewerState._kDrag]. + static const _kDrag = 0.0000135; + late AnimationController _animationController; + Animation? _animation; + + TimeController get _controller => DefaultTimeController.of(context)!; + ScrollController? _scrollController; + bool _scrollControllerIsInitialized = false; + + late double _parentHeight; + + // Layouts the child so only [_controller.value] out of [_controller.maxRange] + // is visible. + double get _outerChildHeight => + _parentHeight * + (_controller.maxRange.duration / _controller.value.duration); + double get _outerOffset { + final timeRange = _controller.value; + return (timeRange.startTime - _controller.maxRange.startTime) / + _controller.maxRange.duration * + _outerChildHeight; + } + + late TimeRange? _initialRange; + late Duration? _lastFocus; + + @override + void initState() { + super.initState(); + _animationController = AnimationController(vsync: this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollController?.dispose(); + _scrollControllerIsInitialized = false; + } + + void _onControllerChanged() { + _scrollController!.jumpTo(_outerOffset); + } + + void _onScrollControllerChanged() { + _controller.value = TimeRange.fromStartAndDuration( + _controller.maxRange.startTime + + _controller.maxRange.duration * + (_scrollController!.offset / _outerChildHeight), + _controller.value.duration, + ); + } + + @override + void dispose() { + _animationController.dispose(); + _scrollController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _parentHeight = constraints.maxHeight; + + if (!_scrollControllerIsInitialized) { + _scrollController = + ScrollController(initialScrollOffset: _outerOffset) + ..addListener(_onScrollControllerChanged); + _controller.addListener(_onControllerChanged); + _scrollControllerIsInitialized = true; + } + + return GestureDetector( + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onScaleEnd: _onScaleEnd, + child: ClipRect( + child: _NoDragSingleChildScrollView( + controller: _scrollController!, + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, _, child) { + // Layouts the child so only [_controller.maxRange] is + // visible. + final innerChildHeight = _outerChildHeight * + (1.days / _controller.maxRange.duration); + final innerOffset = -innerChildHeight * + (_controller.maxRange.startTime / 1.days); + + return SizedBox( + height: _outerChildHeight, + child: _VerticalOverflowBox( + offset: innerOffset, + height: innerChildHeight, + child: widget.child, + ), + ); + }, + ), + ), + ), + ); + }, + ); + } + + void _onScaleStart(ScaleStartDetails details) { + _initialRange = _controller.value; + _lastFocus = _getFocusTime(details.localFocalPoint.dy); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + final newDuration = (_initialRange!.duration * (1 / details.verticalScale)) + .coerceIn(_controller.minDuration, _controller.maxRange.duration); + + final newFocus = _focusToDuration(details.localFocalPoint.dy, newDuration); + final newStart = _lastFocus! - newFocus; + _setNewTimeRange(newStart, newDuration); + } + + void _onScaleEnd(ScaleEndDetails details) { + _initialRange = null; + _lastFocus = null; + + // The following is inspired by [_InteractiveViewerState._onScaleEnd]. + _animation?.removeListener(_onAnimate); + _animationController.reset(); + + final velocity = details.velocity.pixelsPerSecond.dy; + if (velocity.abs() < kMinFlingVelocity) return; + + final frictionSimulation = + FrictionSimulation(_kDrag, _outerOffset, -velocity); + + const effectivelyMotionless = 10.0; + final finalTime = math.log(effectivelyMotionless / velocity.abs()) / + math.log(_kDrag / 100); + + _animation = + Tween(begin: _outerOffset, end: frictionSimulation.finalX) + .animate(CurvedAnimation( + parent: _animationController, + curve: Curves.decelerate, + )); + _animationController.duration = finalTime.seconds; + _animation!.addListener(_onAnimate); + _animationController.forward(); + } + + void _onAnimate() { + if (!_animationController.isAnimating) { + _animation?.removeListener(_onAnimate); + _animation = null; + _animationController.reset(); + return; + } + + _setNewTimeRange( + _controller.maxRange.duration * (_animation!.value / _outerChildHeight), + _controller.value.duration, + ); + } + + Duration _getFocusTime(double focalPoint) { + final range = _controller.value; + return range.startTime + _focusToDuration(focalPoint, range.duration); + } + + Duration _focusToDuration( + double focalPoint, + Duration visibleDuration, + ) => + visibleDuration * (focalPoint / _parentHeight); + void _setNewTimeRange(Duration startTime, Duration duration) { + final actualStartTime = startTime.coerceIn( + _controller.maxRange.startTime, + _controller.maxRange.endTime - duration, + ); + _controller.value = + TimeRange.fromStartAndDuration(actualStartTime, duration); + } +} + +/// A modified [SingleChildScrollView] that doesn't allow drags from a pointer. +/// +/// Necessary because we handle drags ourselves to also detect zoom gestures. +class _NoDragSingleChildScrollView extends SingleChildScrollView { + /// Creates a box in which a single widget can be scrolled. + const _NoDragSingleChildScrollView({ + Key? key, + Axis scrollDirection = Axis.vertical, + bool reverse = false, + EdgeInsetsGeometry? padding, + ScrollPhysics? physics, + ScrollController? controller, + Widget? child, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + Clip clipBehavior = Clip.hardEdge, + String? restorationId, + }) : super( + key: key, + scrollDirection: scrollDirection, + reverse: reverse, + padding: padding, + controller: controller, + primary: false, + physics: physics, + child: child, + dragStartBehavior: dragStartBehavior, + clipBehavior: clipBehavior, + restorationId: restorationId, + ); + + @override + Widget build(BuildContext context) { + // This is really ugly and relies on the implementation of + // [SingleChildScrollView.build]. + final result = super.build(context) as Scrollable; + return _Scrollable( + dragStartBehavior: result.dragStartBehavior, + axisDirection: result.axisDirection, + controller: result.controller, + physics: result.physics, + restorationId: result.restorationId, + viewportBuilder: result.viewportBuilder, + ); + } +} + +class _Scrollable extends Scrollable { + const _Scrollable({ + Key? key, + AxisDirection axisDirection = AxisDirection.down, + ScrollController? controller, + ScrollPhysics? physics, + required ViewportBuilder viewportBuilder, + ScrollIncrementCalculator? incrementCalculator, + bool excludeFromSemantics = false, + int? semanticChildCount, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + String? restorationId, + }) : super( + key: key, + axisDirection: axisDirection, + controller: controller, + physics: physics, + viewportBuilder: viewportBuilder, + incrementCalculator: incrementCalculator, + excludeFromSemantics: excludeFromSemantics, + semanticChildCount: semanticChildCount, + dragStartBehavior: dragStartBehavior, + restorationId: restorationId, + ); + + @override + _ScrollableState createState() => _ScrollableState(); +} + +class _ScrollableState extends ScrollableState { + @override + @protected + void setCanDrag(bool canDrag) {} +} + +// Copied and modified from [OverflowBox]. +class _VerticalOverflowBox extends SingleChildRenderObjectWidget { + const _VerticalOverflowBox({ + Key? key, + required this.height, + required this.offset, + Widget? child, + }) : super(key: key, child: child); + + final double height; + final double offset; + + @override + _RenderVerticalOverflowBox createRenderObject(BuildContext context) { + return _RenderVerticalOverflowBox( + height: height, + offset: offset, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderVerticalOverflowBox renderObject, + ) { + renderObject.height = height; + renderObject.offset = offset; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height)); + properties.add(DoubleProperty('offset', offset)); + } +} + +// Copied and modified from [RenderConstrainedOverflowBox]. +class _RenderVerticalOverflowBox extends RenderShiftedBox { + _RenderVerticalOverflowBox({ + RenderBox? child, + required double height, + required double offset, + }) : _height = height, + _offset = offset, + super(child); + + double get height => _height; + double _height; + set height(double value) { + if (_height == value) return; + _height = value; + markNeedsLayout(); + } + + double get offset => _offset; + double _offset; + set offset(double value) { + if (_offset == value) return; + _offset = value; + markNeedsLayout(); + } + + @override + Size computeDryLayout(BoxConstraints constraints) => + _getInnerConstraints(constraints).biggest; + + @override + void performLayout() { + assert(!sizedByParent); + + if (child == null) return; + + child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); + (child!.parentData! as BoxParentData).offset = Offset(0, offset); + size = Size(child!.size.width, constraints.maxHeight); + } + + BoxConstraints _getInnerConstraints(BoxConstraints constraints) => + constraints.copyWith(minHeight: height, maxHeight: height); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height)); + properties.add(DoubleProperty('offset', offset)); + } +} diff --git a/lib/src/timetable.dart b/lib/src/timetable.dart deleted file mode 100644 index 1985c04..0000000 --- a/lib/src/timetable.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:time_machine/time_machine.dart'; - -import 'all_day.dart'; -import 'content/timetable_content.dart'; -import 'controller.dart'; -import 'event.dart'; -import 'header/timetable_header.dart'; -import 'theme.dart'; - -typedef EventBuilder = Widget Function(E event); -typedef AllDayEventBuilder = Widget Function( - BuildContext context, - E event, - AllDayEventLayoutInfo info, -); - -/// Signature for [Timetable.leadingHeaderBuilder] and -/// [Timetable.dateHeaderBuilder]. -typedef HeaderWidgetBuilder = Widget Function( - BuildContext context, - LocalDate date, -); - -/// Signature for [Timetable.onEventBackgroundTap]. -/// -/// `start` contains the time that the user tapped on. `isAllDay` indicates that -/// the tap occurred in the all-day/nnheader area. -typedef OnEventBackgroundTapCallback = void Function( - LocalDateTime start, - bool isAllDay, -); - -const double hourColumnWidth = 48; - -class Timetable extends StatelessWidget { - const Timetable({ - Key key, - @required this.controller, - @required this.eventBuilder, - this.allDayEventBuilder, - this.onEventBackgroundTap, - this.theme, - this.dateHeaderBuilder, - this.leadingHeaderBuilder, - }) : assert(controller != null), - assert(eventBuilder != null), - super(key: key); - - final TimetableController controller; - final EventBuilder eventBuilder; - - /// Optional [Widget] builder function for all-day event shown in the header. - /// - /// If not set, [eventBuilder] will be used instead. - final AllDayEventBuilder allDayEventBuilder; - final TimetableThemeData theme; - - /// Called when the user taps the background in areas where events are laid - /// out. - final OnEventBackgroundTapCallback onEventBackgroundTap; - - /// Custom builder for the left area of the header. - /// - /// If it's not provided, or the builder returns `null`, a week indicator - /// will be shown. - final HeaderWidgetBuilder leadingHeaderBuilder; - - /// Custom builder for header of a single date. - /// - /// If it's not provided, or the builder returns `null`, the day of week and - /// day of month will be shown. - final HeaderWidgetBuilder dateHeaderBuilder; - - @override - Widget build(BuildContext context) { - Widget child = Column( - children: [ - TimetableHeader( - controller: controller, - onEventBackgroundTap: onEventBackgroundTap, - leadingHeaderBuilder: leadingHeaderBuilder, - dateHeaderBuilder: dateHeaderBuilder, - allDayEventBuilder: - allDayEventBuilder ?? (_, event, __) => eventBuilder(event), - ), - Expanded( - child: TimetableContent( - controller: controller, - eventBuilder: eventBuilder, - onEventBackgroundTap: onEventBackgroundTap, - ), - ), - ], - ); - - if (theme != null) { - child = TimetableTheme(data: theme, child: child); - } - - return child; - } -} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..8d3f451 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,233 @@ +import 'package:dart_date/dart_date.dart' show Interval; +import 'package:flutter/widgets.dart' hide Interval; +import 'package:supercharged/supercharged.dart'; + +import 'week.dart'; + +export 'package:dart_date/dart_date.dart' show Interval; +export 'package:supercharged/supercharged.dart'; + +export 'utils/listenable.dart'; +export 'utils/size_reporting_widget.dart'; +export 'utils/stream_change_notifier.dart'; + +extension DoubleTimetable on double { + double coerceAtLeast(double min) => this < min ? min : this; + double coerceAtMost(double max) => this > max ? max : this; + double coerceIn(double min, double max) => + coerceAtLeast(min).coerceAtMost(max); +} + +extension ComparableTimetable> on T { + bool operator <(T other) => compareTo(other) < 0; + bool operator <=(T other) => compareTo(other) <= 0; + bool operator >(T other) => compareTo(other) > 0; + bool operator >=(T other) => compareTo(other) >= 0; + + T coerceAtLeast(T min) => (this < min) ? min : this; + T coerceAtMost(T max) => this > max ? max : this; + T coerceIn(T min, T max) => coerceAtLeast(min).coerceAtMost(max); +} + +typedef MonthWidgetBuilder = Widget Function( + BuildContext context, + DateTime month, +); +typedef WeekWidgetBuilder = Widget Function(BuildContext context, Week week); +typedef DateWidgetBuilder = Widget Function( + BuildContext context, + DateTime date, +); + +extension DateTimeTimetable on DateTime { + static DateTime date(int year, [int month = 1, int day = 1]) { + final date = DateTime.utc(year, month, day); + assert(date.isValidTimetableDate); + return date; + } + + static DateTime month(int year, int month) { + final date = DateTime.utc(year, month, 1); + assert(date.isValidTimetableMonth); + return date; + } + + DateTime copyWith({ + int? year, + int? month, + int? day, + int? hour, + int? minute, + int? second, + int? millisecond, + bool? isUtc, + }) { + return InternalDateTimeTimetable.create( + year: year ?? this.year, + month: month ?? this.month, + day: day ?? this.day, + hour: hour ?? this.hour, + minute: minute ?? this.minute, + second: second ?? this.second, + millisecond: millisecond ?? this.millisecond, + isUtc: isUtc ?? this.isUtc, + ); + } + + Duration get timeOfDay => difference(atStartOfDay); + + DateTime get atStartOfDay => + copyWith(hour: 0, minute: 0, second: 0, millisecond: 0); + bool get isAtStartOfDay => this == atStartOfDay; + DateTime get atEndOfDay => + copyWith(hour: 23, minute: 59, second: 59, millisecond: 999); + bool get isAtEndOfDay => this == atEndOfDay; + + static DateTime today() { + final date = DateTime.now().toUtc().atStartOfDay; + assert(date.isValidTimetableDate); + return date; + } + + static DateTime currentMonth() { + final month = DateTimeTimetable.today().firstDayOfMonth; + assert(month.isValidTimetableMonth); + return month; + } + + bool get isToday => atStartOfDay == DateTimeTimetable.today(); + + Interval get interval => Interval(atStartOfDay, atEndOfDay); + Interval get fullDayInterval { + assert(isValidTimetableDate); + return Interval(this, atEndOfDay); + } + + DateTime nextOrSame(int dayOfWeek) { + assert(isValidTimetableDate); + assert(weekday.isValidTimetableDayOfWeek); + + return this + ((dayOfWeek - weekday) % DateTime.daysPerWeek).days; + } + + DateTime previousOrSame(int weekday) { + assert(isValidTimetableDate); + assert(weekday.isValidTimetableDayOfWeek); + + return this - ((this.weekday - weekday) % DateTime.daysPerWeek).days; + } + + int get daysInMonth { + final february = isLeapYear ? 29 : 28; + final index = this.month - 1; + return [31, february, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][index]; + } + + DateTime get firstDayOfMonth => atStartOfDay.copyWith(day: 1); + DateTime get lastDayOfMonth => copyWith(day: daysInMonth); + + DateTime roundTimeToMultipleOf(Duration duration) { + assert(duration.isValidTimetableTimeOfDay); + return atStartOfDay + duration * (timeOfDay / duration).floor(); + } + + double get page { + assert(isValidTimetableDateTime); + return millisecondsSinceEpoch / Duration.millisecondsPerDay; + } + + int get datePage { + assert(isValidTimetableDate); + return page.floor(); + } + + static DateTime dateFromPage(int page) { + final date = DateTime.fromMillisecondsSinceEpoch( + (page * Duration.millisecondsPerDay).toInt(), + isUtc: true, + ); + assert(date.isValidTimetableDate); + return date; + } + + static DateTime dateTimeFromPage(double page) { + return DateTime.fromMillisecondsSinceEpoch( + (page * Duration.millisecondsPerDay).toInt(), + isUtc: true, + ); + } +} + +extension InternalDateTimeTimetable on DateTime { + static DateTime create({ + required int year, + int month = 1, + int day = 1, + int hour = 0, + int minute = 0, + int second = 0, + int millisecond = 0, + bool isUtc = true, + }) { + if (isUtc) { + return DateTime.utc(year, month, day, hour, minute, second, millisecond); + } + return DateTime(year, month, day, hour, minute, second, millisecond); + } + + bool operator <(DateTime other) => isBefore(other); + bool operator <=(DateTime other) => + isBefore(other) || isAtSameMomentAs(other); + bool operator >(DateTime other) => isAfter(other); + bool operator >=(DateTime other) => isAfter(other) || isAtSameMomentAs(other); + + static final List innerDateHours = + List.generate(Duration.hoursPerDay - 1, (i) => i + 1); +} + +extension NullableDateTimeTimetable on DateTime? { + bool get isValidTimetableDateTime => this == null || this!.isUtc; + bool get isValidTimetableDate => + isValidTimetableDateTime && (this == null || this!.isAtStartOfDay); + bool get isValidTimetableMonth => + isValidTimetableDate && (this == null || this!.day == 1); +} + +extension NullableDurationTimetable on Duration? { + bool get isValidTimetableTimeOfDay => + this == null || (0.days <= this! && this! <= 1.days); +} + +extension NullableIntTimetable on int? { + bool get isValidTimetableDayOfWeek => + this == null || (DateTime.monday <= this! && this! <= DateTime.sunday); + bool get isValidTimetableMonth => + this == null || (1 <= this! && this! <= DateTime.monthsPerYear); +} + +extension IntervalTimetable on Interval { + bool intersects(Interval other) => start <= other.end && end >= other.start; + + Interval get dateInterval { + final interval = Interval( + start.atStartOfDay, + (end - 1.milliseconds).atEndOfDay, + ); + assert(interval.isValidTimetableDateInterval); + return interval; + } +} + +extension NullableIntervalTimetable on Interval? { + bool get isValidTimetableInterval { + if (this == null) return true; + return this!.start.isValidTimetableDateTime && + this!.end.isValidTimetableDateTime; + } + + bool get isValidTimetableDateInterval { + return isValidTimetableInterval && + (this == null || + (this!.start.isValidTimetableDate && this!.end.isAtEndOfDay)); + } +} diff --git a/lib/src/utils/listenable.dart b/lib/src/utils/listenable.dart new file mode 100644 index 0000000..4079dc8 --- /dev/null +++ b/lib/src/utils/listenable.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +typedef Mapper = R Function(T); + +extension MapValueListenable on ValueListenable { + ValueListenable map(Mapper mapper) => + _MapValueListenable(this, mapper); +} + +class _MapValueListenable extends ValueNotifier { + _MapValueListenable(this.listenable, this.mapper) + : super(mapper(listenable.value)) { + listenable.addListener(_listener); + } + + final ValueListenable listenable; + final Mapper mapper; + + void _listener() => value = mapper(listenable.value); + + @override + void dispose() { + listenable.removeListener(_listener); + super.dispose(); + } +} diff --git a/lib/src/utils/scrolling.dart b/lib/src/utils/scrolling.dart deleted file mode 100644 index 10b3440..0000000 --- a/lib/src/utils/scrolling.dart +++ /dev/null @@ -1,379 +0,0 @@ -// Taken from (modified): https://github.com/google/flutter.widgets/blob/55cdc9a8315246732aca30fc02317f658b2e8a23/packages/linked_scroll_controller/lib/linked_scroll_controller.dart - -// Copyright 2018 the Dart project authors. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file or at -// https://developers.google.com/open-source/licenses/bsd - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -/// Sets up a collection of scroll controllers that mirror their movements to -/// each other. -/// -/// Controllers are added and returned via [addAndGet]. The initial offset -/// of the newly created controller is synced to the current offset. -/// Controllers must be `dispose`d when no longer in use to prevent memory -/// leaks and performance degradation. -/// -/// If controllers are disposed over the course of the lifetime of this -/// object the corresponding scrollables should be given unique keys. -/// Without the keys, Flutter may reuse a controller after it has been disposed, -/// which can cause the controller offsets to fall out of sync. -class LinkedScrollControllerGroup { - LinkedScrollControllerGroup({ - double initialPage = 0, - this.viewportFraction = 1, - }) : assert(initialPage != null), - assert(viewportFraction != null) { - _pageNotifier = ValueNotifier(initialPage); - } - - final double viewportFraction; - - final _allControllers = <_LinkedScrollController>[]; - - ValueNotifier _pageNotifier; - - /// The current page of the group. - double get page => _pageNotifier.value; - ValueListenable get pageListenable => _pageNotifier; - - /// Creates a new controller that is linked to any existing ones. - ScrollController addAndGet() { - final controller = _LinkedScrollController(this); - _allControllers.add(controller); - controller.addListener(() { - _pageNotifier.value = controller.page; - }); - return controller; - } - - /// Adds a callback that will be called when the value of [page] changes. - void addPageChangedListener(VoidCallback onChanged) { - _pageNotifier.addListener(onChanged); - } - - /// Removes the specified page changed listener. - void removePageChangedListener(VoidCallback listener) { - _pageNotifier.removeListener(listener); - } - - Iterable<_LinkedScrollController> get _attachedControllers => - _allControllers.where((controller) => controller.hasClients); - - /// Animates the scroll position of all linked controllers to [page]. - Future animateTo( - double page, { - @required Curve curve, - @required Duration duration, - }) async { - final animations = >[]; - for (final controller in _attachedControllers) { - animations.add(controller.animateToPage( - page, - duration: duration, - curve: curve, - )); - } - return Future.wait(animations).then((_) => null); - } - - /// Jumps the scroll position of all linked controllers to [value]. - void jumpTo(double value) { - for (final controller in _attachedControllers) { - controller.jumpToPage(value); - } - } -} - -/// A scroll controller that mirrors its movements to a peer, which must also -/// be a [_LinkedScrollController]. -class _LinkedScrollController extends ScrollController { - _LinkedScrollController(this._controllers) - : super(initialScrollOffset: _controllers.page); - - final LinkedScrollControllerGroup _controllers; - - double get page => - offset / (position.viewportDimension * _controllers.viewportFraction); - - @override - void dispose() { - _controllers._allControllers.remove(this); - super.dispose(); - } - - @override - void attach(ScrollPosition position) { - assert( - position is _LinkedScrollPosition, - '_LinkedScrollControllers can only be used with' - ' _LinkedScrollPositions.'); - final _LinkedScrollPosition linkedPosition = position; - assert(linkedPosition.owner == this, - '_LinkedScrollPosition cannot change controllers once created.'); - super.attach(position); - } - - @override - _LinkedScrollPosition createScrollPosition(ScrollPhysics physics, - ScrollContext context, ScrollPosition oldPosition) { - return _LinkedScrollPosition( - this, - physics: physics, - context: context, - initialPage: initialScrollOffset, - oldPosition: oldPosition, - ); - } - - @override - _LinkedScrollPosition get position => super.position; - - Iterable<_LinkedScrollController> get _allPeersWithClients => - _controllers._attachedControllers.where((peer) => peer != this); - - bool get canLinkWithPeers => _allPeersWithClients.isNotEmpty; - - Iterable<_LinkedScrollActivity> linkWithPeers(_LinkedScrollPosition driver) { - assert(canLinkWithPeers); - return _allPeersWithClients - .map((peer) => peer.link(driver)) - .expand((e) => e); - } - - Iterable<_LinkedScrollActivity> link(_LinkedScrollPosition driver) { - assert(hasClients); - final activities = <_LinkedScrollActivity>[]; - // ignore: prefer_final_in_for_each - for (_LinkedScrollPosition position in positions) { - activities.add(position.link(driver)); - } - return activities; - } - - Future animateToPage( - double page, { - @required Curve curve, - @required Duration duration, - }) async => - animateTo(_pageToOffset(page), curve: curve, duration: duration); - Future jumpToPage(double page) async => jumpTo(_pageToOffset(page)); - - double _pageToOffset(double page) => - page * position.viewportDimension * _controllers.viewportFraction; -} - -// Implementation details: Whenever position.setPixels or position.forcePixels -// is called on a _LinkedScrollPosition (which may happen programmatically, or -// as a result of a user action), the _LinkedScrollPosition creates a -// _LinkedScrollActivity for each linked position and uses it to move to or jump -// to the appropriate page. -// -// When a new activity begins, the set of peer activities is cleared. -class _LinkedScrollPosition extends ScrollPositionWithSingleContext { - _LinkedScrollPosition( - this.owner, { - ScrollPhysics physics, - ScrollContext context, - this.initialPage, - ScrollPosition oldPosition, - }) : assert(owner != null), - super( - physics: physics, - context: context, - initialPixels: null, - oldPosition: oldPosition, - ); - - final _LinkedScrollController owner; - double initialPage; - - final Set<_LinkedScrollActivity> _peerActivities = <_LinkedScrollActivity>{}; - - @override - bool applyViewportDimension(double viewportDimension) { - final oldViewportDimension = this.viewportDimension; - final result = super.applyViewportDimension(viewportDimension); - final oldPixels = pixels; - final page = (oldPixels == null || oldViewportDimension == 0.0) - ? initialPage - : oldPixels / - (oldViewportDimension * owner._controllers.viewportFraction); - final newPixels = - page * viewportDimension * owner._controllers.viewportFraction; - - if (newPixels != oldPixels) { - correctPixels(newPixels); - return false; - } - return result; - } - - // We override hold to propagate it to all peer controllers. - @override - ScrollHoldController hold(VoidCallback holdCancelCallback) { - for (final controller in owner._allPeersWithClients) { - controller.position._holdInternal(); - } - return super.hold(holdCancelCallback); - } - - // Calls hold without propagating to peers. - void _holdInternal() { - // Passing null to hold seems fishy, but it doesn't appear to hurt anything. - // Revisit this if bad things happen. - super.hold(null); - } - - @override - void beginActivity(ScrollActivity newActivity) { - if (newActivity == null) { - return; - } - for (final activity in _peerActivities) { - activity.unlink(this); - } - - _peerActivities.clear(); - - super.beginActivity(newActivity); - } - - @override - double setPixels(double newPixels) { - if (newPixels == pixels) { - return 0; - } - updateUserScrollDirection(newPixels - pixels > 0.0 - ? ScrollDirection.forward - : ScrollDirection.reverse); - - if (owner.canLinkWithPeers) { - _peerActivities.addAll(owner.linkWithPeers(this)); - for (final activity in _peerActivities) { - activity.moveTo(newPixels); - } - } - - return setPixelsInternal(newPixels); - } - - double setPixelsInternal(double newPixels) { - return super.setPixels(newPixels); - } - - @override - void forcePixels(double value) { - if (value == pixels) { - return; - } - updateUserScrollDirection(value - pixels > 0.0 - ? ScrollDirection.forward - : ScrollDirection.reverse); - - if (owner.canLinkWithPeers) { - _peerActivities.addAll(owner.linkWithPeers(this)); - for (final activity in _peerActivities) { - activity.jumpTo(value); - } - } - - forcePixelsInternal(value); - } - - void forcePixelsInternal(double value) { - super.forcePixels(value); - } - - _LinkedScrollActivity link(_LinkedScrollPosition driver) { - if (this.activity is! _LinkedScrollActivity) { - beginActivity(_LinkedScrollActivity(this)); - } - final _LinkedScrollActivity activity = this.activity; - // ignore: cascade_invocations - activity.link(driver); - return activity; - } - - void unlink(_LinkedScrollActivity activity) { - _peerActivities.remove(activity); - } - - // We override this method to make it public (overridden method is protected) - @override - // ignore: unnecessary_overrides - void updateUserScrollDirection(ScrollDirection value) { - super.updateUserScrollDirection(value); - } - - @override - void debugFillDescription(List description) { - super.debugFillDescription(description); - description.add('owner: $owner'); - } -} - -class _LinkedScrollActivity extends ScrollActivity { - _LinkedScrollActivity(_LinkedScrollPosition delegate) : super(delegate); - - @override - _LinkedScrollPosition get delegate => super.delegate; - - final Set<_LinkedScrollPosition> drivers = <_LinkedScrollPosition>{}; - - void link(_LinkedScrollPosition driver) { - drivers.add(driver); - } - - void unlink(_LinkedScrollPosition driver) { - drivers.remove(driver); - if (drivers.isEmpty) { - delegate?.goIdle(); - } - } - - @override - bool get shouldIgnorePointer => true; - - @override - bool get isScrolling => true; - - // _LinkedScrollActivity is not self-driven but moved by calls to the [moveTo] - // method. - @override - double get velocity => 0; - - void moveTo(double newPixels) { - _updateUserScrollDirection(); - delegate.setPixelsInternal(newPixels); - } - - void jumpTo(double newPixels) { - _updateUserScrollDirection(); - delegate.forcePixelsInternal(newPixels); - } - - void _updateUserScrollDirection() { - assert(drivers.isNotEmpty); - ScrollDirection commonDirection; - for (final driver in drivers) { - commonDirection ??= driver.userScrollDirection; - if (driver.userScrollDirection != commonDirection) { - commonDirection = ScrollDirection.idle; - } - } - delegate.updateUserScrollDirection(commonDirection); - } - - @override - void dispose() { - for (final driver in drivers) { - driver.unlink(this); - } - super.dispose(); - } -} diff --git a/lib/src/utils/size_reporting_widget.dart b/lib/src/utils/size_reporting_widget.dart new file mode 100644 index 0000000..1cd774d --- /dev/null +++ b/lib/src/utils/size_reporting_widget.dart @@ -0,0 +1,173 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +// Copied and modified from https://github.com/Limbou/expandable_page_view/blob/d692cff38f9e098ad5c020d80123a13ab2a53083/lib/size_reporting_widget.dart +class SizeReportingWidget extends StatefulWidget { + const SizeReportingWidget({ + Key? key, + required this.onSizeChanged, + required this.child, + }) : super(key: key); + + final ValueChanged onSizeChanged; + final Widget child; + + @override + _SizeReportingWidgetState createState() => _SizeReportingWidgetState(); +} + +class _SizeReportingWidgetState extends State { + final _widgetKey = GlobalKey(); + Size? _oldSize; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance!.addPostFrameCallback((_) => _notifySize()); + return NotificationListener( + onNotification: (_) { + WidgetsBinding.instance!.addPostFrameCallback((_) => _notifySize()); + return true; + }, + child: SizeChangedLayoutNotifier( + child: Container(key: _widgetKey, child: widget.child), + ), + ); + } + + void _notifySize() { + final context = _widgetKey.currentContext; + if (context == null) return; + + final size = context.size!; + if (_oldSize != size) { + _oldSize = size; + widget.onSizeChanged(size); + } + } +} + +// In order for [DatePageView] and [MonthPageView] to shrink in their cross +// axis, they first have to layout the children in their viewport, and then size +// themselves accordingly. When just observing the size, calling `setState` and +// then returning a [SizedBox], all of this is one frame delayed. +// +// To apply the size during the same layout pass, the "immediate" widgets below +// report their size during layout ([ImmediateSizeReportingWidget] and +// [ImmediateSizeReportingOverflowPage]) or request their height during layout +// ([ImmediateSizedBox]). + +// Copied and modified from: https://github.com/Limbou/expandable_page_view/blob/d692cff38f9e098ad5c020d80123a13ab2a53083/lib/expandable_page_view.dart +class ImmediateSizeReportingOverflowPage extends StatelessWidget { + const ImmediateSizeReportingOverflowPage({ + required this.onSizeChanged, + required this.child, + }); + + /// Called during layout! + final ValueChanged onSizeChanged; + final Widget child; + + @override + Widget build(BuildContext context) { + return OverflowBox( + minHeight: 0, + maxHeight: double.infinity, + alignment: Alignment.topCenter, + child: ImmediateSizeReportingWidget( + onSizeChanged: onSizeChanged, + child: child, + ), + ); + } +} + +class ImmediateSizeReportingWidget extends SingleChildRenderObjectWidget { + const ImmediateSizeReportingWidget({ + Key? key, + required this.onSizeChanged, + required Widget child, + }) : super(key: key, child: child); + + /// Called during layout! + final ValueChanged onSizeChanged; + + @override + RenderObject createRenderObject(BuildContext context) => + _ImmediateSizeReportingRenderObject(onSizeChanged); + @override + void updateRenderObject( + BuildContext context, + _ImmediateSizeReportingRenderObject renderObject, + ) { + renderObject.onSizeChanged = onSizeChanged; + } +} + +class _ImmediateSizeReportingRenderObject extends RenderProxyBox { + _ImmediateSizeReportingRenderObject(this._onSizeChanged); + + ValueChanged get onSizeChanged => _onSizeChanged; + ValueChanged _onSizeChanged; + set onSizeChanged(ValueChanged value) { + if (_onSizeChanged == value) return; + _onSizeChanged = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final oldSize = hasSize ? size : null; + super.performLayout(); + if (size != oldSize) onSizeChanged(size); + } +} + +/// A widget that requests its height during layout via [heightGetter]. +class ImmediateSizedBox extends SingleChildRenderObjectWidget { + const ImmediateSizedBox({ + Key? key, + required this.heightGetter, + required Widget child, + }) : super(key: key, child: child); + + final ValueGetter heightGetter; + + @override + RenderObject createRenderObject(BuildContext context) => + _ImmediateSizedBoxRenderObject(heightGetter); + @override + void updateRenderObject( + BuildContext context, + _ImmediateSizedBoxRenderObject renderObject, + ) { + renderObject.heightGetter = heightGetter; + } +} + +class _ImmediateSizedBoxRenderObject extends RenderProxyBox { + _ImmediateSizedBoxRenderObject(this._heightGetter); + + ValueGetter get heightGetter => _heightGetter; + ValueGetter _heightGetter; + set heightGetter(ValueGetter value) { + if (_heightGetter == value) return; + _heightGetter = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final oldHeight = hasSize ? size.height : null; + child!.layout( + constraints.tighten(height: heightGetter()), + parentUsesSize: true, + ); + if (heightGetter() != oldHeight) { + child!.layout( + constraints.tighten(height: heightGetter()), + parentUsesSize: true, + ); + } + size = Size(child!.size.width, heightGetter()); + } +} diff --git a/lib/src/utils/stream_change_notifier.dart b/lib/src/utils/stream_change_notifier.dart index e367f4e..67fecd5 100644 --- a/lib/src/utils/stream_change_notifier.dart +++ b/lib/src/utils/stream_change_notifier.dart @@ -3,11 +3,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; class StreamChangeNotifier extends ChangeNotifier { - StreamChangeNotifier(Stream stream) : assert(stream != null) { + StreamChangeNotifier(Stream stream) { _subscription = stream.listen((_) => notifyListeners()); } - StreamSubscription _subscription; + late final StreamSubscription _subscription; @override void dispose() { diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart deleted file mode 100644 index b38e3d7..0000000 --- a/lib/src/utils/utils.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:time_machine/time_machine.dart'; - -extension TimetableLocalDate on LocalDate { - bool get isToday => this == LocalDate.today(); -} - -final List innerDateHours = - List.generate(TimeConstants.hoursPerDay - 1, (i) => i + 1); - -extension TimetableLocalDateTime on LocalDateTime { - static LocalDateTime minIsoValue = - LocalDate.minIsoValue.at(LocalTime.minValue); - static LocalDateTime maxIsoValue = - LocalDate.maxIsoValue.at(LocalTime.maxValue); -} - -extension TimetableDateInterval on DateInterval { - Iterable get dates => Iterable.generate(length, start.addDays); -} - -typedef Mapper = R Function(T data); - -extension MapListenable on ValueListenable { - ValueNotifier map(Mapper mapper) => - _MapValueListenable(this, mapper); -} - -class _MapValueListenable extends ValueNotifier { - _MapValueListenable(this.listenable, this.mapper) - : assert(listenable != null), - assert(mapper != null), - super(mapper(listenable.value)) { - listenable.addListener(_listener); - } - - final ValueListenable listenable; - final Mapper mapper; - - @override - void dispose() { - listenable.removeListener(_listener); - super.dispose(); - } - - void _listener() { - value = mapper(listenable.value); - } -} diff --git a/lib/src/utils/vertical_zoom.dart b/lib/src/utils/vertical_zoom.dart deleted file mode 100644 index 3e1ebdd..0000000 --- a/lib/src/utils/vertical_zoom.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -import '../theme.dart'; - -@immutable -abstract class InitialZoom { - const InitialZoom(); - - const factory InitialZoom.zoom(double zoom) = _FactorInitialZoom; - const factory InitialZoom.range({ - double startFraction, - double endFraction, - }) = _RangeInitialZoom; - - double getContentHeight(double parentHeight); - double getOffset(double parentHeight, double contentHeight); -} - -class _FactorInitialZoom extends InitialZoom { - const _FactorInitialZoom(this.zoom) - : assert(zoom != null), - assert(zoom > 0); - - final double zoom; - - @override - double getContentHeight(double parentHeight) => parentHeight * zoom; - @override - double getOffset(double parentHeight, double contentHeight) { - // Center the viewport vertically. - return (contentHeight - parentHeight) / 2; - } -} - -class _RangeInitialZoom extends InitialZoom { - const _RangeInitialZoom({ - this.startFraction = 0, - this.endFraction = 1, - }) : assert(startFraction != null), - assert(0 <= startFraction), - assert(endFraction != null), - assert(endFraction <= 1), - assert(startFraction < endFraction); - - final double startFraction; - final double endFraction; - - @override - double getContentHeight(double parentHeight) => - parentHeight / (endFraction - startFraction); - - @override - double getOffset(double parentHeight, double contentHeight) => - contentHeight * startFraction; -} - -class VerticalZoom extends StatefulWidget { - const VerticalZoom({ - Key key, - this.initialZoom = const InitialZoom.zoom(1), - @required this.child, - this.minChildHeight = 1, - this.maxChildHeight = double.infinity, - }) : assert(initialZoom != null), - assert(child != null), - assert(minChildHeight != null), - assert(minChildHeight > 0), - assert(maxChildHeight != null), - assert(maxChildHeight > 0), - assert(minChildHeight <= maxChildHeight), - super(key: key); - - final InitialZoom initialZoom; - - final Widget child; - final double minChildHeight; - final double maxChildHeight; - - @override - _VerticalZoomState createState() => _VerticalZoomState(); -} - -class _VerticalZoomState extends State { - ScrollController _scrollController; - // We store height i/o zoom factor so our child stays constant when we change - // height. - double _contentHeight; - double _contentHeightUpdateReference; - double _lastFocus; - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - _scrollController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final timetableTheme = context.timetableTheme; - - return LayoutBuilder( - builder: (context, constraints) { - final height = constraints.maxHeight; - - _contentHeight ??= _coerceContentHeight( - widget.initialZoom.getContentHeight(height), - height, - timetableTheme, - ); - _scrollController ??= ScrollController( - initialScrollOffset: - widget.initialZoom.getOffset(height, _contentHeight), - ); - - return GestureDetector( - dragStartBehavior: DragStartBehavior.down, - onScaleStart: (details) => _onZoomStart(height, details), - onScaleUpdate: (details) => - _onZoomUpdate(height, details, timetableTheme), - child: SingleChildScrollView( - // We handle scrolling manually to improve zoom detection. - physics: NeverScrollableScrollPhysics(), - controller: _scrollController, - child: SizedBox( - height: _contentHeight, - child: widget.child, - ), - ), - ); - }, - ); - } - - void _onZoomStart(double height, ScaleStartDetails details) { - _contentHeightUpdateReference = _contentHeight; - _lastFocus = _getFocus(height, details.localFocalPoint); - } - - void _onZoomUpdate( - double height, - ScaleUpdateDetails details, - TimetableThemeData theme, - ) { - setState(() { - _contentHeight = _coerceContentHeight( - details.verticalScale * _contentHeightUpdateReference, - height, - theme, - ); - - final scrollOffset = - _lastFocus * _contentHeight - details.localFocalPoint.dy; - _scrollController.jumpTo( - scrollOffset.coerceIn(0, (_contentHeight - height).coerceAtLeast(0))); - - _lastFocus = _getFocus(height, details.localFocalPoint); - }); - } - - double _coerceContentHeight( - double childHeight, - double parentHeight, - TimetableThemeData theme, - ) { - return childHeight - .coerceIn(widget.minChildHeight, widget.maxChildHeight) - .coerceIn( - (theme?.minimumHourZoom ?? 0) * parentHeight, - (theme?.maximumHourZoom ?? double.infinity) * parentHeight, - ); - } - - double _getFocus(double height, Offset focalPoint) => - (_scrollController.offset + focalPoint.dy) / _contentHeight; -} diff --git a/lib/src/visible_range.dart b/lib/src/visible_range.dart deleted file mode 100644 index d62459a..0000000 --- a/lib/src/visible_range.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/physics.dart'; -import 'package:meta/meta.dart'; -import 'package:time_machine/time_machine.dart'; - -import 'controller.dart'; -import 'timetable.dart'; - -abstract class VisibleRange { - const VisibleRange({ - @required this.visibleDays, - }) : assert(visibleDays != null), - assert(visibleDays > 0); - - /// Display a fixed number of days. - /// - /// While scrolling, this can snap to all dates. - /// - /// When animating to a date (see [TimetableController.animateTo]), that day - /// will be aligned to the left. - const factory VisibleRange.days(int count) = DaysVisibleRange; - - /// Display seven consecutive days, aligned based on - /// [TimetableController.firstDayOfWeek]. - /// - /// While scrolling, this only snaps to week boundaries. - /// - /// When animating to a date (see [TimetableController.animateTo]), the week - /// containing that date will fill the viewport. - const factory VisibleRange.week() = WeekVisibleRange; - - final int visibleDays; - - /// Convenience method of [getTargetPageForFocus] taking a [LocalDate]. - double getTargetPageForFocusDate( - LocalDate focusDate, DayOfWeek firstDayOfWeek) { - assert(focusDate != null); - return getTargetPageForFocus(focusDate.epochDay.toDouble(), firstDayOfWeek); - } - - /// Gets the page to align to the viewport's left side based on the - /// [focusPage] to show. - double getTargetPageForFocus(double focusPage, DayOfWeek firstDayOfWeek); - - /// Gets the page to align to the viewport's left side based on the - /// [currentPage] in that position. - double getTargetPageForCurrent( - double currentPage, - DayOfWeek firstDayOfWeek, { - double velocity = 0, - Tolerance tolerance = Tolerance.defaultTolerance, - }); - - @protected - double getDefaultVelocityAddition(double velocity, Tolerance tolerance) { - assert(velocity != null); - assert(tolerance != null); - - return velocity.abs() > tolerance.velocity ? 0.5 * velocity.sign : 0.0; - } -} - -class DaysVisibleRange extends VisibleRange { - const DaysVisibleRange(int count) : super(visibleDays: count); - - @override - double getTargetPageForFocus(double focusPage, DayOfWeek firstDayOfWeek) => - getTargetPageForCurrent(focusPage, firstDayOfWeek); - - @override - double getTargetPageForCurrent( - double focusPage, - DayOfWeek firstDayOfWeek, { - double velocity = 0, - Tolerance tolerance = Tolerance.defaultTolerance, - }) { - assert(focusPage != null); - assert(firstDayOfWeek != null); - assert(velocity != null); - assert(tolerance != null); - - final velocityAddition = getDefaultVelocityAddition(velocity, tolerance); - return (focusPage + velocityAddition).roundToDouble(); - } -} - -/// The [Timetable] will show exactly one week and will snap to week boundaries. -/// -/// You can configure the first day of a week via -/// [TimetableController.firstDayOfWeek]. -class WeekVisibleRange extends VisibleRange { - const WeekVisibleRange() : super(visibleDays: TimeConstants.daysPerWeek); - - @override - double getTargetPageForFocus( - double focusPage, - DayOfWeek firstDayOfWeek, { - double velocity = 0, - Tolerance tolerance = Tolerance.defaultTolerance, - }) { - assert(focusPage != null); - assert(firstDayOfWeek != null); - assert(velocity != null); - assert(tolerance != null); - - final epochWeekDayOffset = - firstDayOfWeek.value - LocalDate.fromEpochDay(0).dayOfWeek.value; - final focusWeek = - (focusPage - epochWeekDayOffset) / TimeConstants.daysPerWeek; - - final velocityAddition = getDefaultVelocityAddition(velocity, tolerance); - final targetWeek = (focusWeek + velocityAddition).floorToDouble(); - return targetWeek * TimeConstants.daysPerWeek + epochWeekDayOffset; - } - - @override - double getTargetPageForCurrent( - double focusPage, - DayOfWeek firstDayOfWeek, { - double velocity = 0, - Tolerance tolerance = Tolerance.defaultTolerance, - }) { - return getTargetPageForFocus( - focusPage + TimeConstants.daysPerWeek / 2, - firstDayOfWeek, - velocity: velocity, - tolerance: tolerance, - ); - } -} diff --git a/lib/src/week.dart b/lib/src/week.dart new file mode 100644 index 0000000..a4dffbf --- /dev/null +++ b/lib/src/week.dart @@ -0,0 +1,86 @@ +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +import 'utils.dart'; + +extension DateTimeWeekTimetable on DateTime { + Week get week => Week.forDate(this); + + int get dayOfYear { + const common = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + const leapOffsets = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; + final offsets = isLeapYear ? leapOffsets : common; + return offsets[month - 1] + day; + } + + bool get isLeapYear => year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); +} + +@immutable +class Week implements Comparable { + const Week(this.weekBasedYear, this.weekOfYear) + : assert(weekOfYear >= 1 && weekOfYear <= 53); + + factory Week.forDate(DateTime date) { + assert(date.isValidTimetableDate); + + // Algorithm from https://en.wikipedia.org/wiki/ISO_week_date#Calculating_the_week_number_from_a_month_and_day_of_the_month_or_ordinal_date + final year = date.year; + final weekOfYear = (10 + date.dayOfYear - date.weekday) ~/ 7; + + if (weekOfYear == 0) { + // If the week number thus obtained equals 0, it means that the given date + // belongs to the preceding (week-based) year. + final weekOfYear = + DateTimeTimetable.date(year - 1, 12, 31).week.weekOfYear; + return Week(year - 1, weekOfYear); + } + + if (weekOfYear == 53 && + DateTime(year, 12, 31).weekday < DateTime.thursday) { + // If a week number of 53 is obtained, one must check that the date is not + // actually in week 1 of the following year. + return Week(year + 1, 1); + } + + return Week(year, weekOfYear); + } + + final int weekBasedYear; + final int weekOfYear; + + DateTime getDayOfWeek(int dayOfWeek) { + assert(dayOfWeek.isValidTimetableDayOfWeek); + + // Algorithm from https://en.wikipedia.org/wiki/ISO_week_date#`Calculating_an_ordinal_or_month_date_from_a_week_date` + final base = weekOfYear * DateTime.daysPerWeek + dayOfWeek; + final yearCorrection = + DateTimeTimetable.date(weekBasedYear, 1, 4).weekday + 3; + return DateTimeTimetable.date(weekBasedYear, 1, 1) + + (base - yearCorrection - 1).days; + } + + @override + int compareTo(Week other) { + final result = weekBasedYear.compareTo(other.weekBasedYear); + if (result != 0) return result; + return weekOfYear.compareTo(other.weekOfYear); + } + + @override + int get hashCode => hashValues(weekBasedYear, weekOfYear); + + @override + bool operator ==(Object other) { + return other is Week && + other.weekBasedYear == weekBasedYear && + other.weekOfYear == weekOfYear; + } + + @override + String toString() { + final paddedWeek = weekOfYear < 10 ? '0$weekOfYear' : weekOfYear.toString(); + return '$weekBasedYear-W$paddedWeek'; + } +} diff --git a/lib/timetable.dart b/lib/timetable.dart index e3934a0..9840ec8 100644 --- a/lib/timetable.dart +++ b/lib/timetable.dart @@ -1,11 +1,47 @@ library timetable; -export 'src/all_day.dart' show AllDayEventLayoutInfo; -export 'src/basic.dart'; -export 'src/controller.dart'; -export 'src/event.dart'; -export 'src/event_provider.dart' show EventProvider, StreamedEventGetter; -export 'src/initial_time_range.dart'; +export 'src/callbacks.dart'; +export 'src/components/date_dividers.dart'; +export 'src/components/date_events.dart'; +export 'src/components/date_header.dart'; +export 'src/components/date_indicator.dart'; +export 'src/components/hour_dividers.dart'; +export 'src/components/month_indicator.dart'; +export 'src/components/month_widget.dart'; +export 'src/components/multi_date_content.dart'; +export 'src/components/multi_date_event_header.dart'; +export 'src/components/now_indicator.dart'; +export 'src/components/time_indicator.dart'; +export 'src/components/time_indicators.dart'; +export 'src/components/week_indicator.dart'; +export 'src/components/weekday_indicator.dart'; +export 'src/config.dart'; +export 'src/date/controller.dart'; +export 'src/date/date_page_view.dart'; +export 'src/date/month_page_view.dart'; +export 'src/date/visible_date_range.dart'; +export 'src/event/all_day.dart'; +export 'src/event/basic.dart'; +export 'src/event/event.dart'; +export 'src/event/provider.dart'; +export 'src/layouts/compact_month.dart'; +export 'src/layouts/multi_date.dart'; +export 'src/layouts/recurring_multi_date.dart'; +export 'src/localization.dart'; export 'src/theme.dart'; -export 'src/timetable.dart'; -export 'src/visible_range.dart'; +export 'src/time/controller.dart'; +export 'src/time/overlay.dart'; +export 'src/time/time_range.dart'; +export 'src/time/zoom.dart'; +export 'src/utils.dart' + show + DateWidgetBuilder, + DateTimeTimetable, + IntervalTimetable, + MonthWidgetBuilder, + NullableDateTimeTimetable, + NullableDurationTimetable, + NullableIntTimetable, + NullableIntervalTimetable, + WeekWidgetBuilder; +export 'src/week.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index ef688c6..b142d45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,25 +1,29 @@ name: timetable -description: "📅 Customizable, animated calendar widget including day & week views" +description: '📅 Customizable, animated calendar widget including day, week, and month views' version: 0.2.7 homepage: https://github.com/JonasWanke/timetable environment: - sdk: ">=2.7.0 <3.0.0" - flutter: ">=1.17.0" + sdk: '>=2.12.0 <3.0.0' + flutter: '>=2.0.0' dependencies: + black_hole_flutter: ^0.3.0 + collection: ^1.15.0 + dart_date: ^1.1.0-nullsafety.0 flutter: sdk: flutter - - black_hole_flutter: ^0.2.11 - collection: ^1.14.11 - dartx: ^0.5.0 - meta: ^1.1.8 - pedantic: ^1.8.0+1 - rxdart: ^0.24.0 - time_machine: ^0.9.12 + flutter_layout_grid: ^1.0.1 + intl: ^0.17.0 + meta: ^1.3.0 + rxdart: ^0.26.0 + supercharged: ^2.0.0 + tuple: ^2.0.0 dev_dependencies: + extra_pedantic: ^1.3.0 flutter_test: sdk: flutter + glados: ^1.0.0 test: ^1.9.4 + tuple_glados: ^1.0.0 diff --git a/test/date/visible_date_range_test.dart b/test/date/visible_date_range_test.dart new file mode 100644 index 0000000..ff62ce3 --- /dev/null +++ b/test/date/visible_date_range_test.dart @@ -0,0 +1,73 @@ +import 'package:glados/glados.dart'; +import 'package:test/test.dart'; +import 'package:timetable/timetable.dart'; +import 'package:tuple_glados/tuple_glados.dart'; + +void main() { + group('VisibleDateRange.days', () { + Glados(any.tuple2(any.positiveInt, any.int)).test('getTargetPageForFocus', + (it) { + final rangeSize = it.item1; + final page = it.item2.toDouble(); + expect( + VisibleDateRange.days(rangeSize).getTargetPageForFocus(page), + page, + ); + }); + + Glados(any.tuple2(any.positiveInt, any.double)) + .test('getTargetPageForCurrent', (it) { + final rangeSize = it.item1; + final page = it.item2; + expect( + VisibleDateRange.days(rangeSize).getTargetPageForCurrent(page), + page.round(), + ); + }); + }); + + group('VisibleDateRange.week', () { + Glados(any.tuple2(any.dayOfWeek, any.int)).test('getTargetPageForFocus', + (it) { + final startOfWeek = it.item1; + final page = it.item2; + + final daysFromWeekStart = + (DateTimeTimetable.dateFromPage(page).weekday - startOfWeek) % + DateTime.daysPerWeek; + expect( + VisibleDateRange.week(startOfWeek: startOfWeek) + .getTargetPageForFocus(page.toDouble()), + page - daysFromWeekStart, + ); + }); + + Glados(any.tuple2(any.dayOfWeek, any.double)) + .test('getTargetPageForCurrent', (it) { + final startOfWeek = it.item1; + final page = it.item2; + + final daysFromWeekStart = + (DateTimeTimetable.dateFromPage(page.floor()).weekday + + page % 1 - + startOfWeek) % + DateTime.daysPerWeek; + var targetPage = page - daysFromWeekStart; + if (daysFromWeekStart > DateTime.daysPerWeek / 2) { + targetPage += DateTime.daysPerWeek; + } + + expect( + VisibleDateRange.week(startOfWeek: startOfWeek) + .getTargetPageForCurrent(page.toDouble()), + targetPage, + ); + }); + }); +} + +extension AnyTimetable on Any { + Generator get date => any.int.map(DateTimeTimetable.dateFromPage); + Generator get dayOfWeek => + any.intInRange(DateTime.monday, DateTime.sunday); +} diff --git a/test/event_test.dart b/test/event_test.dart index 9e8e26f..84b7148 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -1,107 +1,80 @@ import 'package:test/test.dart'; -import 'package:time_machine/time_machine.dart'; -import 'package:dartx/dartx.dart'; -import 'package:timetable/src/event.dart'; +import 'package:timetable/src/event/event.dart'; +import 'package:timetable/src/utils.dart'; void main() { group('TimetableEvent', () { - final startDate = LocalDate(2020, 1, 1); - final start = startDate.atMidnight(); - final day = Period(days: 1); + final start = DateTime.utc(2020, 1, 1); final events = [ _TestEvent(start, start), - _TestEvent(start, start + day), - _TestEvent(start, start + Period(days: 2)), - _TestEvent(start.addHours(10), start.addHours(12)), + _TestEvent(start, start + 1.days), + _TestEvent(start, start + 2.days), + _TestEvent(start + 10.hours, start + 12.hours), _TestEvent( - start + Period(hours: 10), - start + Period(days: 1, hours: 12), + start + Duration(hours: 10), + start + Duration(days: 1, hours: 12), ), _TestEvent( - start + Period(hours: 10), - start + Period(days: 2, hours: 12), + start + Duration(hours: 10), + start + Duration(days: 2, hours: 12), ), ]; test('intersectsInterval', () { final intervals = [ { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): false, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): true, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): false, }, { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): false, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): true, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): false, }, { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): true, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): true, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): true, }, { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): false, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): false, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): false, }, { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): true, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): false, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): true, }, { - DateInterval(startDate - day, startDate - day): false, - DateInterval(startDate, startDate): true, - DateInterval(startDate, startDate + day): true, - DateInterval(startDate + day, startDate + day): true, + Interval(start - 1.days, start - 1.days): false, + Interval(start, start): false, + Interval(start, start + 1.days): true, + Interval(start + 1.days, start + 1.days): true, }, ]; - for (final index in events.indices) { + for (var index = 0; index < events.length; index++) { final event = events[index]; final ints = intervals[index]; expect( - ints.keys.map(event.intersectsInterval), + ints.keys.map(event.interval.intersects), ints.values, reason: 'index: $index', ); } }); - - test('endDateInclusive', () { - expect(events.map((e) => e.endDateInclusive), [ - startDate, - startDate, - startDate + day, - startDate, - startDate + day, - startDate + Period(days: 2), - ]); - }); - - test('intersectingDates', () { - expect(events.map((e) => e.intersectingDates), [ - DateInterval(startDate, startDate), - DateInterval(startDate, startDate), - DateInterval(startDate, startDate + day), - DateInterval(startDate, startDate), - DateInterval(startDate, startDate + day), - DateInterval(startDate, startDate + Period(days: 2)), - ]); - }); }); } class _TestEvent extends Event { - const _TestEvent( - LocalDateTime start, - LocalDateTime end, - ) : super(id: '', start: start, end: end); + const _TestEvent(DateTime start, DateTime end) + : super(start: start, end: end); } diff --git a/test/utils/vertical_zoom_test.dart b/test/utils/vertical_zoom_test.dart deleted file mode 100644 index a6acfd8..0000000 --- a/test/utils/vertical_zoom_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:timetable/src/utils/vertical_zoom.dart'; - -void main() { - group('InitialZoom', () { - group('zoom', () { - test('default', () { - final initial = InitialZoom.zoom(1); - expect(initial.getContentHeight(200), equals(200)); - expect(initial.getOffset(200, 200), equals(0)); - }); - test('double', () { - final initial = InitialZoom.zoom(2); - expect(initial.getContentHeight(200), equals(400)); - expect(initial.getOffset(200, 400), equals(100)); - }); - }); - - group('range', () { - test('default', () { - final initial = InitialZoom.range(); - expect(initial.getContentHeight(200), equals(200)); - expect(initial.getOffset(200, 200), equals(0)); - }); - test('top half', () { - final initial = InitialZoom.range(endFraction: 0.5); - expect(initial.getContentHeight(200), equals(400)); - expect(initial.getOffset(200, 200), equals(0)); - }); - test('center half', () { - final initial = InitialZoom.range( - startFraction: 0.25, - endFraction: 0.75, - ); - expect(initial.getContentHeight(200), equals(400)); - expect(initial.getOffset(200, 400), equals(100)); - }); - }); - }); - - group('drag & zoom', () { - final parentFinder = find.byType(VerticalZoom).first; - double getParentHeight(WidgetTester tester) => - tester.getSize(parentFinder).height; - - final childFinder = find.byType(Container).first; - double getChildHeight(WidgetTester tester) => - tester.getSize(childFinder).height; - double getChildOffset(WidgetTester tester) { - return tester - .widget(find.byType(SingleChildScrollView)) - .controller - .offset; - } - - testWidgets('initial', (tester) async { - await tester.pumpWidget(VerticalZoom( - maxChildHeight: double.infinity, - child: Container(), - )); - expect(getChildHeight(tester), equals(getParentHeight(tester))); - expect(getChildOffset(tester), equals(0)); - }); - testWidgets('drag w/o zoom', (tester) async { - await tester.pumpWidget(VerticalZoom( - maxChildHeight: double.infinity, - child: Container(), - )); - - await tester.drag(parentFinder, Offset(0, 100)); - expect(getChildOffset(tester), equals(0)); - - await tester.drag(parentFinder, Offset(0, -100)); - expect(getChildHeight(tester), equals(getParentHeight(tester))); - expect(getChildOffset(tester), equals(0)); - }); - testWidgets('drag w/ zoom', (tester) async { - await tester.pumpWidget(VerticalZoom( - initialZoom: InitialZoom.zoom(2), - maxChildHeight: double.infinity, - child: Container(), - )); - - final initialOffset = getParentHeight(tester) / 2; - expect(getChildHeight(tester), equals(2 * getParentHeight(tester))); - expect(getChildOffset(tester), equals(initialOffset)); - - await tester.drag(childFinder, Offset(0, 100)); - expect(getChildHeight(tester), equals(2 * getParentHeight(tester))); - expect(getChildOffset(tester), equals(initialOffset - 100)); - - await tester.drag(parentFinder, Offset(0, -100)); - expect(getChildHeight(tester), equals(2 * getParentHeight(tester))); - expect(getChildOffset(tester), equals(initialOffset)); - }); - }); -} diff --git a/test/visible_range_test.dart b/test/visible_range_test.dart deleted file mode 100644 index 9b40e73..0000000 --- a/test/visible_range_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:test/test.dart'; -import 'package:time_machine/time_machine.dart'; -import 'package:timetable/src/visible_range.dart'; - -void main() { - VisibleRange visibleRange; - - group('VisibleRange.days', () { - setUp(() => visibleRange = VisibleRange.days(3)); - - test('getTargetPageForFocus', () { - // Monday of week 2020-01 - final startDate = LocalDate(2019, 12, 30); - - expect( - 0.rangeTo(2).map((offset) { - return visibleRange.getTargetPageForFocusDate( - startDate + Period(days: offset), - DayOfWeek.monday, - ); - }), - equals(0.rangeTo(2).map((offset) => startDate.epochDay + offset)), - ); - }); - - test('getTargetPageForCurrent', () { - // Monday of week 2020-01 - final startPage = LocalDate(2019, 12, 30).epochDay.toDouble(); - - final values = { - startPage: startPage, - startPage - 0.4: startPage, - startPage + 0.4: startPage, - startPage + 1: startPage + 1, - }; - expect( - values.keys.map((current) { - return visibleRange.getTargetPageForCurrent( - current, - DayOfWeek.monday, - ); - }), - equals(values.values), - ); - }); - }); - - group('VisibleRange.week', () { - setUp(() => visibleRange = VisibleRange.week()); - - group('getTargetPageForFocus', () { - LocalDate getTargetDate(LocalDate focusDate) { - final targetPage = - visibleRange.getTargetPageForFocusDate(focusDate, DayOfWeek.monday); - return LocalDate.fromEpochDay(targetPage.toInt()); - } - - Iterable getTargetDates(int weekNumber) { - return [ - DayOfWeek.monday, - DayOfWeek.tuesday, - DayOfWeek.wednesday, - DayOfWeek.thursday, - DayOfWeek.friday, - DayOfWeek.saturday, - DayOfWeek.sunday, - ] - .map((d) => WeekYearRules.iso - .getLocalDate(2020, weekNumber, d, CalendarSystem.iso)) - .map(getTargetDate); - } - - test('week 2020-1', () { - expect( - getTargetDates(1), - everyElement(equals(LocalDate(2019, 12, 30))), - ); - }); - test('week 2020-18', () { - expect( - getTargetDates(18), - everyElement(equals(LocalDate(2020, 4, 27))), - ); - }); - }); - - test('getTargetPageForCurrent', () { - // Monday of week 2020-01 - final startPage = LocalDate(2019, 12, 30).epochDay.toDouble(); - - expect( - (-3).rangeTo(3).map((offset) { - return visibleRange.getTargetPageForCurrent( - startPage + offset, - DayOfWeek.monday, - ); - }), - everyElement(equals(startPage)), - ); - }); - }); -}