diff --git a/docs/pages/docs/calendar.mdx b/docs/pages/docs/calendar.mdx
new file mode 100644
index 000000000..e61f480fb
--- /dev/null
+++ b/docs/pages/docs/calendar.mdx
@@ -0,0 +1,91 @@
+import { Tabs } from 'nextra/components';
+import { Widget } from "../../components/widget";
+
+# Calendar
+A date field component that allows users to enter and edit date.
+
+The calendar pages are designed to be navigable through swipe gestures on mobile platforms, allowing left and right swipes
+to transition between pages.
+
+
+
+
+
+
+ ```dart
+ FCalendar(
+ controller: FCalendarSingleRangeController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+ ```
+
+
+
+## Usage
+
+### `FCalendar(...)`
+
+```dart
+FCalendar(
+ controller: FCalendarSingleRangeController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ today: DateTime.utc(2024, 7, 14),
+ initalType = FCalendarPickerType.yearMonth,
+ initialDate = DateTime.utc(2024, 9, 12),
+ enabled: (date) => allowed.contains(date),
+ onMonthChange: (date) => print(date),
+ onPress: (date) => print(date),
+ onLongPress: (date) => print(date),
+);
+```
+## Examples
+
+### Single Date
+
+
+
+
+
+ ```dart
+ FCalendar(
+ controller: FCalendarSingleValueController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+ ```
+
+
+
+### Multiple Dates
+
+
+
+
+
+ ```dart
+ FCalendar(
+ controller: FCalendarMultiValueController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+ ```
+
+
+
+### Single Range
+
+
+
+
+
+ ```dart
+ FCalendar(
+ controller: FCalendarSingleRangeController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ )
+ ```
+
+
diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md
index ea9447369..66d455791 100644
--- a/forui/CHANGELOG.md
+++ b/forui/CHANGELOG.md
@@ -1,5 +1,8 @@
## Next
+### Additions
+* Add `FCalendar`
+
### Enhancements
* **Breaking** Change `FSwitch` to be usable in `Form`s.
* **Breaking** Rename `FThemeData.checkBoxStyle` to `FThemeData.checkboxStyle` for consistency.
diff --git a/forui/example/.metadata b/forui/example/.metadata
index 62095849c..bb11ee0c9 100644
--- a/forui/example/.metadata
+++ b/forui/example/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
+ revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49"
channel: "stable"
project_type: app
@@ -13,17 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
- create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- - platform: android
- create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- - platform: ios
- create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- - platform: web
- create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: macos
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
# User provided section
diff --git a/forui/example/macos/.gitignore b/forui/example/macos/.gitignore
new file mode 100644
index 000000000..746adbb6b
--- /dev/null
+++ b/forui/example/macos/.gitignore
@@ -0,0 +1,7 @@
+# Flutter-related
+**/Flutter/ephemeral/
+**/Pods/
+
+# Xcode-related
+**/dgph
+**/xcuserdata/
diff --git a/forui/example/macos/Flutter/Flutter-Debug.xcconfig b/forui/example/macos/Flutter/Flutter-Debug.xcconfig
new file mode 100644
index 000000000..4b81f9b2d
--- /dev/null
+++ b/forui/example/macos/Flutter/Flutter-Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/forui/example/macos/Flutter/Flutter-Release.xcconfig b/forui/example/macos/Flutter/Flutter-Release.xcconfig
new file mode 100644
index 000000000..5caa9d157
--- /dev/null
+++ b/forui/example/macos/Flutter/Flutter-Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/forui/example/macos/Podfile b/forui/example/macos/Podfile
new file mode 100644
index 000000000..c795730db
--- /dev/null
+++ b/forui/example/macos/Podfile
@@ -0,0 +1,43 @@
+platform :osx, '10.14'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_macos_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+ use_modular_headers!
+
+ flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_macos_build_settings(target)
+ end
+end
diff --git a/forui/example/macos/Podfile.lock b/forui/example/macos/Podfile.lock
new file mode 100644
index 000000000..a83eba5b7
--- /dev/null
+++ b/forui/example/macos/Podfile.lock
@@ -0,0 +1,23 @@
+PODS:
+ - FlutterMacOS (1.0.0)
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+
+DEPENDENCIES:
+ - FlutterMacOS (from `Flutter/ephemeral`)
+ - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
+
+EXTERNAL SOURCES:
+ FlutterMacOS:
+ :path: Flutter/ephemeral
+ path_provider_foundation:
+ :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
+
+SPEC CHECKSUMS:
+ FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
+ path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
+
+PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
+
+COCOAPODS: 1.15.2
diff --git a/forui/example/macos/Runner.xcodeproj/project.pbxproj b/forui/example/macos/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..20f21ad0a
--- /dev/null
+++ b/forui/example/macos/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,801 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXAggregateTarget section */
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
+ isa = PBXAggregateTarget;
+ buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
+ buildPhases = (
+ 33CC111E2044C6BF0003C045 /* ShellScript */,
+ );
+ dependencies = (
+ );
+ name = "Flutter Assemble";
+ productName = FLX;
+ };
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+ 156C1B9468344A0D7441B641 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2FB2F8172F2DE440F71D5029 /* Pods_Runner.framework */; };
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+ ECE7C92D21414069E3C48C40 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80A04D67BBD004997223452D /* Pods_RunnerTests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC10EC2044A3C60003C045;
+ remoteInfo = Runner;
+ };
+ 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC111A2044C6BA0003C045;
+ remoteInfo = FLX;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 33CC110E2044A8840003C045 /* Bundle Framework */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Bundle Framework";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 2FB2F8172F2DE440F71D5029 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; };
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; };
+ 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; };
+ 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; };
+ 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; };
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; };
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; };
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; };
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; };
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; };
+ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; };
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; };
+ 3DAD8F25A2AB4A7DBB75FC6F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 4958E3937C0A70E735B4DDD7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ 56B8E47EFD637C217518CBDD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; };
+ 80A04D67BBD004997223452D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; };
+ C13BA42F9034C705246445D5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ CCF8DB4CCC737BEAD46CAD00 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+ DBACD1025B85B50A10E7DD41 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 331C80D2294CF70F00263BE5 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ ECE7C92D21414069E3C48C40 /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EA2044A3C60003C045 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 156C1B9468344A0D7441B641 /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C80D6294CF71000263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 33BA886A226E78AF003329D5 /* Configs */ = {
+ isa = PBXGroup;
+ children = (
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
+ );
+ path = Configs;
+ sourceTree = "";
+ };
+ 33CC10E42044A3C60003C045 = {
+ isa = PBXGroup;
+ children = (
+ 33FAB671232836740065AC1E /* Runner */,
+ 33CEB47122A05771004F2AC0 /* Flutter */,
+ 331C80D6294CF71000263BE5 /* RunnerTests */,
+ 33CC10EE2044A3C60003C045 /* Products */,
+ D73912EC22F37F3D000D13A0 /* Frameworks */,
+ 3EE9245861A5305E78613D5F /* Pods */,
+ );
+ sourceTree = "";
+ };
+ 33CC10EE2044A3C60003C045 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10ED2044A3C60003C045 /* example.app */,
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 33CC11242044D66E0003C045 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */,
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */,
+ 33CC10F72044A3C60003C045 /* Info.plist */,
+ );
+ name = Resources;
+ path = ..;
+ sourceTree = "";
+ };
+ 33CEB47122A05771004F2AC0 /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
+ );
+ path = Flutter;
+ sourceTree = "";
+ };
+ 33FAB671232836740065AC1E /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */,
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */,
+ 33E51914231749380026EE4D /* Release.entitlements */,
+ 33CC11242044D66E0003C045 /* Resources */,
+ 33BA886A226E78AF003329D5 /* Configs */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ 3EE9245861A5305E78613D5F /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 3DAD8F25A2AB4A7DBB75FC6F /* Pods-Runner.debug.xcconfig */,
+ 56B8E47EFD637C217518CBDD /* Pods-Runner.release.xcconfig */,
+ CCF8DB4CCC737BEAD46CAD00 /* Pods-Runner.profile.xcconfig */,
+ C13BA42F9034C705246445D5 /* Pods-RunnerTests.debug.xcconfig */,
+ 4958E3937C0A70E735B4DDD7 /* Pods-RunnerTests.release.xcconfig */,
+ DBACD1025B85B50A10E7DD41 /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+ D73912EC22F37F3D000D13A0 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 2FB2F8172F2DE440F71D5029 /* Pods_Runner.framework */,
+ 80A04D67BBD004997223452D /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C80D4294CF70F00263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ B7E3014888957E4A9ABCF014 /* [CP] Check Pods Manifest.lock */,
+ 331C80D1294CF70F00263BE5 /* Sources */,
+ 331C80D2294CF70F00263BE5 /* Frameworks */,
+ 331C80D3294CF70F00263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 33CC10EC2044A3C60003C045 /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 189688E4FB54772A33DBC4D9 /* [CP] Check Pods Manifest.lock */,
+ 33CC10E92044A3C60003C045 /* Sources */,
+ 33CC10EA2044A3C60003C045 /* Frameworks */,
+ 33CC10EB2044A3C60003C045 /* Resources */,
+ 33CC110E2044A8840003C045 /* Bundle Framework */,
+ 3399D490228B24CF009A79C7 /* ShellScript */,
+ 83F050BBD0D3DF2DC03A970A /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */,
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 33CC10ED2044A3C60003C045 /* example.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 33CC10E52044A3C60003C045 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastSwiftUpdateCheck = 0920;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C80D4294CF70F00263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 33CC10EC2044A3C60003C045;
+ };
+ 33CC10EC2044A3C60003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ LastSwiftMigration = 1100;
+ ProvisioningStyle = Automatic;
+ SystemCapabilities = {
+ com.apple.Sandbox = {
+ enabled = 1;
+ };
+ };
+ };
+ 33CC111A2044C6BA0003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ ProvisioningStyle = Manual;
+ };
+ };
+ };
+ buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 33CC10E42044A3C60003C045;
+ productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 33CC10EC2044A3C60003C045 /* Runner */,
+ 331C80D4294CF70F00263BE5 /* RunnerTests */,
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C80D3294CF70F00263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EB2044A3C60003C045 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 189688E4FB54772A33DBC4D9 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 3399D490228B24CF009A79C7 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
+ };
+ 33CC111E2044C6BF0003C045 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ Flutter/ephemeral/FlutterInputs.xcfilelist,
+ );
+ inputPaths = (
+ Flutter/ephemeral/tripwire,
+ );
+ outputFileListPaths = (
+ Flutter/ephemeral/FlutterOutputs.xcfilelist,
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
+ };
+ 83F050BBD0D3DF2DC03A970A /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ B7E3014888957E4A9ABCF014 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C80D1294CF70F00263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10E92044A3C60003C045 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC10EC2044A3C60003C045 /* Runner */;
+ targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
+ };
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
+ targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 33CC10F52044A3C60003C045 /* Base */,
+ );
+ name = MainMenu.xib;
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 331C80DB294CF71000263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = C13BA42F9034C705246445D5 /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.foruslabs.forui.samples.example.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example";
+ };
+ name = Debug;
+ };
+ 331C80DC294CF71000263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 4958E3937C0A70E735B4DDD7 /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.foruslabs.forui.samples.example.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example";
+ };
+ name = Release;
+ };
+ 331C80DD294CF71000263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = DBACD1025B85B50A10E7DD41 /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.foruslabs.forui.samples.example.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example";
+ };
+ name = Profile;
+ };
+ 338D0CE9231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Profile;
+ };
+ 338D0CEA231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Profile;
+ };
+ 338D0CEB231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Profile;
+ };
+ 33CC10F92044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 33CC10FA2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ 33CC10FC2044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 33CC10FD2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 33CC111C2044C6BA0003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Debug;
+ };
+ 33CC111D2044C6BA0003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C80DB294CF71000263BE5 /* Debug */,
+ 331C80DC294CF71000263BE5 /* Release */,
+ 331C80DD294CF71000263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10F92044A3C60003C045 /* Debug */,
+ 33CC10FA2044A3C60003C045 /* Release */,
+ 338D0CE9231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10FC2044A3C60003C045 /* Debug */,
+ 33CC10FD2044A3C60003C045 /* Release */,
+ 338D0CEA231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC111C2044C6BA0003C045 /* Debug */,
+ 33CC111D2044C6BA0003C045 /* Release */,
+ 338D0CEB231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 33CC10E52044A3C60003C045 /* Project object */;
+}
diff --git a/forui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/forui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 000000000..18d981003
--- /dev/null
+++ b/forui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/forui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/forui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 000000000..15368eccb
--- /dev/null
+++ b/forui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/forui/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/forui/example/macos/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..21a3cc14c
--- /dev/null
+++ b/forui/example/macos/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/forui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/forui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 000000000..18d981003
--- /dev/null
+++ b/forui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/forui/example/macos/Runner/AppDelegate.swift b/forui/example/macos/Runner/AppDelegate.swift
new file mode 100644
index 000000000..d53ef6437
--- /dev/null
+++ b/forui/example/macos/Runner/AppDelegate.swift
@@ -0,0 +1,9 @@
+import Cocoa
+import FlutterMacOS
+
+@NSApplicationMain
+class AppDelegate: FlutterAppDelegate {
+ override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return true
+ }
+}
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..a2ec33f19
--- /dev/null
+++ b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_16.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_64.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_128.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_1024.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
new file mode 100644
index 000000000..82b6f9d9a
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png
new file mode 100644
index 000000000..13b35eba5
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png
new file mode 100644
index 000000000..0a3f5fa40
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png
new file mode 100644
index 000000000..bdb57226d
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png
new file mode 100644
index 000000000..f083318e0
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
new file mode 100644
index 000000000..326c0e72c
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ
diff --git a/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png
new file mode 100644
index 000000000..2f1632cfd
Binary files /dev/null and b/forui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ
diff --git a/forui/example/macos/Runner/Base.lproj/MainMenu.xib b/forui/example/macos/Runner/Base.lproj/MainMenu.xib
new file mode 100644
index 000000000..80e867a4e
--- /dev/null
+++ b/forui/example/macos/Runner/Base.lproj/MainMenu.xib
@@ -0,0 +1,343 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/forui/example/macos/Runner/Configs/AppInfo.xcconfig b/forui/example/macos/Runner/Configs/AppInfo.xcconfig
new file mode 100644
index 000000000..b08c22631
--- /dev/null
+++ b/forui/example/macos/Runner/Configs/AppInfo.xcconfig
@@ -0,0 +1,14 @@
+// Application-level settings for the Runner target.
+//
+// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
+// future. If not, the values below would default to using the project name when this becomes a
+// 'flutter create' template.
+
+// The application's name. By default this is also the title of the Flutter window.
+PRODUCT_NAME = example
+
+// The application's bundle identifier
+PRODUCT_BUNDLE_IDENTIFIER = com.foruslabs.forui.samples.example
+
+// The copyright displayed in application information
+PRODUCT_COPYRIGHT = Copyright © 2024 com.foruslabs.forui.samples. All rights reserved.
diff --git a/forui/example/macos/Runner/Configs/Debug.xcconfig b/forui/example/macos/Runner/Configs/Debug.xcconfig
new file mode 100644
index 000000000..36b0fd946
--- /dev/null
+++ b/forui/example/macos/Runner/Configs/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Debug.xcconfig"
+#include "Warnings.xcconfig"
diff --git a/forui/example/macos/Runner/Configs/Release.xcconfig b/forui/example/macos/Runner/Configs/Release.xcconfig
new file mode 100644
index 000000000..dff4f4956
--- /dev/null
+++ b/forui/example/macos/Runner/Configs/Release.xcconfig
@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Release.xcconfig"
+#include "Warnings.xcconfig"
diff --git a/forui/example/macos/Runner/Configs/Warnings.xcconfig b/forui/example/macos/Runner/Configs/Warnings.xcconfig
new file mode 100644
index 000000000..42bcbf478
--- /dev/null
+++ b/forui/example/macos/Runner/Configs/Warnings.xcconfig
@@ -0,0 +1,13 @@
+WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
+GCC_WARN_UNDECLARED_SELECTOR = YES
+CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
+CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
+CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
+CLANG_WARN_PRAGMA_PACK = YES
+CLANG_WARN_STRICT_PROTOTYPES = YES
+CLANG_WARN_COMMA = YES
+GCC_WARN_STRICT_SELECTOR_MATCH = YES
+CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
+CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
+GCC_WARN_SHADOW = YES
+CLANG_WARN_UNREACHABLE_CODE = YES
diff --git a/forui/example/macos/Runner/DebugProfile.entitlements b/forui/example/macos/Runner/DebugProfile.entitlements
new file mode 100644
index 000000000..dddb8a30c
--- /dev/null
+++ b/forui/example/macos/Runner/DebugProfile.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.network.server
+
+
+
diff --git a/forui/example/macos/Runner/Info.plist b/forui/example/macos/Runner/Info.plist
new file mode 100644
index 000000000..4789daa6a
--- /dev/null
+++ b/forui/example/macos/Runner/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconFile
+
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSHumanReadableCopyright
+ $(PRODUCT_COPYRIGHT)
+ NSMainNibFile
+ MainMenu
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/forui/example/macos/Runner/MainFlutterWindow.swift b/forui/example/macos/Runner/MainFlutterWindow.swift
new file mode 100644
index 000000000..3cc05eb23
--- /dev/null
+++ b/forui/example/macos/Runner/MainFlutterWindow.swift
@@ -0,0 +1,15 @@
+import Cocoa
+import FlutterMacOS
+
+class MainFlutterWindow: NSWindow {
+ override func awakeFromNib() {
+ let flutterViewController = FlutterViewController()
+ let windowFrame = self.frame
+ self.contentViewController = flutterViewController
+ self.setFrame(windowFrame, display: true)
+
+ RegisterGeneratedPlugins(registry: flutterViewController)
+
+ super.awakeFromNib()
+ }
+}
diff --git a/forui/example/macos/Runner/Release.entitlements b/forui/example/macos/Runner/Release.entitlements
new file mode 100644
index 000000000..852fa1a47
--- /dev/null
+++ b/forui/example/macos/Runner/Release.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+
diff --git a/forui/example/macos/RunnerTests/RunnerTests.swift b/forui/example/macos/RunnerTests/RunnerTests.swift
new file mode 100644
index 000000000..61f3bd1fc
--- /dev/null
+++ b/forui/example/macos/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Cocoa
+import FlutterMacOS
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/forui/example/pubspec.lock b/forui/example/pubspec.lock
index 26fa81a28..a6a869abf 100644
--- a/forui/example/pubspec.lock
+++ b/forui/example/pubspec.lock
@@ -458,10 +458,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
- sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a
+ sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e"
url: "https://pub.dev"
source: hosted
- version: "2.2.6"
+ version: "2.2.7"
path_provider_foundation:
dependency: transitive
description:
@@ -490,10 +490,10 @@ packages:
dependency: transitive
description:
name: path_provider_windows
- sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
- version: "2.2.1"
+ version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -723,14 +723,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
- win32:
- dependency: transitive
- description:
- name: win32
- sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
- url: "https://pub.dev"
- source: hosted
- version: "5.5.1"
xdg_directories:
dependency: transitive
description:
diff --git a/forui/lib/src/foundation/inkwell.dart b/forui/lib/src/foundation/inkwell.dart
new file mode 100644
index 000000000..4ee4cd0cc
--- /dev/null
+++ b/forui/lib/src/foundation/inkwell.dart
@@ -0,0 +1,89 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+
+@internal
+class FInkWell extends StatefulWidget {
+ final FocusNode? focusNode;
+ final String? semanticLabel;
+ final bool selected;
+ final VoidCallback? onPress;
+ final VoidCallback? onLongPress;
+ final ValueWidgetBuilder builder;
+ final Widget? child;
+
+ const FInkWell({
+ required this.builder,
+ this.focusNode,
+ this.semanticLabel,
+ this.selected = false,
+ this.onPress,
+ this.onLongPress,
+ this.child,
+ super.key,
+ });
+
+ @override
+ State createState() => _FInkWellState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('semanticLabel', semanticLabel, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('focusNode', focusNode, level: DiagnosticLevel.debug))
+ ..add(FlagProperty('selected', value: selected, ifTrue: 'selected', level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('onPress', onPress, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('onLongPress', onLongPress, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('builder', builder, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('child', child, level: DiagnosticLevel.debug));
+ }
+}
+
+class _FInkWellState extends State {
+ bool _focused = false;
+ bool _hovered = false;
+
+ @override
+ void initState() {
+ super.initState();
+ widget.focusNode?.addListener(_updateFocused);
+ }
+
+ @override
+ void didUpdateWidget(FInkWell old) {
+ super.didUpdateWidget(old);
+ widget.focusNode?.addListener(_updateFocused);
+ old.focusNode?.removeListener(_updateFocused);
+ }
+
+ @override
+ Widget build(BuildContext context) => Focus(
+ focusNode: widget.focusNode,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: Semantics(
+ label: widget.semanticLabel,
+ button: true,
+ selected: widget.selected,
+ excludeSemantics: true,
+ child: GestureDetector(
+ onTap: widget.onPress,
+ onLongPress: widget.onLongPress,
+ child: widget.builder(context, _focused || _hovered, widget.child),
+ ),
+ ),
+ ),
+ );
+
+ @override
+ void dispose() {
+ widget.focusNode?.removeListener(_updateFocused);
+ super.dispose();
+ }
+
+ void _updateFocused() => setState(() => _focused = widget.focusNode?.hasFocus ?? false);
+}
diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart
index 6743e80fa..937966b98 100644
--- a/forui/lib/src/theme/theme_data.dart
+++ b/forui/lib/src/theme/theme_data.dart
@@ -31,6 +31,9 @@ final class FThemeData with Diagnosticable {
/// The button styles.
final FButtonStyles buttonStyles;
+ /// The calendar style.
+ final FCalendarStyle calendarStyle;
+
/// The card style.
final FCardStyle cardStyle;
@@ -71,6 +74,7 @@ final class FThemeData with Diagnosticable {
required this.colorScheme,
required this.badgeStyles,
required this.buttonStyles,
+ required this.calendarStyle,
required this.cardStyle,
required this.checkboxStyle,
required this.dialogStyle,
@@ -99,6 +103,7 @@ final class FThemeData with Diagnosticable {
style: style,
badgeStyles: FBadgeStyles.inherit(colorScheme: colorScheme, typography: typography, style: style),
buttonStyles: FButtonStyles.inherit(colorScheme: colorScheme, typography: typography, style: style),
+ calendarStyle: FCalendarStyle.inherit(colorScheme: colorScheme, typography: typography, style: style),
cardStyle: FCardStyle.inherit(colorScheme: colorScheme, typography: typography, style: style),
checkboxStyle: FCheckboxStyle.inherit(colorScheme: colorScheme),
dialogStyle: FDialogStyle.inherit(colorScheme: colorScheme, typography: typography, style: style),
@@ -135,6 +140,7 @@ final class FThemeData with Diagnosticable {
FStyle? style,
FBadgeStyles? badgeStyles,
FButtonStyles? buttonStyles,
+ FCalendarStyle? calendarStyle,
FCardStyle? cardStyle,
FCheckboxStyle? checkboxStyle,
FDialogStyle? dialogStyle,
@@ -152,6 +158,7 @@ final class FThemeData with Diagnosticable {
style: style ?? this.style,
badgeStyles: badgeStyles ?? this.badgeStyles,
buttonStyles: buttonStyles ?? this.buttonStyles,
+ calendarStyle: calendarStyle ?? this.calendarStyle,
cardStyle: cardStyle ?? this.cardStyle,
checkboxStyle: checkboxStyle ?? this.checkboxStyle,
dialogStyle: dialogStyle ?? this.dialogStyle,
@@ -173,6 +180,7 @@ final class FThemeData with Diagnosticable {
..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('buttonStyles', buttonStyles, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('calendarStyle', calendarStyle, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('cardStyle', cardStyle, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('checkboxStyle', checkboxStyle, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('dialogStyle', dialogStyle, level: DiagnosticLevel.debug))
@@ -195,6 +203,7 @@ final class FThemeData with Diagnosticable {
style == other.style &&
badgeStyles == other.badgeStyles &&
buttonStyles == other.buttonStyles &&
+ calendarStyle == other.calendarStyle &&
cardStyle == other.cardStyle &&
checkboxStyle == other.checkboxStyle &&
dialogStyle == other.dialogStyle &&
@@ -213,6 +222,7 @@ final class FThemeData with Diagnosticable {
style.hashCode ^
badgeStyles.hashCode ^
buttonStyles.hashCode ^
+ calendarStyle.hashCode ^
cardStyle.hashCode ^
checkboxStyle.hashCode ^
dialogStyle.hashCode ^
diff --git a/forui/lib/src/widgets/calendar/calendar.dart b/forui/lib/src/widgets/calendar/calendar.dart
new file mode 100644
index 000000000..af11481fb
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/calendar.dart
@@ -0,0 +1,276 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/widgets/calendar/day/day_picker.dart';
+import 'package:forui/src/widgets/calendar/day/paged_day_picker.dart';
+import 'package:forui/src/widgets/calendar/shared/header.dart';
+import 'package:forui/src/widgets/calendar/year_month_picker.dart';
+
+export 'day/day_picker.dart' show FCalendarDayPickerStyle, FCalendarDayStyle;
+export 'shared/entry.dart' show FCalendarEntryStyle;
+export 'shared/header.dart' show FCalendarHeaderStyle, FCalendarPickerType;
+export 'calendar_controller.dart';
+export 'year_month_picker.dart' show FCalendarYearMonthPickerStyle;
+
+/// A calendar.
+///
+/// The calendar pages are designed to be navigable through swipe gestures on mobile Android, iOS & iPadOS, allowing
+/// left and right swipes to transition between pages.
+///
+/// See:
+/// * https://forui.dev/docs/calendar for working examples.
+/// * [FCalendarDayStyle] for customizing a card's appearance.
+class FCalendar extends StatelessWidget {
+ static bool _true(DateTime _) => true;
+
+ /// The style. Defaults to [FThemeData.calendarStyle].
+ final FCalendarStyle? style;
+
+ /// A controller that determines if a date is selected.
+ final FCalendarController controller;
+
+ /// The start date. It is truncated to the nearest date.
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if [end] <= [start]
+ final DateTime start;
+
+ /// The end date. It is truncated to the nearest date.
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if [end] <= [start]
+ final DateTime end;
+
+ /// The current date. It is truncated to the nearest date. Defaults to the [DateTime.now].
+ final DateTime today;
+
+ /// A predicate that determines if a date can be selected. It may be called more than once for a single date.
+ ///
+ /// Defaults to returning true for all dates.
+ final Predicate enabled;
+
+ /// A callback for when the displayed month changes.
+ final ValueChanged? onMonthChange;
+
+ /// A callback for when a date in a [FCalendarPickerType.day] picker is pressed.
+ final ValueChanged? onPress;
+
+ /// A callback for when a date in a [FCalendarPickerType.day] picker is long pressed.
+ final ValueChanged? onLongPress;
+ final ValueNotifier _type;
+ final ValueNotifier _month;
+
+ /// Creates a [FCalendar].
+ ///
+ /// [initialDate] defaults to [today]. It is truncated to the nearest date.
+ FCalendar({
+ required this.controller,
+ required this.start,
+ required this.end,
+ this.style,
+ this.enabled = _true,
+ this.onMonthChange,
+ this.onPress,
+ this.onLongPress,
+ FCalendarPickerType initialType = FCalendarPickerType.day,
+ DateTime? today,
+ DateTime? initialDate,
+ super.key,
+ }) : assert(start.toLocalDate() < end.toLocalDate(), 'end date must be greater than start date'),
+ today = today ?? DateTime.now(),
+ _type = ValueNotifier(initialType),
+ _month = ValueNotifier((initialDate ?? today ?? DateTime.now()).toLocalDate().truncate(to: DateUnit.months));
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style ?? context.theme.calendarStyle;
+ return DecoratedBox(
+ decoration: style.decoration,
+ child: Padding(
+ padding: style.padding,
+ child: SizedBox(
+ height: (DayPicker.maxRows * DayPicker.tileDimension) + Header.height + 5,
+ width: DateTime.daysPerWeek * DayPicker.tileDimension,
+ child: Stack(
+ alignment: Alignment.topCenter,
+ children: [
+ ValueListenableBuilder(
+ valueListenable: _month,
+ builder: (context, month, child) => Header(
+ style: style.headerStyle,
+ type: _type,
+ month: month,
+ ),
+ ),
+ ValueListenableBuilder(
+ valueListenable: _type,
+ builder: (context, value, child) => switch (value) {
+ FCalendarPickerType.day => PagedDayPicker(
+ style: style,
+ start: start.toLocalDate(),
+ end: end.toLocalDate(),
+ today: today.toLocalDate(),
+ initial: _month.value,
+ enabled: (date) => enabled(date.toNative()),
+ selected: (date) => controller.contains(date.toNative()),
+ onMonthChange: (date) {
+ _month.value = date;
+ onMonthChange?.call(date.toNative());
+ },
+ onPress: (date) {
+ final native = date.toNative();
+ controller.onPress(native);
+ onPress?.call(native);
+ },
+ onLongPress: (date) => onLongPress?.call(date.toNative()),
+ ),
+ FCalendarPickerType.yearMonth => YearMonthPicker(
+ style: style,
+ start: start.toLocalDate(),
+ end: end.toLocalDate(),
+ today: today.toLocalDate(),
+ initial: _month.value,
+ onChange: (date) {
+ _month.value = date;
+ _type.value = FCalendarPickerType.day;
+ },
+ ),
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('controller', controller))
+ ..add(DiagnosticsProperty('start', start))
+ ..add(DiagnosticsProperty('end', end))
+ ..add(DiagnosticsProperty('today', today))
+ ..add(DiagnosticsProperty('enabled', enabled))
+ ..add(DiagnosticsProperty('onMonthChange', onMonthChange))
+ ..add(DiagnosticsProperty('onPress', onPress))
+ ..add(DiagnosticsProperty('onLongPress', onLongPress));
+ }
+}
+
+/// The calendar's style.
+final class FCalendarStyle with Diagnosticable {
+ /// The header's style.
+ final FCalendarHeaderStyle headerStyle;
+
+ /// The day picker's style.
+ final FCalendarDayPickerStyle dayPickerStyle;
+
+ /// The year/month picker's style.
+ final FCalendarYearMonthPickerStyle yearMonthPickerStyle;
+
+ /// The decoration surrounding the header & picker.
+ final BoxDecoration decoration;
+
+ /// The padding surrounding the header & picker. Defaults to `EdgeInsets.symmetric(horizontal: 12, vertical: 16)`.
+ final EdgeInsets padding;
+
+ /// The duration of the page switch animation. Defaults to 200 milliseconds.
+ final Duration pageAnimationDuration;
+
+ /// Creates a new [FCalendarStyle].
+ FCalendarStyle({
+ required this.headerStyle,
+ required this.dayPickerStyle,
+ required this.yearMonthPickerStyle,
+ required this.decoration,
+ this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
+ this.pageAnimationDuration = const Duration(milliseconds: 200),
+ });
+
+ /// Creates a [FCalendarStyle] that inherits the color scheme and typography.
+ FCalendarStyle.inherit({
+ required FColorScheme colorScheme,
+ required FTypography typography,
+ required FStyle style,
+ }) : this(
+ headerStyle: FCalendarHeaderStyle.inherit(colorScheme: colorScheme, typography: typography),
+ dayPickerStyle: FCalendarDayPickerStyle.inherit(colorScheme: colorScheme, typography: typography),
+ yearMonthPickerStyle: FCalendarYearMonthPickerStyle.inherit(colorScheme: colorScheme, typography: typography),
+ decoration: BoxDecoration(
+ borderRadius: style.borderRadius,
+ border: Border.all(color: colorScheme.border),
+ color: colorScheme.background,
+ ),
+ );
+
+ /// Returns a copy of this [FCalendarStyle] but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FCalendarStyle(
+ /// headerStyle: ...,
+ /// dayPickerStyle: ...,
+ /// // Other arguments omitted for brevity.
+ /// );
+ ///
+ /// final copy = style.copyWith(dayPickerStyle: ...);
+ ///
+ /// print(style.headerStyle == copy.headerStyle); // true
+ /// print(style.dayPickerStyle == copy.dayPickerStyle); // false
+ /// ```
+ FCalendarStyle copyWith({
+ FCalendarHeaderStyle? headerStyle,
+ FCalendarDayPickerStyle? dayPickerStyle,
+ FCalendarYearMonthPickerStyle? yearMonthPickerStyle,
+ BoxDecoration? decoration,
+ EdgeInsets? padding,
+ Duration? pageAnimationDuration,
+ }) =>
+ FCalendarStyle(
+ headerStyle: headerStyle ?? this.headerStyle,
+ dayPickerStyle: dayPickerStyle ?? this.dayPickerStyle,
+ yearMonthPickerStyle: yearMonthPickerStyle ?? this.yearMonthPickerStyle,
+ decoration: decoration ?? this.decoration,
+ padding: padding ?? this.padding,
+ pageAnimationDuration: pageAnimationDuration ?? this.pageAnimationDuration,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('headerStyle', headerStyle))
+ ..add(DiagnosticsProperty('dayPickerStyle', dayPickerStyle))
+ ..add(DiagnosticsProperty('yearMonthPickerStyle', yearMonthPickerStyle))
+ ..add(DiagnosticsProperty('decoration', decoration))
+ ..add(DiagnosticsProperty('padding', padding))
+ ..add(DiagnosticsProperty('pageAnimationDuration', pageAnimationDuration));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarStyle &&
+ runtimeType == other.runtimeType &&
+ headerStyle == other.headerStyle &&
+ dayPickerStyle == other.dayPickerStyle &&
+ yearMonthPickerStyle == other.yearMonthPickerStyle &&
+ decoration == other.decoration &&
+ padding == other.padding &&
+ pageAnimationDuration == other.pageAnimationDuration;
+
+ @override
+ int get hashCode =>
+ headerStyle.hashCode ^
+ dayPickerStyle.hashCode ^
+ yearMonthPickerStyle.hashCode ^
+ decoration.hashCode ^
+ padding.hashCode ^
+ pageAnimationDuration.hashCode;
+}
diff --git a/forui/lib/src/widgets/calendar/calendar_controller.dart b/forui/lib/src/widgets/calendar/calendar_controller.dart
new file mode 100644
index 000000000..ea2d8b710
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/calendar_controller.dart
@@ -0,0 +1,113 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/forui.dart';
+
+/// A controller that controls date selection in a calendar.
+///
+/// This class should be extended to customize date selection. By default, the following controllers are provided:
+/// * [FCalendarSingleValueController] for selecting a single date.
+/// * [FCalendarMultiValueController] for selecting multiple date.
+/// * [FCalendarSingleRangeController] for selecting a single range.
+abstract class FCalendarController extends ValueNotifier {
+ /// Creates a [FCalendarController] with the given initial [value].
+ FCalendarController(super._value);
+
+ /// Called when the given [date] in a [FCalendarPickerType.day] picker is pressed.
+ ///
+ /// [date] is always in UTC timezone and truncated to the nearest date.
+ void onPress(DateTime date);
+
+ /// Returns true if the given [date] is selected.
+ bool contains(DateTime date);
+}
+
+/// A date selection controller that allows only a single date to be selected.
+///
+/// The selected date is always in UTC timezone and truncated to the nearest date.
+final class FCalendarSingleValueController extends FCalendarController {
+ /// Creates a [FCalendarSingleValueController] with the given initial [value].
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if the given [value] is not in UTC timezone.
+ FCalendarSingleValueController([super.value]) : assert(value?.isUtc ?? true, 'value must be in UTC timezone');
+
+ @override
+ bool contains(DateTime date) => value?.toLocalDate() == date.toLocalDate();
+
+ @override
+ void onPress(DateTime date) => value = value?.toLocalDate() == date.toLocalDate() ? null : date;
+}
+
+/// A date selection controller that allows multiple dates to be selected.
+///
+/// The selected dates are always in UTC timezone and truncated to the nearest date.
+final class FCalendarMultiValueController extends FCalendarController> {
+ /// Creates a [FCalendarMultiValueController] with the given initial [value].
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if the given dates in [value] is not in UTC timezone.
+ FCalendarMultiValueController([super.value = const {}])
+ : assert(value.every((d) => d.isUtc), 'dates must be in UTC timezone');
+
+ @override
+ bool contains(DateTime date) => value.contains(date);
+
+ @override
+ void onPress(DateTime date) {
+ final copy = {...value};
+ value = copy..toggle(date);
+ }
+}
+
+/// A date selection controller that allows a single range to be selected.
+///
+/// Both the start and end dates of the range is inclusive. The selected dates are always in UTC timezone and truncated
+/// to the nearest date.
+final class FCalendarSingleRangeController extends FCalendarController<(DateTime, DateTime)?> {
+ /// Creates a [FCalendarSingleRangeController] with the given initial [value].
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if:
+ /// * the given dates in [value] is not in UTC timezone.
+ /// * the end date is less than start date.
+ FCalendarSingleRangeController([super.value])
+ : assert(value == null || (value.$1.isUtc && value.$2.isUtc), 'value must be in UTC timezone'),
+ assert(
+ value == null || (value.$1.isBefore(value.$2) || value.$1.isAtSameMomentAs(value.$2)),
+ 'end date must be greater than or equal to start date',
+ );
+
+ @override
+ bool contains(DateTime date) {
+ if (value case (final first, final last)) {
+ final current = date.toLocalDate();
+ return first.toLocalDate() <= current && current <= last.toLocalDate();
+ }
+
+ return false;
+ }
+
+ @override
+ void onPress(DateTime date) {
+ if (value == null) {
+ value = (date, date);
+ return;
+ }
+
+ final (first, last) = value!;
+ final pressed = date.toLocalDate();
+
+ switch ((first.toLocalDate(), last.toLocalDate())) {
+ case (final first, final last) when pressed == first || pressed == last:
+ value = null;
+
+ case (final first, final last) when pressed < first:
+ value = (pressed.toNative(), last.toNative());
+
+ case (final first, _):
+ value = (first.toNative(), pressed.toNative());
+ }
+ }
+}
diff --git a/forui/lib/src/widgets/calendar/day/day_picker.dart b/forui/lib/src/widgets/calendar/day/day_picker.dart
new file mode 100644
index 000000000..aa72f84b2
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/day/day_picker.dart
@@ -0,0 +1,380 @@
+import 'dart:collection';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/widgets/calendar/shared/entry.dart';
+
+@internal
+class DayPicker extends StatefulWidget {
+ static const maxRows = 7;
+ static const tileDimension = 42.0;
+
+ final FCalendarDayPickerStyle style;
+ final LocalDate month;
+ final LocalDate today;
+ final LocalDate? focused;
+ final Predicate enabled;
+ final Predicate selected;
+ final ValueChanged onPress;
+ final ValueChanged onLongPress;
+
+ const DayPicker({
+ required this.style,
+ required this.month,
+ required this.today,
+ required this.focused,
+ required this.enabled,
+ required this.selected,
+ required this.onPress,
+ required this.onLongPress,
+ super.key,
+ });
+
+ @override
+ State createState() => _DayPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('month', month))
+ ..add(DiagnosticsProperty('today', today))
+ ..add(DiagnosticsProperty('focused', focused))
+ ..add(DiagnosticsProperty('enabledPredicate', enabled, ifNull: 'all enabled'))
+ ..add(DiagnosticsProperty('selectedPredicate', selected, ifNull: 'none selected'))
+ ..add(DiagnosticsProperty('onPress', onPress))
+ ..add(DiagnosticsProperty('onLongPress', onLongPress));
+ }
+}
+
+class _DayPickerState extends State {
+ final SplayTreeMap _days = SplayTreeMap();
+
+ @override
+ void initState() {
+ super.initState();
+
+ final (first, last) = _range;
+ for (var date = first; date <= last; date = date.tomorrow) {
+ _days[date] = FocusNode(skipTraversal: true, debugLabel: '$date');
+ }
+
+ if (_days[widget.focused] case final focusNode?) {
+ focusNode.requestFocus();
+ }
+ }
+
+ (LocalDate, LocalDate) get _range {
+ final firstDayOfWeek = widget.style.startDayOfWeek ?? DateTime.sunday; // TODO: Localization
+ final firstDayOfMonth = widget.month.firstDayOfMonth;
+ var difference = firstDayOfMonth.weekday - firstDayOfWeek;
+ if (difference < 0) {
+ difference += 7;
+ }
+
+ final first = firstDayOfMonth.minus(days: difference);
+
+ final lastDayOfWeek = firstDayOfWeek == DateTime.monday ? DateTime.sunday : firstDayOfWeek - 1;
+ final lastDayOfMonth = widget.month.lastDayOfMonth;
+ difference = lastDayOfWeek - lastDayOfMonth.weekday;
+ if (difference < 0) {
+ difference += 7;
+ }
+
+ final last = lastDayOfMonth.plus(days: difference);
+
+ return (first, last);
+ }
+
+ @override
+ Widget build(BuildContext context) => SizedBox(
+ width: DateTime.daysPerWeek * DayPicker.tileDimension,
+ child: GridView.custom(
+ padding: EdgeInsets.zero,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const _GridDelegate(),
+ childrenDelegate: SliverChildListDelegate(
+ addRepaintBoundaries: false,
+ [
+ ..._headers(context),
+ for (final MapEntry(key: date, value: focusNode) in _days.entries)
+ Entry.day(
+ style: widget.style,
+ date: date,
+ focusNode: focusNode,
+ current: date.month == widget.month.month,
+ today: date == widget.today,
+ enabled: widget.enabled,
+ selected: widget.selected,
+ onPress: widget.onPress,
+ onLongPress: widget.onLongPress,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ List _headers(BuildContext context) {
+ final firstDayOfWeek = widget.style.startDayOfWeek ?? DateTime.sunday; // TODO: Localization
+ final narrowWeekdays = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; // TODO: Localization
+
+ return [
+ for (int i = firstDayOfWeek, j = 0; j < DateTime.daysPerWeek; i = (i + 1) % DateTime.daysPerWeek, j++)
+ ExcludeSemantics(
+ child: Center(child: Text(narrowWeekdays[i - 1], style: widget.style.headerTextStyle)),
+ ),
+ ];
+ }
+
+ @override
+ void didUpdateWidget(DayPicker old) {
+ super.didUpdateWidget(old);
+ assert(old.month == widget.month, 'current month must not change.');
+
+ if (_days[widget.focused] case final focusNode? when old.focused != widget.focused) {
+ focusNode.requestFocus();
+ }
+ }
+
+ @override
+ void dispose() {
+ for (final node in _days.values) {
+ node.dispose();
+ }
+ super.dispose();
+ }
+}
+
+/// Based on Material [CalendarDatePicker]'s _DayPickerGridDelegate.
+class _GridDelegate extends SliverGridDelegate {
+ const _GridDelegate();
+
+ @override
+ SliverGridLayout getLayout(SliverConstraints constraints) => SliverGridRegularTileLayout(
+ childCrossAxisExtent: DayPicker.tileDimension,
+ childMainAxisExtent: DayPicker.tileDimension,
+ crossAxisCount: DateTime.daysPerWeek,
+ crossAxisStride: DayPicker.tileDimension,
+ mainAxisStride: DayPicker.tileDimension,
+ reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
+ );
+
+ @override
+ bool shouldRelayout(_GridDelegate oldDelegate) => false;
+}
+
+/// A day picker's style.
+final class FCalendarDayPickerStyle with Diagnosticable {
+ /// The text style for the day of th week headers.
+ final TextStyle headerTextStyle;
+
+ /// The styles of the current month on display and the enclosing months, when enabled.
+ final ({FCalendarDayStyle current, FCalendarDayStyle enclosing}) enabledStyles;
+
+ /// The styles of the current month on display and the enclosing months, when disabled.
+ final ({FCalendarDayStyle current, FCalendarDayStyle enclosing}) disabledStyles;
+
+ /// The starting day of the week. Defaults to the current locale's preferred starting day of the week if null.
+ ///
+ /// Specifying [startDayOfWeek] will override the current locale's preferred starting day of the week.
+ ///
+ /// ## Contract:
+ /// Throws an [AssertionError] if:
+ /// * [startDayOfWeek] < [DateTime.monday]
+ /// * [DateTime.sunday] < [startDayOfWeek]
+ final int? startDayOfWeek;
+
+ /// Creates a [FCalendarDayPickerStyle].
+ const FCalendarDayPickerStyle({
+ required this.headerTextStyle,
+ required this.enabledStyles,
+ required this.disabledStyles,
+ this.startDayOfWeek,
+ }) : assert(
+ startDayOfWeek == null || (DateTime.monday <= startDayOfWeek && startDayOfWeek <= DateTime.sunday),
+ 'startDayOfWeek must be between DateTime.monday (1) and DateTime.sunday (7).',
+ );
+
+ /// Creates a [FCalendarDayPickerStyle] that inherits from the given [colorScheme] and [typography].
+ factory FCalendarDayPickerStyle.inherit({required FColorScheme colorScheme, required FTypography typography}) {
+ final textStyle = typography.sm.copyWith(color: colorScheme.foreground, fontWeight: FontWeight.w500);
+ final mutedTextStyle =
+ typography.sm.copyWith(color: colorScheme.mutedForeground.withOpacity(0.5), fontWeight: FontWeight.w500);
+
+ final disabled = FCalendarDayStyle(
+ selectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.primaryForeground,
+ textStyle: mutedTextStyle,
+ radius: const Radius.circular(4),
+ ),
+ unselectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.background,
+ textStyle: mutedTextStyle,
+ radius: const Radius.circular(4),
+ ),
+ );
+
+ return FCalendarDayPickerStyle(
+ headerTextStyle: typography.xs.copyWith(color: colorScheme.mutedForeground),
+ enabledStyles: (
+ current: FCalendarDayStyle(
+ selectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.foreground,
+ textStyle: typography.sm.copyWith(color: colorScheme.background, fontWeight: FontWeight.w500),
+ radius: const Radius.circular(4),
+ ),
+ unselectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.background,
+ textStyle: textStyle,
+ focusedBackgroundColor: colorScheme.secondary,
+ radius: const Radius.circular(4),
+ ),
+ ),
+ enclosing: FCalendarDayStyle(
+ selectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.primaryForeground,
+ textStyle: mutedTextStyle,
+ radius: const Radius.circular(4),
+ ),
+ unselectedStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.background,
+ textStyle: mutedTextStyle,
+ focusedBackgroundColor: colorScheme.primaryForeground,
+ radius: const Radius.circular(4),
+ ),
+ ),
+ ),
+ disabledStyles: (current: disabled, enclosing: disabled),
+ );
+ }
+
+ /// Returns a copy of this [FCalendarDayPickerStyle] but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FMonthStyle(
+ /// headerTextStyle: ...,
+ /// enabledCurrent: ...,
+ /// // Other arguments omitted for brevity.
+ /// );
+ ///
+ /// final copy = style.copyWith(
+ /// enabledCurrent: ...,
+ /// );
+ ///
+ /// print(style.headerTextStyle == copy.headerTextStyle); // true
+ /// print(style.enabled.current == copy.enabled.current); // false
+ /// ```
+ FCalendarDayPickerStyle copyWith({
+ TextStyle? headerTextStyle,
+ FCalendarDayStyle? enabledCurrent,
+ FCalendarDayStyle? enabledEnclosing,
+ FCalendarDayStyle? disabledCurrent,
+ FCalendarDayStyle? disabledEnclosing,
+ int? startDayOfWeek,
+ }) =>
+ FCalendarDayPickerStyle(
+ headerTextStyle: headerTextStyle ?? this.headerTextStyle,
+ enabledStyles: (
+ current: enabledCurrent ?? enabledStyles.current,
+ enclosing: enabledEnclosing ?? enabledStyles.enclosing,
+ ),
+ disabledStyles: (
+ current: disabledCurrent ?? disabledStyles.current,
+ enclosing: disabledEnclosing ?? disabledStyles.enclosing,
+ ),
+ startDayOfWeek: startDayOfWeek ?? this.startDayOfWeek,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('headerTextStyle', headerTextStyle))
+ ..add(DiagnosticsProperty('enabled.current', enabledStyles.current))
+ ..add(DiagnosticsProperty('enabled.enclosing', enabledStyles.enclosing))
+ ..add(DiagnosticsProperty('disabled.current', disabledStyles.current))
+ ..add(DiagnosticsProperty('disabled.enclosing', disabledStyles.enclosing))
+ ..add(IntProperty('startDayOfWeek', startDayOfWeek));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarDayPickerStyle &&
+ runtimeType == other.runtimeType &&
+ headerTextStyle == other.headerTextStyle &&
+ enabledStyles == other.enabledStyles &&
+ disabledStyles == other.disabledStyles &&
+ startDayOfWeek == other.startDayOfWeek;
+
+ @override
+ int get hashCode =>
+ headerTextStyle.hashCode ^ enabledStyles.hashCode ^ disabledStyles.hashCode ^ startDayOfWeek.hashCode;
+}
+
+/// A calender day's style.
+final class FCalendarDayStyle with Diagnosticable {
+ /// The selected dates' style.
+ final FCalendarEntryStyle selectedStyle;
+
+ /// The unselected dates' style.
+ final FCalendarEntryStyle unselectedStyle;
+
+ /// Creates a [FCalendarDayStyle].
+ const FCalendarDayStyle({
+ required this.unselectedStyle,
+ required this.selectedStyle,
+ });
+
+ /// Returns a copy of this [FCalendarDayStyle] but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FCalendarDayStyle(
+ /// selectedStyle: ...,
+ /// unselectedStyle: ...,
+ /// // Other arguments omitted for brevity
+ /// );
+ ///
+ /// final copy = style.copyWith(
+ /// unselectedStyle: ...,
+ /// );
+ ///
+ /// print(style.selectedStyle == copy.selectedStyle); // true
+ /// print(style.unselectedStyle == copy.unselectedStyle); // false
+ /// ```
+ FCalendarDayStyle copyWith({
+ FCalendarEntryStyle? selectedStyle,
+ FCalendarEntryStyle? unselectedStyle,
+ }) =>
+ FCalendarDayStyle(
+ selectedStyle: selectedStyle ?? this.selectedStyle,
+ unselectedStyle: unselectedStyle ?? this.unselectedStyle,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('selectedStyle', selectedStyle))
+ ..add(DiagnosticsProperty('unselectedStyle', unselectedStyle));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarDayStyle &&
+ runtimeType == other.runtimeType &&
+ unselectedStyle == other.unselectedStyle &&
+ selectedStyle == other.selectedStyle;
+
+ @override
+ int get hashCode => unselectedStyle.hashCode ^ selectedStyle.hashCode;
+}
diff --git a/forui/lib/src/widgets/calendar/day/paged_day_picker.dart b/forui/lib/src/widgets/calendar/day/paged_day_picker.dart
new file mode 100644
index 000000000..f98ffe294
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/day/paged_day_picker.dart
@@ -0,0 +1,130 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/semantics.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/day/day_picker.dart';
+import 'package:forui/src/widgets/calendar/shared/paged_picker.dart';
+
+@internal
+class PagedDayPicker extends PagedPicker {
+ final Predicate selected;
+ final ValueChanged? onMonthChange;
+ final ValueChanged onPress;
+ final ValueChanged onLongPress;
+
+ PagedDayPicker({
+ required this.selected,
+ required this.onMonthChange,
+ required this.onPress,
+ required this.onLongPress,
+ required super.style,
+ required super.start,
+ required super.end,
+ required super.today,
+ required super.initial,
+ required super.enabled,
+ super.key,
+ });
+
+ @override
+ State createState() => _PagedDayPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('selectedPredicate', selected))
+ ..add(DiagnosticsProperty('onMonthChange', onMonthChange))
+ ..add(DiagnosticsProperty('onPress', onPress))
+ ..add(DiagnosticsProperty('onLongPress', onLongPress));
+ }
+}
+
+class _PagedDayPickerState extends PagedPickerState {
+ @override
+ Widget buildItem(BuildContext context, int page) => DayPicker(
+ style: widget.style.dayPickerStyle,
+ month: widget.start.truncate(to: DateUnit.months).plus(months: page),
+ today: widget.today,
+ focused: focusedDate,
+ enabled: widget.enabled,
+ selected: widget.selected,
+ onPress: (date) {
+ setState(() => focusedDate = date);
+ widget.onPress(date);
+ },
+ onLongPress: (date) {
+ setState(() => focusedDate = date);
+ widget.onLongPress(date);
+ },
+ );
+
+ @override
+ void onPageChange(int page) {
+ setState(() {
+ final changed = widget.start.truncate(to: DateUnit.months).plus(months: page);
+ if (current == changed) {
+ return;
+ }
+
+ current = changed;
+ widget.onMonthChange?.call(current);
+ if (focusedDate case final focused? when focused.truncate(to: DateUnit.months) == current) {
+ // We have navigated to a new month with the grid focused, but the
+ // focused day is not in this month. Choose a new one trying to keep
+ // the same day of the month.
+ focusedDate = _focusableDayForMonth(current, focusedDate!.day);
+ }
+
+ SemanticsService.announce(current.toString(), textDirection); // TODO: localization
+ });
+ }
+
+ @override
+ void onGridFocusChange(bool focused) {
+ setState(() {
+ if (focused && focusedDate == null) {
+ final preferred = widget.today.truncate(to: DateUnit.months) == current ? widget.today.day : 1;
+ focusedDate = _focusableDayForMonth(current, preferred);
+ }
+ });
+ }
+
+ /// Returns a focusable date for the given month.
+ ///
+ /// If the preferredDay is available in the month it will be returned,
+ /// otherwise the first selectable day in the month will be returned. If
+ /// no dates are selectable in the month, then it will return null.
+ LocalDate? _focusableDayForMonth(LocalDate month, int preferredDay) {
+ // Can we use the preferred day in this month?
+ if (preferredDay <= month.daysInMonth) {
+ final newFocus = month.copyWith(day: preferredDay);
+ if (widget.enabled(newFocus)) {
+ return newFocus;
+ }
+ }
+
+ // Start at the 1st and take the first enabled date.
+ for (var newFocus = month; newFocus.month == month.month; newFocus = newFocus.tomorrow) {
+ if (widget.enabled(newFocus)) {
+ return newFocus;
+ }
+ }
+
+ return null;
+ }
+
+ @override
+ int delta(LocalDate start, LocalDate end) => (end.year - start.year) * 12 + end.month - start.month;
+
+ @override
+ Map get directionOffset => const {
+ TraversalDirection.up: Period(days: -DateTime.daysPerWeek),
+ TraversalDirection.right: Period(days: 1),
+ TraversalDirection.down: Period(days: DateTime.daysPerWeek),
+ TraversalDirection.left: Period(days: -1),
+ };
+}
diff --git a/forui/lib/src/widgets/calendar/month/month_picker.dart b/forui/lib/src/widgets/calendar/month/month_picker.dart
new file mode 100644
index 000000000..2d4db053a
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/month/month_picker.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/shared/entry.dart';
+import 'package:forui/src/widgets/calendar/year_month_picker.dart';
+
+// ignore: non_constant_identifier_names
+final _MMM = DateFormat.MMM();
+
+@internal
+class MonthPicker extends StatefulWidget {
+ static const columns = 3;
+
+ final FCalendarYearMonthPickerStyle style;
+ final LocalDate currentYear;
+ final LocalDate start;
+ final LocalDate end;
+ final LocalDate today;
+ final LocalDate? focused;
+ final ValueChanged onPress;
+
+ MonthPicker({
+ required this.style,
+ required this.currentYear,
+ required this.start,
+ required this.end,
+ required this.today,
+ required this.focused,
+ required this.onPress,
+ super.key,
+ }) : assert(currentYear == currentYear.truncate(to: DateUnit.years), 'currentYear must be truncated to years');
+
+ @override
+ State createState() => _MonthPickerState();
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('currentYear', currentYear, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('start', start, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('end', end, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('today', today, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('focused', focused, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('onPress', onPress, level: DiagnosticLevel.debug));
+ }
+}
+
+class _MonthPickerState extends State {
+ late List _months;
+
+ @override
+ void initState() {
+ super.initState();
+ _months = List.generate(12, (i) => FocusNode(skipTraversal: true, debugLabel: '$i'));
+
+ if (widget.focused != null) {
+ _months[widget.focused!.month - 1].requestFocus();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => GridView(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: MonthPicker.columns,
+ childAspectRatio: 1.618,
+ ),
+ children: [
+ for (var month = widget.currentYear, i = 0; i < 12; month = month.plus(months: 1), i++)
+ Entry.yearMonth(
+ style: widget.style,
+ date: month,
+ focusNode: _months[i],
+ current: widget.today.truncate(to: DateUnit.months) == month,
+ enabled: widget.start <= month && month <= widget.end,
+ format: (date) => _MMM.format(date.toNative()), // TODO: localize
+ onPress: widget.onPress,
+ ),
+ ],
+ );
+
+ @override
+ void didUpdateWidget(MonthPicker old) {
+ super.didUpdateWidget(old);
+ assert(old.currentYear == widget.currentYear, 'currentYear must not change.');
+
+ final focused = widget.focused;
+ if (focused == null || focused < widget.currentYear || widget.currentYear.plus(years: 1) <= focused) {
+ return;
+ }
+
+ if (_months[focused.month - 1] case final focusNode when old.focused != widget.focused) {
+ focusNode.requestFocus();
+ }
+ }
+
+ @override
+ void dispose() {
+ for (final node in _months) {
+ node.dispose();
+ }
+ super.dispose();
+ }
+}
diff --git a/forui/lib/src/widgets/calendar/month/paged_month_picker.dart b/forui/lib/src/widgets/calendar/month/paged_month_picker.dart
new file mode 100644
index 000000000..a59e9afc9
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/month/paged_month_picker.dart
@@ -0,0 +1,84 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/month/month_picker.dart';
+import 'package:forui/src/widgets/calendar/shared/paged_picker.dart';
+
+@internal
+class PagedMonthPicker extends PagedPicker {
+ final ValueChanged onPress;
+
+ PagedMonthPicker({
+ required this.onPress,
+ required super.style,
+ required super.start,
+ required super.end,
+ required super.today,
+ required super.initial,
+ super.key,
+ });
+
+ @override
+ State createState() => _PagedMonthPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('onPress', onPress));
+ }
+}
+
+class _PagedMonthPickerState extends PagedPickerState {
+ @override
+ Widget buildItem(BuildContext context, int page) => MonthPicker(
+ style: widget.style.yearMonthPickerStyle,
+ currentYear: widget.initial,
+ start: widget.start,
+ end: widget.end,
+ today: widget.today,
+ focused: focusedDate,
+ onPress: widget.onPress,
+ );
+
+ @override
+ void onPageChange(int page) {} // Months will only appear on a single page.
+
+ @override
+ void onGridFocusChange(bool focused) {
+ setState(() {
+ if (focused && focusedDate == null) {
+ final currentMonth = widget.today.truncate(to: DateUnit.months);
+ focusedDate = _focusableMonth(widget.initial.year == widget.today.year ? currentMonth : current);
+ }
+ });
+ }
+
+ LocalDate? _focusableMonth(LocalDate preferredMonth) {
+ final end = widget.initial.plus(years: 1);
+ if (widget.initial <= preferredMonth && preferredMonth < end) {
+ return preferredMonth;
+ }
+
+ for (var newFocus = widget.initial; newFocus < end; newFocus = newFocus.plus(months: 1)) {
+ if (widget.enabled(newFocus)) {
+ return newFocus;
+ }
+ }
+
+ return null;
+ }
+
+ @override
+ int delta(LocalDate start, LocalDate end) => 0;
+
+ @override
+ Map get directionOffset => const {
+ TraversalDirection.up: Period(months: -MonthPicker.columns),
+ TraversalDirection.right: Period(months: 1),
+ TraversalDirection.down: Period(months: MonthPicker.columns),
+ TraversalDirection.left: Period(months: -1),
+ };
+}
diff --git a/forui/lib/src/widgets/calendar/shared/entry.dart b/forui/lib/src/widgets/calendar/shared/entry.dart
new file mode 100644
index 000000000..a2b137128
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/shared/entry.dart
@@ -0,0 +1,296 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/foundation/inkwell.dart';
+import 'package:forui/src/widgets/calendar/day/day_picker.dart';
+import 'package:forui/src/widgets/calendar/year_month_picker.dart';
+
+final _yMMMMd = DateFormat.yMMMMd();
+
+@internal
+abstract class Entry extends StatelessWidget {
+ final FCalendarEntryStyle style;
+ final ValueWidgetBuilder builder;
+
+ factory Entry.day({
+ required FCalendarDayPickerStyle style,
+ required LocalDate date,
+ required FocusNode focusNode,
+ required bool current,
+ required bool today,
+ required Predicate enabled,
+ required Predicate selected,
+ required ValueChanged onPress,
+ required ValueChanged onLongPress,
+ }) {
+ final enable = enabled(date);
+ final select = selected(date);
+
+ final styles = enable ? style.enabledStyles : style.disabledStyles;
+ final dayStyle = current ? styles.current : styles.enclosing;
+ final entryStyle = select ? dayStyle.selectedStyle : dayStyle.unselectedStyle;
+
+ // ignore: avoid_positional_boolean_parameters
+ Widget builder(BuildContext context, bool focused, Widget? child) => _Content(
+ style: entryStyle,
+ borderRadius: BorderRadius.horizontal(
+ left: selected(date.yesterday) ? Radius.zero : entryStyle.radius,
+ right: selected(date.tomorrow) ? Radius.zero : entryStyle.radius,
+ ),
+ text: '${date.day}', // TODO: localization
+ focused: focused,
+ current: today,
+ );
+
+ if (enabled(date)) {
+ return _EnabledEntry(
+ focusNode: focusNode,
+ date: date,
+ semanticLabel: '${_yMMMMd.format(date.toNative())}${today ? ', Today' : ''}',
+ selected: selected(date),
+ onPress: onPress,
+ onLongPress: onLongPress,
+ style: entryStyle,
+ builder: builder,
+ );
+ } else {
+ return _DisabledEntry(style: entryStyle, builder: builder);
+ }
+ }
+
+ factory Entry.yearMonth({
+ required FCalendarYearMonthPickerStyle style,
+ required LocalDate date,
+ required FocusNode focusNode,
+ required bool current,
+ required bool enabled,
+ required ValueChanged onPress,
+ required String Function(LocalDate) format,
+ }) {
+ final entryStyle = enabled ? style.enabledStyle : style.disabledStyle;
+
+ // ignore: avoid_positional_boolean_parameters
+ Widget builder(BuildContext context, bool focused, Widget? child) => _Content(
+ style: entryStyle,
+ borderRadius: BorderRadius.all(entryStyle.radius),
+ text: format(date),
+ focused: focused,
+ current: current,
+ );
+
+ if (enabled) {
+ return _EnabledEntry(
+ focusNode: focusNode,
+ date: date,
+ semanticLabel: format(date),
+ onPress: onPress,
+ style: entryStyle,
+ builder: builder,
+ );
+ } else {
+ return _DisabledEntry(style: entryStyle, builder: builder);
+ }
+ }
+
+ const Entry._({
+ required this.style,
+ required this.builder,
+ });
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('builder', builder));
+ }
+}
+
+class _EnabledEntry extends Entry {
+ final FocusNode focusNode;
+ final LocalDate date;
+ final String semanticLabel;
+ final bool selected;
+ final ValueChanged onPress;
+ final ValueChanged? onLongPress;
+
+ const _EnabledEntry({
+ required this.focusNode,
+ required this.date,
+ required this.semanticLabel,
+ required this.onPress,
+ required super.style,
+ required super.builder,
+ this.selected = false,
+ this.onLongPress,
+ }) : super._();
+
+ @override
+ Widget build(BuildContext context) => FInkWell(
+ focusNode: focusNode,
+ semanticLabel: semanticLabel,
+ selected: selected,
+ onPress: () => onPress(date),
+ onLongPress: () => onLongPress?.call(date),
+ builder: builder,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('focusNode', focusNode))
+ ..add(DiagnosticsProperty('date', date))
+ ..add(StringProperty('semanticLabel', semanticLabel))
+ ..add(FlagProperty('selected', value: selected, ifTrue: 'selected'))
+ ..add(DiagnosticsProperty('onPress', onPress))
+ ..add(DiagnosticsProperty('onLongPress', onLongPress));
+ }
+}
+
+class _DisabledEntry extends Entry {
+ const _DisabledEntry({
+ required super.style,
+ required super.builder,
+ }) : super._();
+
+ @override
+ Widget build(BuildContext context) => ExcludeSemantics(child: builder(context, false, null));
+}
+
+class _Content extends StatelessWidget {
+ final FCalendarEntryStyle style;
+ final BorderRadius borderRadius;
+ final String text;
+ final bool focused;
+ final bool current;
+
+ const _Content({
+ required this.style,
+ required this.borderRadius,
+ required this.text,
+ required this.focused,
+ required this.current,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ var textStyle = focused ? style.focusedTextStyle : style.textStyle;
+ if (current) {
+ textStyle = textStyle.copyWith(decoration: TextDecoration.underline);
+ }
+
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: borderRadius,
+ color: focused ? style.focusedBackgroundColor : style.backgroundColor,
+ ),
+ child: Center(
+ child: Text(text, style: textStyle),
+ ),
+ );
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('borderRadius', borderRadius))
+ ..add(StringProperty('text', text))
+ ..add(FlagProperty('focused', value: focused, ifTrue: 'focused'))
+ ..add(FlagProperty('current', value: current, ifTrue: 'current'));
+ }
+}
+
+/// A calendar entry's style.
+final class FCalendarEntryStyle with Diagnosticable {
+ /// The unfocused day's background color.
+ final Color backgroundColor;
+
+ /// The unfocused day's text style.
+ final TextStyle textStyle;
+
+ /// The focused day's background color. Defaults to [backgroundColor].
+ final Color focusedBackgroundColor;
+
+ /// The focused day's text style. Defaults to [textStyle].
+ final TextStyle focusedTextStyle;
+
+ /// The entry border's radius. Defaults to `Radius.circular(4)`.
+ final Radius radius;
+
+ /// Creates a [FCalendarEntryStyle].
+ FCalendarEntryStyle({
+ required this.backgroundColor,
+ required this.textStyle,
+ required this.radius,
+ Color? focusedBackgroundColor,
+ TextStyle? focusedTextStyle,
+ }) : focusedBackgroundColor = focusedBackgroundColor ?? backgroundColor,
+ focusedTextStyle = focusedTextStyle ?? textStyle;
+
+ /// Returns a copy of this [FCalendarEntryStyle] but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FCalendarEntryStyle(
+ /// backgroundColor: ...,
+ /// textStyle: ...,
+ /// );
+ ///
+ /// final copy = style.copyWith(
+ /// textStyle: ...,
+ /// );
+ ///
+ /// print(style.backgroundColor == copy.backgroundColor); // true
+ /// print(style.textStyle == copy.textStyle); // false
+ /// ```
+ FCalendarEntryStyle copyWith({
+ Color? backgroundColor,
+ TextStyle? textStyle,
+ Color? focusedBackgroundColor,
+ TextStyle? focusedTextStyle,
+ Radius? radius,
+ }) =>
+ FCalendarEntryStyle(
+ backgroundColor: backgroundColor ?? this.backgroundColor,
+ textStyle: textStyle ?? this.textStyle,
+ focusedBackgroundColor: focusedBackgroundColor ?? this.focusedBackgroundColor,
+ focusedTextStyle: focusedTextStyle ?? this.focusedTextStyle,
+ radius: radius ?? this.radius,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(ColorProperty('backgroundColor', backgroundColor))
+ ..add(DiagnosticsProperty('textStyle', textStyle))
+ ..add(ColorProperty('focusedBackgroundColor', focusedBackgroundColor))
+ ..add(DiagnosticsProperty('focusedTextStyle', focusedTextStyle))
+ ..add(DiagnosticsProperty('radius', radius));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarEntryStyle &&
+ runtimeType == other.runtimeType &&
+ backgroundColor == other.backgroundColor &&
+ textStyle == other.textStyle &&
+ focusedBackgroundColor == other.focusedBackgroundColor &&
+ focusedTextStyle == other.focusedTextStyle &&
+ radius == other.radius;
+
+ @override
+ int get hashCode =>
+ backgroundColor.hashCode ^
+ textStyle.hashCode ^
+ focusedBackgroundColor.hashCode ^
+ focusedTextStyle.hashCode ^
+ radius.hashCode;
+}
diff --git a/forui/lib/src/widgets/calendar/shared/header.dart b/forui/lib/src/widgets/calendar/shared/header.dart
new file mode 100644
index 000000000..39ddea130
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/shared/header.dart
@@ -0,0 +1,263 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/inkwell.dart';
+
+/// The current picker type.
+enum FCalendarPickerType {
+ /// The day picker.
+ day,
+
+ /// The year-month picker.
+ yearMonth,
+}
+
+final _yMMMM = DateFormat.yMMMM();
+
+@internal
+class Header extends StatefulWidget {
+ static const height = 31.0;
+
+ final FCalendarHeaderStyle style;
+ final ValueNotifier type;
+ final LocalDate month;
+
+ const Header({
+ required this.style,
+ required this.type,
+ required this.month,
+ super.key,
+ });
+
+ @override
+ State createState() => _HeaderState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('type', type))
+ ..add(DiagnosticsProperty('month', month));
+ }
+}
+
+class _HeaderState extends State with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ widget.type.addListener(_animate);
+ _controller = AnimationController(vsync: this, duration: widget.style.animationDuration);
+ _controller.value = widget.type.value == FCalendarPickerType.day ? 0.0 : 1.0;
+ }
+
+ @override
+ Widget build(BuildContext context) => SizedBox(
+ height: Header.height,
+ child: FInkWell(
+ onPress: () => widget.type.value = switch (widget.type.value) {
+ FCalendarPickerType.day => FCalendarPickerType.yearMonth,
+ FCalendarPickerType.yearMonth => FCalendarPickerType.day,
+ },
+ builder: (context, _, child) => child!,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(_yMMMM.format(widget.month.toNative()), style: widget.style.headerTextStyle), // TODO: Localization
+ RotationTransition(
+ turns: Tween(begin: 0.0, end: 0.25).animate(_controller),
+ child: Padding(
+ padding: const EdgeInsets.all(2.0),
+ child: FAssets.icons.chevronRight(
+ height: 15,
+ colorFilter: ColorFilter.mode(context.theme.colorScheme.primary, BlendMode.srcIn),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ @override
+ void didUpdateWidget(Header old) {
+ super.didUpdateWidget(old);
+ old.type.removeListener(_animate);
+ widget.type.addListener(_animate);
+ }
+
+ @override
+ void dispose() {
+ widget.type.removeListener(_animate);
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _animate() {
+ // we check the picker type to prevent de-syncs
+ switch ((widget.type.value, _controller.isCompleted)) {
+ case (FCalendarPickerType.yearMonth, false):
+ _controller.forward();
+ case (FCalendarPickerType.day, true):
+ _controller.reverse();
+
+ case _:
+ }
+ }
+}
+
+@internal
+class Navigation extends StatelessWidget {
+ final FCalendarHeaderStyle style;
+ final VoidCallback? onPrevious;
+ final VoidCallback? onNext;
+
+ const Navigation({
+ required this.style,
+ required this.onPrevious,
+ required this.onNext,
+ super.key,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final buttonStyle = context.theme.buttonStyles.outline;
+ final effectiveButtonStyle = buttonStyle.copyWith(
+ enabledBoxDecoration: buttonStyle.enabledBoxDecoration.copyWith(borderRadius: BorderRadius.circular(4)),
+ disabledBoxDecoration: buttonStyle.disabledBoxDecoration.copyWith(borderRadius: BorderRadius.circular(4)),
+ );
+
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 5),
+ child: SizedBox(
+ height: Header.height,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 7),
+ child: FButton.raw(
+ // TODO: Replace with FButton.icon.
+ style: effectiveButtonStyle,
+ onPress: onPrevious,
+ child: Padding(
+ padding: const EdgeInsets.all(7),
+ child: FAssets.icons.chevronLeft(
+ height: 17,
+ colorFilter: ColorFilter.mode(style.iconColor, BlendMode.srcIn),
+ ),
+ ),
+ ),
+ ),
+ const Expanded(child: SizedBox()),
+ Padding(
+ padding: const EdgeInsets.only(right: 7),
+ child: FButton.raw(
+ // TODO: Replace with FButton.icon.
+ style: effectiveButtonStyle,
+ onPress: onNext,
+ child: Padding(
+ padding: const EdgeInsets.all(7),
+ child: FAssets.icons.chevronRight(
+ height: 17,
+ colorFilter: ColorFilter.mode(style.iconColor, BlendMode.srcIn),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('onPrevious', onPrevious))
+ ..add(DiagnosticsProperty('onNext', onNext));
+ }
+}
+
+/// The calendar header's style.
+final class FCalendarHeaderStyle with Diagnosticable {
+ /// The header's text style.
+ final TextStyle headerTextStyle;
+
+ /// The header icons' color.
+ final Color iconColor;
+
+ /// The arrow turn animation's duration. Defaults to `Duration(milliseconds: 200)`.
+ final Duration animationDuration;
+
+ /// Creates a [FCalendarHeaderStyle].
+ FCalendarHeaderStyle({
+ required this.headerTextStyle,
+ required this.iconColor,
+ this.animationDuration = const Duration(milliseconds: 200),
+ });
+
+ /// Creates a [FCalendarHeaderStyle] that inherits its values from the given [colorScheme] and [typography].
+ FCalendarHeaderStyle.inherit({required FColorScheme colorScheme, required FTypography typography})
+ : this(
+ headerTextStyle: typography.sm.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600),
+ iconColor: colorScheme.mutedForeground,
+ );
+
+ /// Creates a copy of this but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FCalendarHeaderStyle(
+ /// headerTextStyle: ...,
+ /// iconColor:...,
+ /// // Other arguments omitted for brevity.
+ /// );
+ ///
+ /// final copy = style.copyWith(
+ /// iconColor: ...,
+ /// );
+ ///
+ /// print(style.headerTextStyle == copy.headerTextStyle); // true
+ /// print(style.iconColor == copy.iconColor); // false
+ /// ```
+ FCalendarHeaderStyle copyWith({
+ TextStyle? headerTextStyle,
+ Color? iconColor,
+ Duration? animationDuration,
+ }) =>
+ FCalendarHeaderStyle(
+ headerTextStyle: headerTextStyle ?? this.headerTextStyle,
+ iconColor: iconColor ?? this.iconColor,
+ animationDuration: animationDuration ?? this.animationDuration,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('headerTextStyle', headerTextStyle))
+ ..add(ColorProperty('iconColor', iconColor))
+ ..add(DiagnosticsProperty('animationDuration', animationDuration));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarHeaderStyle &&
+ runtimeType == other.runtimeType &&
+ headerTextStyle == other.headerTextStyle &&
+ iconColor == other.iconColor &&
+ animationDuration == other.animationDuration;
+
+ @override
+ int get hashCode => headerTextStyle.hashCode ^ iconColor.hashCode ^ animationDuration.hashCode;
+}
diff --git a/forui/lib/src/widgets/calendar/shared/paged_picker.dart b/forui/lib/src/widgets/calendar/shared/paged_picker.dart
new file mode 100644
index 000000000..b991a9715
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/shared/paged_picker.dart
@@ -0,0 +1,213 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/calendar.dart';
+import 'package:forui/src/widgets/calendar/shared/header.dart';
+
+@internal
+abstract class PagedPicker extends StatefulWidget {
+ final FCalendarStyle style;
+ final LocalDate start;
+ final LocalDate end;
+ final LocalDate today;
+ final LocalDate initial;
+ final Predicate enabled;
+
+ PagedPicker({
+ required this.style,
+ required this.start,
+ required this.end,
+ required this.today,
+ required this.initial,
+ Predicate? enabled,
+ super.key,
+ }) : enabled = ((date) => start <= date && date <= end && (enabled?.call(date) ?? true));
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('start', start))
+ ..add(DiagnosticsProperty('end', end))
+ ..add(DiagnosticsProperty('today', today))
+ ..add(DiagnosticsProperty('initial', initial))
+ ..add(DiagnosticsProperty('enabledPredicate', enabled));
+ }
+}
+
+// Most of the traversal logic is copied from Material's _MonthPickerState.
+@internal
+abstract class PagedPickerState extends State {
+ static const _shortcuts = {
+ SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left),
+ SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right),
+ SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
+ SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
+ };
+
+ LocalDate? focusedDate;
+ late LocalDate current;
+ late TextDirection textDirection;
+ final GlobalKey _pageViewKey = GlobalKey();
+ late PageController _controller;
+ late Map> _actions;
+ late FocusNode _focusNode;
+
+ @override
+ void initState() {
+ super.initState();
+ current = widget.initial;
+ _controller = PageController(initialPage: delta(widget.start, widget.initial));
+ _actions = {
+ NextFocusIntent: CallbackAction(onInvoke: _onGridNextFocus),
+ PreviousFocusIntent: CallbackAction(onInvoke: _onGridPreviousFocus),
+ DirectionalFocusIntent: CallbackAction(onInvoke: _onDirectionFocus),
+ };
+ _focusNode = FocusNode();
+ }
+
+ @override
+ Widget build(BuildContext context) => Column(
+ children: [
+ Navigation(
+ style: widget.style.headerStyle,
+ onPrevious: _first ? null : _onPrevious,
+ onNext: _last ? null : _onNext,
+ ),
+ Expanded(
+ child: FocusableActionDetector(
+ shortcuts: _shortcuts,
+ actions: _actions,
+ focusNode: _focusNode,
+ onFocusChange: onGridFocusChange,
+ child: PageView.builder(
+ key: _pageViewKey,
+ controller: _controller,
+ itemBuilder: buildItem,
+ itemCount: delta(widget.start, widget.end) + 1,
+ onPageChanged: onPageChange,
+ ),
+ ),
+ ),
+ ],
+ );
+
+ Widget buildItem(BuildContext context, int page);
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ textDirection = Directionality.of(context);
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('current', current))
+ ..add(DiagnosticsProperty('focusedDate', focusedDate))
+ ..add(DiagnosticsProperty('textDirection', textDirection))
+ ..add(DiagnosticsProperty('directionOffset', directionOffset));
+ }
+
+ void _onNext() {
+ if (!_last) {
+ _controller.nextPage(duration: widget.style.pageAnimationDuration, curve: Curves.ease);
+ }
+ }
+
+ void _onPrevious() {
+ if (!_first) {
+ _controller.previousPage(duration: widget.style.pageAnimationDuration, curve: Curves.ease);
+ }
+ }
+
+ bool get _first => delta(widget.start, current) == 0;
+
+ bool get _last => delta(widget.start, current) == delta(widget.start, widget.end);
+
+ void _showPage(LocalDate date) {
+ final page = delta(widget.start, date);
+ _controller.animateToPage(
+ page,
+ duration: widget.style.pageAnimationDuration,
+ curve: Curves.ease,
+ );
+ }
+
+ void onPageChange(int page);
+
+ // ignore: avoid_positional_boolean_parameters
+ void onGridFocusChange(bool focused);
+
+ /// Move focus to the next element after the day grid.
+ void _onGridNextFocus(NextFocusIntent intent) {
+ _focusNode
+ ..requestFocus()
+ ..nextFocus();
+ }
+
+ /// Move focus to the previous element before the day grid.
+ void _onGridPreviousFocus(PreviousFocusIntent intent) {
+ _focusNode
+ ..requestFocus()
+ ..previousFocus();
+ }
+
+ /// Move the internal focus date in the direction of the given intent.
+ ///
+ /// This will attempt to move the focused day to the next selectable day in
+ /// the given direction. If the new date is not in the current month, then
+ /// the page view will be scrolled to show the new date's month.
+ ///
+ /// For horizontal directions, it will move forward or backward a day (depending
+ /// on the current [TextDirection]). For vertical directions it will move up and
+ /// down a week at a time.
+ void _onDirectionFocus(DirectionalFocusIntent intent) {
+ assert(focusedDate != null, 'Cannot move focus without a focused day.');
+ setState(() {
+ if (_nextDate(focusedDate!, intent.direction) case final next?) {
+ focusedDate = next;
+ if (delta(widget.start, focusedDate!) != delta(widget.start, current)) {
+ _showPage(focusedDate!);
+ }
+ }
+ });
+ }
+
+ LocalDate? _nextDate(LocalDate date, TraversalDirection direction) {
+ final textDirection = Directionality.of(context);
+ final offset = directionOffset[switch ((direction, textDirection)) {
+ (TraversalDirection.left, TextDirection.rtl) => TraversalDirection.right,
+ (TraversalDirection.right, TextDirection.rtl) => TraversalDirection.left,
+ _ => direction,
+ }]!;
+
+ var next = date + offset;
+ while (widget.start <= next && next <= widget.end) {
+ if (widget.enabled(next)) {
+ return next;
+ }
+
+ next = date + offset;
+ }
+
+ return null;
+ }
+
+ int delta(LocalDate start, LocalDate end);
+
+ Map get directionOffset;
+}
diff --git a/forui/lib/src/widgets/calendar/year/paged_year_picker.dart b/forui/lib/src/widgets/calendar/year/paged_year_picker.dart
new file mode 100644
index 000000000..ceabfe331
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/year/paged_year_picker.dart
@@ -0,0 +1,100 @@
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/shared/paged_picker.dart';
+import 'package:forui/src/widgets/calendar/year/year_picker.dart';
+
+@internal
+class PagedYearPicker extends PagedPicker {
+ final ValueChanged onPress;
+
+ PagedYearPicker({
+ required this.onPress,
+ required super.style,
+ required super.start,
+ required super.end,
+ required super.today,
+ required super.initial,
+ super.key,
+ });
+
+ @override
+ State createState() => _PagedYearPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('onPress', onPress));
+ }
+}
+
+class _PagedYearPickerState extends PagedPickerState {
+ @override
+ Widget buildItem(BuildContext context, int page) => YearPicker(
+ style: widget.style.yearMonthPickerStyle,
+ startYear: widget.start.truncate(to: DateUnit.years).plus(years: page * YearPicker.items),
+ start: widget.start,
+ end: widget.end,
+ today: widget.today,
+ focused: focusedDate,
+ onPress: widget.onPress,
+ );
+
+ @override
+ void onGridFocusChange(bool focused) {
+ setState(() {
+ if (focused && focusedDate == null) {
+ final currentYear = widget.today.truncate(to: DateUnit.years);
+ focusedDate = _focusableYear(current, currentYear == current ? currentYear : current);
+ }
+ });
+ }
+
+ @override
+ void onPageChange(int page) {
+ setState(() {
+ final changed = widget.start.truncate(to: DateUnit.years).plus(years: page * YearPicker.items);
+ if (current == changed) {
+ return;
+ }
+
+ current = changed;
+ if (focusedDate case final focused? when focused.truncate(to: DateUnit.years) == current) {
+ // We have navigated to a new page with the grid focused, but the
+ // focused year is not in this page. Choose a new one.
+ focusedDate = _focusableYear(current, focusedDate!);
+ }
+
+ SemanticsService.announce(current.toString(), textDirection);
+ });
+ }
+
+ LocalDate? _focusableYear(LocalDate startYear, LocalDate preferredYear) {
+ final endYear = startYear.plus(years: YearPicker.items);
+ if (startYear <= preferredYear && preferredYear < endYear) {
+ return preferredYear;
+ }
+
+ for (var newFocus = startYear; newFocus < endYear; newFocus = newFocus.plus(years: 1)) {
+ if (widget.enabled(newFocus)) {
+ return newFocus;
+ }
+ }
+
+ return null;
+ }
+
+ @override
+ int delta(LocalDate start, LocalDate end) => ((end.year - start.year) / YearPicker.items).floor();
+
+ @override
+ Map get directionOffset => const {
+ TraversalDirection.up: Period(years: -YearPicker.columns),
+ TraversalDirection.right: Period(years: 1),
+ TraversalDirection.down: Period(years: YearPicker.columns),
+ TraversalDirection.left: Period(years: -1),
+ };
+}
diff --git a/forui/lib/src/widgets/calendar/year/year_picker.dart b/forui/lib/src/widgets/calendar/year/year_picker.dart
new file mode 100644
index 000000000..492fb9877
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/year/year_picker.dart
@@ -0,0 +1,110 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/src/widgets/calendar/shared/entry.dart';
+import 'package:forui/src/widgets/calendar/year_month_picker.dart';
+
+@internal
+class YearPicker extends StatefulWidget {
+ static const columns = 3;
+ static const rows = 4;
+ static const items = columns * rows;
+
+ final FCalendarYearMonthPickerStyle style;
+ final LocalDate startYear;
+ final LocalDate start;
+ final LocalDate end;
+ final LocalDate today;
+ final LocalDate? focused;
+ final ValueChanged onPress;
+
+ YearPicker({
+ required this.style,
+ required this.startYear,
+ required this.start,
+ required this.end,
+ required this.today,
+ required this.focused,
+ required this.onPress,
+ super.key,
+ }) : assert(startYear == startYear.truncate(to: DateUnit.years), 'startYear must be truncated to years.');
+
+ @override
+ State createState() => _YearPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('startYear', startYear, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('start', start, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('end', end, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('today', today, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('focused', focused, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('onPress', onPress, level: DiagnosticLevel.debug));
+ }
+}
+
+class _YearPickerState extends State {
+ late List _years;
+
+ @override
+ void initState() {
+ super.initState();
+ _years = List.generate(YearPicker.items, (i) => FocusNode(skipTraversal: true, debugLabel: '$i'));
+
+ final focused = widget.focused;
+ if (focused == null || focused < widget.startYear || widget.startYear.plus(years: YearPicker.items) <= focused) {
+ return;
+ }
+
+ _years[focused.year - widget.startYear.year].requestFocus();
+ }
+
+ @override
+ Widget build(BuildContext context) => GridView(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: YearPicker.columns,
+ childAspectRatio: 1.618,
+ ),
+ children: [
+ for (var year = widget.startYear, i = 0; i < YearPicker.items; year = year.plus(years: 1), i++)
+ Entry.yearMonth(
+ style: widget.style,
+ date: year,
+ focusNode: _years[i],
+ current: widget.today.year == year.year,
+ enabled: widget.start <= year && year <= widget.end,
+ format: (date) => '${date.year}', // TODO: localization
+ onPress: widget.onPress,
+ ),
+ ],
+ );
+
+ @override
+ void didUpdateWidget(YearPicker old) {
+ super.didUpdateWidget(old);
+ assert(old.startYear == widget.startYear, 'startYear must noe change.');
+
+ final focused = widget.focused;
+ if (focused == null || focused < widget.startYear || widget.startYear.plus(years: YearPicker.items) <= focused) {
+ return;
+ }
+
+ if (_years[focused.year - widget.startYear.year] case final focusNode when old.focused != widget.focused) {
+ focusNode.requestFocus();
+ }
+ }
+
+ @override
+ void dispose() {
+ for (final node in _years) {
+ node.dispose();
+ }
+ super.dispose();
+ }
+}
diff --git a/forui/lib/src/widgets/calendar/year_month_picker.dart b/forui/lib/src/widgets/calendar/year_month_picker.dart
new file mode 100644
index 000000000..7b1da364e
--- /dev/null
+++ b/forui/lib/src/widgets/calendar/year_month_picker.dart
@@ -0,0 +1,146 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+import 'package:sugar/sugar.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/widgets/calendar/month/paged_month_picker.dart';
+import 'package:forui/src/widgets/calendar/year/paged_year_picker.dart';
+
+@internal
+class YearMonthPicker extends StatefulWidget {
+ final FCalendarStyle style;
+ final LocalDate start;
+ final LocalDate end;
+ final LocalDate today;
+ final LocalDate initial;
+ final ValueChanged onChange;
+
+ const YearMonthPicker({
+ required this.style,
+ required this.start,
+ required this.end,
+ required this.today,
+ required this.initial,
+ required this.onChange,
+ super.key,
+ });
+
+ @override
+ State createState() => _YearMonthPickerState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(DiagnosticsProperty('start', start))
+ ..add(DiagnosticsProperty('end', end))
+ ..add(DiagnosticsProperty('today', today))
+ ..add(DiagnosticsProperty('initial', initial))
+ ..add(DiagnosticsProperty('onChange', onChange));
+ }
+}
+
+class _YearMonthPickerState extends State {
+ LocalDate? _date;
+
+ @override
+ void initState() {
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_date == null) {
+ return PagedYearPicker(
+ style: widget.style,
+ start: widget.start,
+ end: widget.end,
+ today: widget.today,
+ initial: widget.initial.truncate(to: DateUnit.years),
+ onPress: (year) => setState(() => _date = year),
+ );
+ } else {
+ return PagedMonthPicker(
+ style: widget.style,
+ start: widget.start,
+ end: widget.end,
+ today: widget.today,
+ initial: _date!,
+ onPress: widget.onChange,
+ );
+ }
+ }
+}
+
+/// The year/month picker's style.
+final class FCalendarYearMonthPickerStyle with Diagnosticable {
+ /// The enabled years/months' styles.
+ final FCalendarEntryStyle enabledStyle;
+
+ /// The disabled years/months' styles.
+ final FCalendarEntryStyle disabledStyle;
+
+ /// Creates a new year/month picker style.
+ FCalendarYearMonthPickerStyle({required this.enabledStyle, required this.disabledStyle});
+
+ /// Creates a new year/month picker style that inherits the color scheme and typography.
+ FCalendarYearMonthPickerStyle.inherit({required FColorScheme colorScheme, required FTypography typography})
+ : this(
+ enabledStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.background,
+ textStyle: typography.sm.copyWith(color: colorScheme.foreground, fontWeight: FontWeight.w500),
+ focusedBackgroundColor: colorScheme.secondary,
+ radius: const Radius.circular(8),
+ ),
+ disabledStyle: FCalendarEntryStyle(
+ backgroundColor: colorScheme.background,
+ textStyle: typography.sm
+ .copyWith(color: colorScheme.mutedForeground.withOpacity(0.5), fontWeight: FontWeight.w500),
+ radius: const Radius.circular(8),
+ ),
+ );
+
+ /// Returns a copy of this [FCalendarYearMonthPickerStyle] but with the given fields replaced with the new values.
+ ///
+ /// ```dart
+ /// final style = FCalendarYearMonthPickerStyle(
+ /// enabledStyle: ...,
+ /// disabledStyle: ...,
+ /// );
+ ///
+ /// final copy = style.copyWith(disabledStyle: ...);
+ ///
+ /// print(style.enabledStyle == copy.enabledStyle); // true
+ /// print(style.disabledStyle == copy.disabledStyle); // false
+ /// ```
+ FCalendarYearMonthPickerStyle copyWith({
+ FCalendarEntryStyle? enabledStyle,
+ FCalendarEntryStyle? disabledStyle,
+ }) =>
+ FCalendarYearMonthPickerStyle(
+ enabledStyle: enabledStyle ?? this.enabledStyle,
+ disabledStyle: disabledStyle ?? this.disabledStyle,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('enabledStyle', enabledStyle))
+ ..add(DiagnosticsProperty('disabledStyle', disabledStyle));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FCalendarYearMonthPickerStyle &&
+ runtimeType == other.runtimeType &&
+ enabledStyle == other.enabledStyle &&
+ disabledStyle == other.disabledStyle;
+
+ @override
+ int get hashCode => enabledStyle.hashCode ^ disabledStyle.hashCode;
+}
diff --git a/forui/lib/widgets.dart b/forui/lib/widgets.dart
index 25e657a06..dc9ff14de 100644
--- a/forui/lib/widgets.dart
+++ b/forui/lib/widgets.dart
@@ -3,6 +3,7 @@ library forui.widgets;
export 'src/widgets/badge/badge.dart' hide Variant;
export 'src/widgets/button/button.dart' hide Variant;
+export 'src/widgets/calendar/calendar.dart';
export 'src/widgets/card/card.dart';
export 'src/widgets/checkbox.dart';
export 'src/widgets/dialog/dialog.dart';
diff --git a/forui/pubspec.yaml b/forui/pubspec.yaml
index 87d78832d..9ce013bd0 100644
--- a/forui/pubspec.yaml
+++ b/forui/pubspec.yaml
@@ -21,6 +21,7 @@ dependencies:
flutter_svg: ^2.0.10+1
forui_assets: ^0.2.0
google_fonts: ^6.2.0
+ intl: any
meta: ^1.11.0
nitrogen_flutter_svg: ^0.3.0+1
nitrogen_types: ^0.3.0+1
diff --git a/forui/test/golden/calendar/day-picker/zinc-dark-default.png b/forui/test/golden/calendar/day-picker/zinc-dark-default.png
new file mode 100644
index 000000000..0f3dfc9db
Binary files /dev/null and b/forui/test/golden/calendar/day-picker/zinc-dark-default.png differ
diff --git a/forui/test/golden/calendar/day-picker/zinc-dark-max-rows.png b/forui/test/golden/calendar/day-picker/zinc-dark-max-rows.png
new file mode 100644
index 000000000..39a4303aa
Binary files /dev/null and b/forui/test/golden/calendar/day-picker/zinc-dark-max-rows.png differ
diff --git a/forui/test/golden/calendar/day-picker/zinc-light-default.png b/forui/test/golden/calendar/day-picker/zinc-light-default.png
new file mode 100644
index 000000000..499ba0104
Binary files /dev/null and b/forui/test/golden/calendar/day-picker/zinc-light-default.png differ
diff --git a/forui/test/golden/calendar/day-picker/zinc-light-max-rows.png b/forui/test/golden/calendar/day-picker/zinc-light-max-rows.png
new file mode 100644
index 000000000..eeca3a041
Binary files /dev/null and b/forui/test/golden/calendar/day-picker/zinc-light-max-rows.png differ
diff --git a/forui/test/golden/calendar/month-picker/zinc-dark-default.png b/forui/test/golden/calendar/month-picker/zinc-dark-default.png
new file mode 100644
index 000000000..ea656d363
Binary files /dev/null and b/forui/test/golden/calendar/month-picker/zinc-dark-default.png differ
diff --git a/forui/test/golden/calendar/month-picker/zinc-light-default.png b/forui/test/golden/calendar/month-picker/zinc-light-default.png
new file mode 100644
index 000000000..01504167d
Binary files /dev/null and b/forui/test/golden/calendar/month-picker/zinc-light-default.png differ
diff --git a/forui/test/golden/calendar/year-picker/zinc-dark-default.png b/forui/test/golden/calendar/year-picker/zinc-dark-default.png
new file mode 100644
index 000000000..755b2533a
Binary files /dev/null and b/forui/test/golden/calendar/year-picker/zinc-dark-default.png differ
diff --git a/forui/test/golden/calendar/year-picker/zinc-dark-initial-date.png b/forui/test/golden/calendar/year-picker/zinc-dark-initial-date.png
new file mode 100644
index 000000000..a2dc2edf0
Binary files /dev/null and b/forui/test/golden/calendar/year-picker/zinc-dark-initial-date.png differ
diff --git a/forui/test/golden/calendar/year-picker/zinc-light-default.png b/forui/test/golden/calendar/year-picker/zinc-light-default.png
new file mode 100644
index 000000000..78a0f9a03
Binary files /dev/null and b/forui/test/golden/calendar/year-picker/zinc-light-default.png differ
diff --git a/forui/test/golden/calendar/year-picker/zinc-light-initial-date.png b/forui/test/golden/calendar/year-picker/zinc-light-initial-date.png
new file mode 100644
index 000000000..cfb7ae928
Binary files /dev/null and b/forui/test/golden/calendar/year-picker/zinc-light-initial-date.png differ
diff --git a/forui/test/src/foundation/inkwell_test.dart b/forui/test/src/foundation/inkwell_test.dart
new file mode 100644
index 000000000..654708929
--- /dev/null
+++ b/forui/test/src/foundation/inkwell_test.dart
@@ -0,0 +1,82 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/inkwell.dart';
+import '../test_scaffold.dart';
+
+void main() {
+ group('FInkWell', () {
+ testWidgets('focused', (tester) async {
+ final focusNode = FocusNode();
+
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FInkWell(
+ focusNode: focusNode,
+ builder: (_, value, __) => Text('$value'),
+ ),
+ ),
+ );
+ expect(find.text('false'), findsOneWidget);
+
+ focusNode.requestFocus();
+ await tester.pumpAndSettle();
+ expect(find.text('true'), findsOneWidget);
+ });
+
+ testWidgets('hovered', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FInkWell(
+ builder: (_, value, __) => Text('$value'),
+ ),
+ ),
+ );
+ expect(find.text('false'), findsOneWidget);
+
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ addTearDown(gesture.removePointer);
+ await tester.pump();
+
+ await gesture.moveTo(tester.getCenter(find.byType(FInkWell)));
+ await tester.pumpAndSettle();
+
+ expect(find.text('true'), findsOneWidget);
+
+ await gesture.moveTo(Offset.zero);
+ await tester.pumpAndSettle();
+
+ expect(find.text('false'), findsOneWidget);
+ });
+
+ testWidgets('semantics', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FInkWell(
+ semanticLabel: 'My Label',
+ selected: true,
+ builder: (_, value, __) => Text('$value'),
+ ),
+ ),
+ );
+
+ final semantics = tester.getSemantics(find.byType(FInkWell));
+ expect(
+ semantics,
+ matchesSemantics(
+ label: 'My Label',
+ isButton: true,
+ isSelected: true,
+ isFocusable: true,
+ ),
+ );
+ });
+ });
+}
diff --git a/forui/test/src/widgets/calendar/calendar_controller_test.dart b/forui/test/src/widgets/calendar/calendar_controller_test.dart
new file mode 100644
index 000000000..820f8bc7a
--- /dev/null
+++ b/forui/test/src/widgets/calendar/calendar_controller_test.dart
@@ -0,0 +1,91 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+
+void main() {
+ group('FCalendarSingleValueController', () {
+ test(
+ 'constructor throws error',
+ () => expect(() => FCalendarSingleValueController(DateTime.now()), throwsAssertionError),
+ );
+
+ for (final (date, expected) in [
+ (DateTime.utc(2024, 5, 4), true),
+ (DateTime.utc(2024, 5, 5), false),
+ ]) {
+ test('contains(...) contains date', () {
+ final controller = FCalendarSingleValueController(DateTime.utc(2024, 5, 4));
+ expect(controller.contains(date), expected);
+ });
+ }
+
+ for (final (initial, date, expected) in [
+ (null, DateTime.utc(2024), DateTime.utc(2024)),
+ (null, DateTime.utc(2025), DateTime.utc(2025)),
+ (DateTime.utc(2024), DateTime.utc(2025), DateTime.utc(2025)),
+ (DateTime.utc(2024), DateTime.utc(2024), null),
+ ]) {
+ test('onPress(...)', () {
+ final controller = FCalendarSingleValueController(initial)..onPress(date);
+ expect(controller.value, expected);
+ });
+ }
+ });
+
+ group('FCalendarMultiValueController', () {
+ for (final (date, expected) in [
+ (DateTime.utc(2024), true),
+ (DateTime.utc(2025), false),
+ ]) {
+ test('contains(...)', () {
+ final controller = FCalendarMultiValueController({DateTime.utc(2024)});
+ expect(controller.contains(date), expected);
+ });
+ }
+
+ for (final (initial, date, expected) in [
+ ({DateTime.utc(2024)}, DateTime.utc(2024), {}),
+ ({}, DateTime.utc(2024), {DateTime.utc(2024)}),
+ ({DateTime.utc(2024)}, DateTime.utc(2025), {DateTime.utc(2024), DateTime.utc(2025)}),
+ ]) {
+ test('onPress(...)', () {
+ final controller = FCalendarMultiValueController(initial)..onPress(date);
+ expect(controller.value, expected);
+ });
+ }
+ });
+
+ group('FCalendarSingleRangeController', () {
+ test(
+ 'constructor throws error',
+ () => expect(() => FCalendarSingleRangeController((DateTime(2025), DateTime(2024))), throwsAssertionError),
+ );
+
+ for (final (initial, date, expected) in [
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2024), true),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2025), true),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2023), false),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2026), false),
+ (null, DateTime.utc(2023), false),
+ ]) {
+ test('contains(...)', () {
+ final controller = FCalendarSingleRangeController(initial);
+ expect(controller.contains(date), expected);
+ });
+ }
+
+ for (final (initial, date, expected) in [
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2024), null),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2025), null),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2023), (DateTime.utc(2023), DateTime.utc(2025))),
+ ((DateTime.utc(2024), DateTime.utc(2025)), DateTime.utc(2026), (DateTime.utc(2024), DateTime.utc(2026))),
+ ((DateTime.utc(2024), DateTime.utc(2027)), DateTime.utc(2025), (DateTime.utc(2024), DateTime.utc(2025))),
+ (null, DateTime.utc(2023), (DateTime.utc(2023), DateTime.utc(2023))),
+ ]) {
+ test('onPress(...)', () {
+ final controller = FCalendarSingleRangeController(initial)..onPress(date);
+ expect(controller.value, expected);
+ });
+ }
+ });
+}
diff --git a/forui/test/src/widgets/calendar/calendar_golden_test.dart b/forui/test/src/widgets/calendar/calendar_golden_test.dart
new file mode 100644
index 000000000..dc78db1f3
--- /dev/null
+++ b/forui/test/src/widgets/calendar/calendar_golden_test.dart
@@ -0,0 +1,177 @@
+@Tags(['golden'])
+library;
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ final selected = {
+ DateTime.utc(2024, 7, 4),
+ DateTime.utc(2024, 7, 5),
+ DateTime.utc(2024, 7, 16),
+ DateTime.utc(2024, 7, 17),
+ DateTime.utc(2024, 7, 18),
+ };
+
+ group('FCalendar', () {
+ for (final (name, theme, background) in TestScaffold.themes) {
+ group('day picker', () {
+ testWidgets('default - $name', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: theme,
+ background: background,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: FCalendar(
+ controller: FCalendarMultiValueController(selected),
+ enabled: (date) => date != DateTime.utc(2024, 7, 2),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ ),
+ ),
+ ),
+ );
+
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ addTearDown(gesture.removePointer);
+ await tester.pump();
+
+ await gesture.moveTo(tester.getCenter(find.text('8')));
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('calendar/day-picker/$name-default.png'),
+ );
+ });
+
+ testWidgets('max rows - $name', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: theme,
+ background: background,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: FCalendar(
+ controller: FCalendarMultiValueController(selected),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 6, 14),
+ ),
+ ),
+ ),
+ );
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('calendar/day-picker/$name-max-rows.png'),
+ );
+ });
+ });
+
+ group('month picker', () {
+ testWidgets('default - $name', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: theme,
+ background: background,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: FCalendar(
+ controller: FCalendarMultiValueController(selected),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ initialType: FCalendarPickerType.yearMonth,
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('2020'));
+ await tester.pumpAndSettle();
+
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ addTearDown(gesture.removePointer);
+ await tester.pump();
+
+ await gesture.moveTo(tester.getCenter(find.text('Feb')));
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('calendar/month-picker/$name-default.png'),
+ );
+ });
+ });
+
+ group('year picker', () {
+ testWidgets('default - $name', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: theme,
+ background: background,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: FCalendar(
+ controller: FCalendarMultiValueController(selected),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ initialType: FCalendarPickerType.yearMonth,
+ ),
+ ),
+ ),
+ );
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('calendar/year-picker/$name-default.png'),
+ );
+ });
+
+ testWidgets('initial date different from today - $name', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: theme,
+ background: background,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: FCalendar(
+ controller: FCalendarMultiValueController(selected),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ initialDate: DateTime(1984, 4, 2),
+ initialType: FCalendarPickerType.yearMonth,
+ ),
+ ),
+ ),
+ );
+
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ addTearDown(gesture.removePointer);
+ await tester.pump();
+
+ await gesture.moveTo(tester.getCenter(find.text('1991')));
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('calendar/year-picker/$name-initial-date.png'),
+ );
+ });
+ });
+ }
+ });
+}
diff --git a/forui/test/src/widgets/calendar/calendar_test.dart b/forui/test/src/widgets/calendar/calendar_test.dart
new file mode 100644
index 000000000..386d31e6c
--- /dev/null
+++ b/forui/test/src/widgets/calendar/calendar_test.dart
@@ -0,0 +1,100 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ group('FCalendar', () {
+ group('previous button', () {
+ testWidgets('navigates to previous page', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FCalendar(
+ controller: FCalendarMultiValueController(),
+ enabled: (date) => date != DateTime.utc(2024, 7, 2),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ ),
+ ),
+ );
+
+ expect(find.text('July 2024'), findsOneWidget);
+
+ await tester.tap(find.byType(FButton).first);
+ await tester.pumpAndSettle();
+
+ expect(find.text('June 2024'), findsOneWidget);
+ });
+
+ testWidgets('did not navigate to previous page', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FCalendar(
+ controller: FCalendarMultiValueController(),
+ enabled: (date) => date != DateTime.utc(2024, 7, 2),
+ start: DateTime(2024, 7),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ ),
+ ),
+ );
+
+ expect(find.text('July 2024'), findsOneWidget);
+
+ await tester.tap(find.byType(FButton).first);
+ await tester.pumpAndSettle();
+
+ expect(find.text('June 2024'), findsNothing);
+ });
+ });
+
+ group('next button', () {
+ testWidgets('navigates to next page', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FCalendar(
+ controller: FCalendarMultiValueController(),
+ enabled: (date) => date != DateTime.utc(2024, 7, 2),
+ start: DateTime(1900, 1, 8),
+ end: DateTime(2024, 8, 10),
+ today: DateTime(2024, 7, 14),
+ ),
+ ),
+ );
+
+ expect(find.text('July 2024'), findsOneWidget);
+
+ await tester.tap(find.byType(FButton).last);
+ await tester.pumpAndSettle();
+
+ expect(find.text('August 2024'), findsOneWidget);
+ });
+
+ testWidgets('did not navigate to next page', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ data: FThemes.zinc.light,
+ child: FCalendar(
+ controller: FCalendarMultiValueController(),
+ enabled: (date) => date != DateTime.utc(2024, 7, 2),
+ start: DateTime(2024),
+ end: DateTime(2024, 7, 10),
+ today: DateTime(2024, 7, 14),
+ ),
+ ),
+ );
+
+ expect(find.text('July 2024'), findsOneWidget);
+
+ await tester.tap(find.byType(FButton).first);
+ await tester.pumpAndSettle();
+
+ expect(find.text('August 2024'), findsNothing);
+ });
+ });
+ });
+}
diff --git a/samples/lib/main.dart b/samples/lib/main.dart
index d377b51fb..a6a06a53a 100644
--- a/samples/lib/main.dart
+++ b/samples/lib/main.dart
@@ -51,6 +51,18 @@ class _AppRouter extends $_AppRouter {
path: '/button/icon',
page: ButtonIconRoute.page,
),
+ AutoRoute(
+ path: '/calendar/default',
+ page: CalendarRoute.page,
+ ),
+ AutoRoute(
+ path: '/calendar/multi-value',
+ page: MultiValueCalendarRoute.page,
+ ),
+ AutoRoute(
+ path: '/calendar/single-range',
+ page: SingleRangeCalendarRoute.page,
+ ),
AutoRoute(
path: '/card/default',
page: CardRoute.page,
diff --git a/samples/lib/widgets/calendar.dart b/samples/lib/widgets/calendar.dart
new file mode 100644
index 000000000..45309e96a
--- /dev/null
+++ b/samples/lib/widgets/calendar.dart
@@ -0,0 +1,48 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:forui/forui.dart';
+
+import 'package:forui_samples/sample_scaffold.dart';
+
+@RoutePage()
+class CalendarPage extends SampleScaffold {
+ CalendarPage({
+ @queryParam super.theme,
+ });
+
+ @override
+ Widget child(BuildContext context) => FCalendar(
+ controller: FCalendarSingleValueController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+}
+
+@RoutePage()
+class MultiValueCalendarPage extends SampleScaffold {
+ MultiValueCalendarPage({
+ @queryParam super.theme,
+ });
+
+ @override
+ Widget child(BuildContext context) => FCalendar(
+ controller: FCalendarMultiValueController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+}
+
+@RoutePage()
+class SingleRangeCalendarPage extends SampleScaffold {
+ SingleRangeCalendarPage({
+ @queryParam super.theme,
+ });
+
+ @override
+ Widget child(BuildContext context) => FCalendar(
+ controller: FCalendarSingleRangeController(),
+ start: DateTime.utc(2024),
+ end: DateTime.utc(2030),
+ );
+}
diff --git a/samples/pubspec.lock b/samples/pubspec.lock
index 4fac45f3b..9968df5e8 100644
--- a/samples/pubspec.lock
+++ b/samples/pubspec.lock
@@ -479,10 +479,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
- sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a
+ sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e"
url: "https://pub.dev"
source: hosted
- version: "2.2.6"
+ version: "2.2.7"
path_provider_foundation:
dependency: transitive
description:
@@ -511,10 +511,10 @@ packages:
dependency: transitive
description:
name: path_provider_windows
- sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
- version: "2.2.1"
+ version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -744,14 +744,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
- win32:
- dependency: transitive
- description:
- name: win32
- sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
- url: "https://pub.dev"
- source: hosted
- version: "5.5.1"
xdg_directories:
dependency: transitive
description: