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: