diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6b71c53
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+# Build and test the DurationPicker package
+name: CI
+
+# Runs on pushes and pull requests targeting the default branch
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+# Allow one concurrent deployment
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: macos-13
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ # For list of all Xcode and iOS versions, see https://github.com/actions/runner-images/blob/main/images/macos/macos-13-Readme.md#xcode
+ - name: Select Xcode 15.1
+ run: sudo xcode-select -s "/Applications/Xcode_15.1.app"
+ - name: List available iOS simulators
+ run: xcrun simctl list devices | grep -E "iPhone|iPad" | awk -F '[()]' '{print $1 ",", "iOS", $2}'
+ - name: Run tests
+ run: |
+ xcodebuild test \
+ -scheme DurationPicker \
+ -sdk iphonesimulator \
+ -destination 'platform=iOS Simulator,name=iPhone 15 Pro Max,OS=17.2' \
+ -configuration Debug \
+ || exit 1; \
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
new file mode 100644
index 0000000..73994c2
--- /dev/null
+++ b/.github/workflows/documentation.yml
@@ -0,0 +1,52 @@
+# Build and deploy DocC to GitHub pages. Based off of the following guide:
+# https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5
+name: Documentation
+
+# Runs on pushes targeting the default branch
+on:
+ push:
+ branches:
+ - main
+
+# Allow one concurrent deployment
+concurrency:
+ group: "pages"
+ cancel-in-progress: true
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+jobs:
+ build:
+ environment:
+ # Must be set to this for deploying to GitHub Pages
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: macos-13
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ # For list of all Xcode and iOS versions, see https://github.com/actions/runner-images/blob/main/images/macos/macos-13-Readme.md#xcode
+ - name: Select Xcode
+ run: sudo xcode-select -s "/Applications/Xcode_15.0.1.app"
+ - name: Build DocC
+ run: |
+ xcodebuild docbuild -scheme DurationPicker \
+ -derivedDataPath /tmp/docbuild \
+ -destination 'generic/platform=iOS';
+ $(xcrun --find docc) process-archive \
+ transform-for-static-hosting /tmp/docbuild/Build/Products/Debug-iphoneos/DurationPicker.doccarchive \
+ --hosting-base-path DurationPicker \
+ --output-path docs;
+ echo "" > docs/index.html;
+ - name: Upload Artifact
+ uses: actions/upload-pages-artifact@v2
+ with:
+ # Upload only docs directory from output above
+ path: 'docs'
+ - name: Deploy to GitHub Pages
+ uses: actions/deploy-pages@v3
+ id: deployment
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..97cfad1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+/.build
+xcuserdata
+.swiftpm
+.build
\ No newline at end of file
diff --git a/Demo/DurationPickerDemo.xcodeproj/project.pbxproj b/Demo/DurationPickerDemo.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..406bcd8
--- /dev/null
+++ b/Demo/DurationPickerDemo.xcodeproj/project.pbxproj
@@ -0,0 +1,596 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 60;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ AD22D49E2AC9E7D100442E8F /* DemoDataSource+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4822AC9E7D100442E8F /* DemoDataSource+Models.swift */; };
+ AD22D49F2AC9E7D100442E8F /* DemoDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4832AC9E7D100442E8F /* DemoDataSource.swift */; };
+ AD22D4A12AC9E7D100442E8F /* DemoMenuButtonCellTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4862AC9E7D100442E8F /* DemoMenuButtonCellTransformer.swift */; };
+ AD22D4A22AC9E7D100442E8F /* DemoTextFieldCellTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4872AC9E7D100442E8F /* DemoTextFieldCellTransformer.swift */; };
+ AD22D4A42AC9E7D100442E8F /* DemoTitleCellTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4892AC9E7D100442E8F /* DemoTitleCellTransformer.swift */; };
+ AD22D4A52AC9E7D100442E8F /* DemoAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D48B2AC9E7D100442E8F /* DemoAdapter.swift */; };
+ AD22D4A62AC9E7D100442E8F /* DemoCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D48C2AC9E7D100442E8F /* DemoCellDelegate.swift */; };
+ AD22D4A92AC9E7D100442E8F /* DemoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4912AC9E7D100442E8F /* DemoViewModel.swift */; };
+ AD22D4AA2AC9E7D100442E8F /* DemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4932AC9E7D100442E8F /* DemoViewController.swift */; };
+ AD22D4AB2AC9E7D100442E8F /* DemoDurationPickerContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4952AC9E7D100442E8F /* DemoDurationPickerContentConfiguration.swift */; };
+ AD22D4AC2AC9E7D100442E8F /* DemoTextFieldContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4962AC9E7D100442E8F /* DemoTextFieldContentConfiguration.swift */; };
+ AD22D4AD2AC9E7D100442E8F /* DemoDatePickerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4972AC9E7D100442E8F /* DemoDatePickerContentView.swift */; };
+ AD22D4AE2AC9E7D100442E8F /* DemoTextFieldContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D4982AC9E7D100442E8F /* DemoTextFieldContentView.swift */; };
+ AD22D4B02AC9E7D100442E8F /* DemoDurationPickerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D49A2AC9E7D100442E8F /* DemoDurationPickerContentView.swift */; };
+ AD22D4B12AC9E7D100442E8F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D49B2AC9E7D100442E8F /* AppDelegate.swift */; };
+ AD22D4B32AC9E7D100442E8F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD22D49D2AC9E7D100442E8F /* SceneDelegate.swift */; };
+ AD22D4B62AC9E7EE00442E8F /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = AD22D4B52AC9E7EE00442E8F /* DurationPicker */; };
+ AD2F43312AC9EA4C007A8B09 /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = AD2F43302AC9EA4C007A8B09 /* DurationPicker */; };
+ AD4CB8952B74B6090048C037 /* DemoDatePickerCellTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CB8942B74B6090048C037 /* DemoDatePickerCellTransformer.swift */; };
+ AD4CB8972B74B6440048C037 /* DemoDurationPickerCellTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CB8962B74B6440048C037 /* DemoDurationPickerCellTransformer.swift */; };
+ AD4CB89B2B74B7260048C037 /* DemoDatePickerContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CB89A2B74B7260048C037 /* DemoDatePickerContentConfiguration.swift */; };
+ AD4E7C612B789AE90012FF74 /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = AD4E7C602B789AE90012FF74 /* DurationPicker */; };
+ ADA18D812AC9EAEE00563833 /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = ADA18D802AC9EAEE00563833 /* DurationPicker */; };
+ ADA18D842AC9EBEA00563833 /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = ADA18D832AC9EBEA00563833 /* DurationPicker */; };
+ ADA18D872AC9EC8C00563833 /* DurationPicker in Frameworks */ = {isa = PBXBuildFile; productRef = ADA18D862AC9EC8C00563833 /* DurationPicker */; };
+ ADDC067F2B13A8E800EBF5F4 /* DurationPicker.Mode++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADDC067E2B13A8E800EBF5F4 /* DurationPicker.Mode++.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ AD22D4CA2AC9E89C00442E8F /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ AD22D4692AC9E7A600442E8F /* DurationPickerDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DurationPickerDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ AD22D4822AC9E7D100442E8F /* DemoDataSource+Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DemoDataSource+Models.swift"; sourceTree = ""; };
+ AD22D4832AC9E7D100442E8F /* DemoDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDataSource.swift; sourceTree = ""; };
+ AD22D4862AC9E7D100442E8F /* DemoMenuButtonCellTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoMenuButtonCellTransformer.swift; sourceTree = ""; };
+ AD22D4872AC9E7D100442E8F /* DemoTextFieldCellTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoTextFieldCellTransformer.swift; sourceTree = ""; };
+ AD22D4892AC9E7D100442E8F /* DemoTitleCellTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoTitleCellTransformer.swift; sourceTree = ""; };
+ AD22D48B2AC9E7D100442E8F /* DemoAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAdapter.swift; sourceTree = ""; };
+ AD22D48C2AC9E7D100442E8F /* DemoCellDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoCellDelegate.swift; sourceTree = ""; };
+ AD22D4912AC9E7D100442E8F /* DemoViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoViewModel.swift; sourceTree = ""; };
+ AD22D4932AC9E7D100442E8F /* DemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoViewController.swift; sourceTree = ""; };
+ AD22D4952AC9E7D100442E8F /* DemoDurationPickerContentConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDurationPickerContentConfiguration.swift; sourceTree = ""; };
+ AD22D4962AC9E7D100442E8F /* DemoTextFieldContentConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoTextFieldContentConfiguration.swift; sourceTree = ""; };
+ AD22D4972AC9E7D100442E8F /* DemoDatePickerContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDatePickerContentView.swift; sourceTree = ""; };
+ AD22D4982AC9E7D100442E8F /* DemoTextFieldContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoTextFieldContentView.swift; sourceTree = ""; };
+ AD22D49A2AC9E7D100442E8F /* DemoDurationPickerContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDurationPickerContentView.swift; sourceTree = ""; };
+ AD22D49B2AC9E7D100442E8F /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ AD22D49C2AC9E7D100442E8F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ AD22D49D2AC9E7D100442E8F /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ AD4CB8942B74B6090048C037 /* DemoDatePickerCellTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDatePickerCellTransformer.swift; sourceTree = ""; };
+ AD4CB8962B74B6440048C037 /* DemoDurationPickerCellTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDurationPickerCellTransformer.swift; sourceTree = ""; };
+ AD4CB89A2B74B7260048C037 /* DemoDatePickerContentConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoDatePickerContentConfiguration.swift; sourceTree = ""; };
+ ADDC067E2B13A8E800EBF5F4 /* DurationPicker.Mode++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DurationPicker.Mode++.swift"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ AD22D4662AC9E7A600442E8F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ ADA18D872AC9EC8C00563833 /* DurationPicker in Frameworks */,
+ AD2F43312AC9EA4C007A8B09 /* DurationPicker in Frameworks */,
+ AD4E7C612B789AE90012FF74 /* DurationPicker in Frameworks */,
+ AD22D4B62AC9E7EE00442E8F /* DurationPicker in Frameworks */,
+ ADA18D842AC9EBEA00563833 /* DurationPicker in Frameworks */,
+ ADA18D812AC9EAEE00563833 /* DurationPicker in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ AD1229FD2B43D71B00F18A20 /* Demo */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D48A2AC9E7D100442E8F /* Adapter */,
+ AD22D4812AC9E7D100442E8F /* Data Source */,
+ ADDC06792B0EF98100EBF5F4 /* Extensions */,
+ AD22D4842AC9E7D100442E8F /* Transformer */,
+ AD22D4922AC9E7D100442E8F /* View */,
+ AD22D4912AC9E7D100442E8F /* DemoViewModel.swift */,
+ );
+ path = Demo;
+ sourceTree = "";
+ };
+ AD22D4602AC9E7A600442E8F = {
+ isa = PBXGroup;
+ children = (
+ AD22D4802AC9E7D100442E8F /* DurationPickerDemo */,
+ AD22D46A2AC9E7A600442E8F /* Products */,
+ AD22D4D82AC9E97C00442E8F /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ AD22D46A2AC9E7A600442E8F /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D4692AC9E7A600442E8F /* DurationPickerDemo.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ AD22D4802AC9E7D100442E8F /* DurationPickerDemo */ = {
+ isa = PBXGroup;
+ children = (
+ AD7AB6C22B34D1AB00BA8BD6 /* App */,
+ AD1229FD2B43D71B00F18A20 /* Demo */,
+ AD22D4B72AC9E83900442E8F /* Resources */,
+ );
+ path = DurationPickerDemo;
+ sourceTree = "";
+ };
+ AD22D4812AC9E7D100442E8F /* Data Source */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D4822AC9E7D100442E8F /* DemoDataSource+Models.swift */,
+ AD22D4832AC9E7D100442E8F /* DemoDataSource.swift */,
+ );
+ path = "Data Source";
+ sourceTree = "";
+ };
+ AD22D4842AC9E7D100442E8F /* Transformer */ = {
+ isa = PBXGroup;
+ children = (
+ AD4CB8942B74B6090048C037 /* DemoDatePickerCellTransformer.swift */,
+ AD4CB8962B74B6440048C037 /* DemoDurationPickerCellTransformer.swift */,
+ AD22D4862AC9E7D100442E8F /* DemoMenuButtonCellTransformer.swift */,
+ AD22D4872AC9E7D100442E8F /* DemoTextFieldCellTransformer.swift */,
+ AD22D4892AC9E7D100442E8F /* DemoTitleCellTransformer.swift */,
+ );
+ path = Transformer;
+ sourceTree = "";
+ };
+ AD22D48A2AC9E7D100442E8F /* Adapter */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D48B2AC9E7D100442E8F /* DemoAdapter.swift */,
+ AD22D48C2AC9E7D100442E8F /* DemoCellDelegate.swift */,
+ );
+ path = Adapter;
+ sourceTree = "";
+ };
+ AD22D4922AC9E7D100442E8F /* View */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D4942AC9E7D100442E8F /* Content View */,
+ AD22D4932AC9E7D100442E8F /* DemoViewController.swift */,
+ );
+ path = View;
+ sourceTree = "";
+ };
+ AD22D4942AC9E7D100442E8F /* Content View */ = {
+ isa = PBXGroup;
+ children = (
+ AD4CB89A2B74B7260048C037 /* DemoDatePickerContentConfiguration.swift */,
+ AD22D4972AC9E7D100442E8F /* DemoDatePickerContentView.swift */,
+ AD22D4952AC9E7D100442E8F /* DemoDurationPickerContentConfiguration.swift */,
+ AD22D49A2AC9E7D100442E8F /* DemoDurationPickerContentView.swift */,
+ AD22D4962AC9E7D100442E8F /* DemoTextFieldContentConfiguration.swift */,
+ AD22D4982AC9E7D100442E8F /* DemoTextFieldContentView.swift */,
+ );
+ path = "Content View";
+ sourceTree = "";
+ };
+ AD22D4B72AC9E83900442E8F /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D49C2AC9E7D100442E8F /* Info.plist */,
+ );
+ name = Resources;
+ sourceTree = "";
+ };
+ AD22D4D82AC9E97C00442E8F /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ AD7AB6C22B34D1AB00BA8BD6 /* App */ = {
+ isa = PBXGroup;
+ children = (
+ AD22D49B2AC9E7D100442E8F /* AppDelegate.swift */,
+ AD22D49D2AC9E7D100442E8F /* SceneDelegate.swift */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ ADDC06792B0EF98100EBF5F4 /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ ADDC067E2B13A8E800EBF5F4 /* DurationPicker.Mode++.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ AD22D4682AC9E7A600442E8F /* DurationPickerDemo */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AD22D47D2AC9E7A700442E8F /* Build configuration list for PBXNativeTarget "DurationPickerDemo" */;
+ buildPhases = (
+ AD22D4652AC9E7A600442E8F /* Sources */,
+ AD22D4662AC9E7A600442E8F /* Frameworks */,
+ AD22D4672AC9E7A600442E8F /* Resources */,
+ AD22D4CA2AC9E89C00442E8F /* Embed Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = DurationPickerDemo;
+ packageProductDependencies = (
+ AD22D4B52AC9E7EE00442E8F /* DurationPicker */,
+ AD2F43302AC9EA4C007A8B09 /* DurationPicker */,
+ ADA18D802AC9EAEE00563833 /* DurationPicker */,
+ ADA18D832AC9EBEA00563833 /* DurationPicker */,
+ ADA18D862AC9EC8C00563833 /* DurationPicker */,
+ AD4E7C602B789AE90012FF74 /* DurationPicker */,
+ );
+ productName = DurationPickerDemo;
+ productReference = AD22D4692AC9E7A600442E8F /* DurationPickerDemo.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ AD22D4612AC9E7A600442E8F /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ AD22D4682AC9E7A600442E8F = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = AD22D4642AC9E7A600442E8F /* Build configuration list for PBXProject "DurationPickerDemo" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ fr,
+ ca,
+ "zh-HK",
+ "zh-Hans",
+ "zh-Hant",
+ hr,
+ cs,
+ uk,
+ "pt-PT",
+ "pt-BR",
+ "es-419",
+ ro,
+ sk,
+ hu,
+ nb,
+ ms,
+ ru,
+ it,
+ "en-IN",
+ el,
+ pl,
+ es,
+ sv,
+ fi,
+ de,
+ hi,
+ ja,
+ id,
+ "en-GB",
+ "en-AU",
+ tr,
+ "fr-CA",
+ ko,
+ nl,
+ da,
+ th,
+ vi,
+ ar,
+ );
+ mainGroup = AD22D4602AC9E7A600442E8F;
+ packageReferences = (
+ AD4E7C5F2B789AE90012FF74 /* XCLocalSwiftPackageReference ".." */,
+ );
+ productRefGroup = AD22D46A2AC9E7A600442E8F /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ AD22D4682AC9E7A600442E8F /* DurationPickerDemo */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ AD22D4672AC9E7A600442E8F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ AD22D4652AC9E7A600442E8F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AD4CB8952B74B6090048C037 /* DemoDatePickerCellTransformer.swift in Sources */,
+ AD22D4A52AC9E7D100442E8F /* DemoAdapter.swift in Sources */,
+ AD22D4B12AC9E7D100442E8F /* AppDelegate.swift in Sources */,
+ AD22D49F2AC9E7D100442E8F /* DemoDataSource.swift in Sources */,
+ AD22D4A92AC9E7D100442E8F /* DemoViewModel.swift in Sources */,
+ AD22D4B02AC9E7D100442E8F /* DemoDurationPickerContentView.swift in Sources */,
+ AD22D4AE2AC9E7D100442E8F /* DemoTextFieldContentView.swift in Sources */,
+ ADDC067F2B13A8E800EBF5F4 /* DurationPicker.Mode++.swift in Sources */,
+ AD22D4A22AC9E7D100442E8F /* DemoTextFieldCellTransformer.swift in Sources */,
+ AD22D4A12AC9E7D100442E8F /* DemoMenuButtonCellTransformer.swift in Sources */,
+ AD22D4AA2AC9E7D100442E8F /* DemoViewController.swift in Sources */,
+ AD22D4A62AC9E7D100442E8F /* DemoCellDelegate.swift in Sources */,
+ AD22D49E2AC9E7D100442E8F /* DemoDataSource+Models.swift in Sources */,
+ AD22D4AB2AC9E7D100442E8F /* DemoDurationPickerContentConfiguration.swift in Sources */,
+ AD22D4A42AC9E7D100442E8F /* DemoTitleCellTransformer.swift in Sources */,
+ AD22D4B32AC9E7D100442E8F /* SceneDelegate.swift in Sources */,
+ AD22D4AD2AC9E7D100442E8F /* DemoDatePickerContentView.swift in Sources */,
+ AD4CB89B2B74B7260048C037 /* DemoDatePickerContentConfiguration.swift in Sources */,
+ AD4CB8972B74B6440048C037 /* DemoDurationPickerCellTransformer.swift in Sources */,
+ AD22D4AC2AC9E7D100442E8F /* DemoTextFieldContentConfiguration.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ AD22D47B2AC9E7A700442E8F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = 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_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ 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_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ AD22D47C2AC9E7A700442E8F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = 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_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ AD22D47E2AC9E7A700442E8F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = DYZ8Y2U8DB;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = DurationPickerDemo/Info.plist;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.macgallagher.DurationPickerDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ AD22D47F2AC9E7A700442E8F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = DYZ8Y2U8DB;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = DurationPickerDemo/Info.plist;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.macgallagher.DurationPickerDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ AD22D4642AC9E7A600442E8F /* Build configuration list for PBXProject "DurationPickerDemo" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AD22D47B2AC9E7A700442E8F /* Debug */,
+ AD22D47C2AC9E7A700442E8F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ AD22D47D2AC9E7A700442E8F /* Build configuration list for PBXNativeTarget "DurationPickerDemo" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AD22D47E2AC9E7A700442E8F /* Debug */,
+ AD22D47F2AC9E7A700442E8F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ AD4E7C5F2B789AE90012FF74 /* XCLocalSwiftPackageReference ".." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ..;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ AD22D4B52AC9E7EE00442E8F /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+ AD2F43302AC9EA4C007A8B09 /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+ AD4E7C602B789AE90012FF74 /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+ ADA18D802AC9EAEE00563833 /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+ ADA18D832AC9EBEA00563833 /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+ ADA18D862AC9EC8C00563833 /* DurationPicker */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DurationPicker;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = AD22D4612AC9E7A600442E8F /* Project object */;
+}
diff --git a/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/Demo/DurationPickerDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Demo/DurationPickerDemo.xcodeproj/xcshareddata/xcschemes/DurationPickerDemo.xcscheme b/Demo/DurationPickerDemo.xcodeproj/xcshareddata/xcschemes/DurationPickerDemo.xcscheme
new file mode 100644
index 0000000..6d25a4f
--- /dev/null
+++ b/Demo/DurationPickerDemo.xcodeproj/xcshareddata/xcschemes/DurationPickerDemo.xcscheme
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Demo/DurationPickerDemo/App/AppDelegate.swift b/Demo/DurationPickerDemo/App/AppDelegate.swift
new file mode 100644
index 0000000..0676329
--- /dev/null
+++ b/Demo/DurationPickerDemo/App/AppDelegate.swift
@@ -0,0 +1,35 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+@main
+final class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ func application(_ application: UIApplication,
+ configurationForConnecting connectingSceneSession: UISceneSession,
+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ UISceneConfiguration(
+ name: "Default Configuration",
+ sessionRole: connectingSceneSession.role)
+ }
+}
diff --git a/Demo/DurationPickerDemo/App/SceneDelegate.swift b/Demo/DurationPickerDemo/App/SceneDelegate.swift
new file mode 100644
index 0000000..eb6b0c3
--- /dev/null
+++ b/Demo/DurationPickerDemo/App/SceneDelegate.swift
@@ -0,0 +1,41 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+
+ var window: UIWindow?
+
+ func scene(_ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions) {
+ guard let windowScene = scene as? UIWindowScene else {
+ return
+ }
+
+ window = UIWindow(windowScene: windowScene)
+ let navigationController = UINavigationController(rootViewController: DemoViewController())
+ window?.rootViewController = navigationController
+ window?.makeKeyAndVisible()
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Adapter/DemoAdapter.swift b/Demo/DurationPickerDemo/Demo/Adapter/DemoAdapter.swift
new file mode 100644
index 0000000..2583747
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Adapter/DemoAdapter.swift
@@ -0,0 +1,85 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+typealias DemoCellRegistration = UICollectionView.CellRegistration
+
+enum DemoAdapter {
+
+ static func makeCellProvider(viewModel: DemoViewModel,
+ delegate: DemoCellDelegate?) -> DemoDataSource.CellProvider {
+ let titleCellTransformer = DemoTitleCellTransformer.makeCellRegistration(viewModel: viewModel)
+
+ let datePickerCellRegistration = DemoDatePickerCellTransformer.makeCellRegistration(
+ viewModel: viewModel,
+ delegate: delegate)
+
+ let durationPickerCellRegistration = DemoDurationPickerCellTransformer.makeCellRegistration(
+ viewModel: viewModel,
+ delegate: delegate)
+
+ let menuButtonCellRegistration = DemoMenuButtonCellTransformer.makeCellRegistration(
+ viewModel: viewModel,
+ delegate: delegate)
+
+ let textFieldRegistration = DemoTextFieldCellTransformer.makeCellRegistration(
+ viewModel: viewModel,
+ delegate: delegate)
+
+ return { collectionView, indexPath, item in
+ switch item {
+ case .datePicker:
+ collectionView.dequeueConfiguredReusableCell(
+ using: datePickerCellRegistration,
+ for: indexPath,
+ item: item)
+ case .datePickerToggle:
+ collectionView.dequeueConfiguredReusableCell(
+ using: titleCellTransformer,
+ for: indexPath,
+ item: item)
+ case .durationPicker:
+ collectionView.dequeueConfiguredReusableCell(
+ using: durationPickerCellRegistration,
+ for: indexPath,
+ item: item)
+ case .durationPickerHourInterval,
+ .durationPickerMaximumDuration,
+ .durationPickerMinimumDuration,
+ .durationPickerMinuteInterval,
+ .durationPickerSecondInterval,
+ .durationPickerValue:
+ collectionView.dequeueConfiguredReusableCell(
+ using: textFieldRegistration,
+ for: indexPath,
+ item: item)
+ case .durationPickerPickerMode:
+ collectionView.dequeueConfiguredReusableCell(
+ using: menuButtonCellRegistration,
+ for: indexPath,
+ item: item)
+ }
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Adapter/DemoCellDelegate.swift b/Demo/DurationPickerDemo/Demo/Adapter/DemoCellDelegate.swift
new file mode 100644
index 0000000..1544cdd
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Adapter/DemoCellDelegate.swift
@@ -0,0 +1,39 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+protocol DemoCellDelegate: AnyObject {
+
+ func datePickerCell(countDownDurationDidChangeForItem item: DemoDataSource.Item,
+ countDownDuration: TimeInterval)
+
+ func textFieldCell(textDidChangeForItem item: DemoDataSource.Item,
+ text: String?)
+
+ func durationPickerCell(durationDidChangeForItem item: DemoDataSource.Item,
+ duration: TimeInterval)
+
+ func menuButtonCell(didSelectMenuItemForItem item: DemoDataSource.Item,
+ menuItem: UIAction.Identifier)
+}
diff --git a/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource+Models.swift b/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource+Models.swift
new file mode 100644
index 0000000..ad021c2
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource+Models.swift
@@ -0,0 +1,44 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+extension DemoDataSource {
+
+ enum Section: Hashable {
+ case datePicker
+ case durationPicker
+ }
+
+ enum Item: Hashable {
+ case datePicker
+ case datePickerToggle
+ case durationPicker
+ case durationPickerHourInterval
+ case durationPickerMaximumDuration
+ case durationPickerMinimumDuration
+ case durationPickerMinuteInterval
+ case durationPickerPickerMode
+ case durationPickerSecondInterval
+ case durationPickerValue
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource.swift b/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource.swift
new file mode 100644
index 0000000..d8d698a
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Data Source/DemoDataSource.swift
@@ -0,0 +1,62 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import UIKit
+
+final class DemoDataSource: UICollectionViewDiffableDataSource {
+
+ func reload(showDatePicker: Bool,
+ animatingDifferences: Bool = false) {
+ var snapshot = NSDiffableDataSourceSnapshot()
+
+ // Date picker section
+ snapshot.appendSections([.datePicker])
+ snapshot.appendItems([.datePickerToggle])
+ if showDatePicker {
+ snapshot.appendItems([.datePicker])
+ }
+
+ // Duration picker section
+ snapshot.appendSections([.durationPicker])
+ snapshot.appendItems([
+ .durationPicker,
+ .durationPickerPickerMode,
+ .durationPickerValue,
+ .durationPickerMinimumDuration,
+ .durationPickerMaximumDuration,
+ .durationPickerHourInterval,
+ .durationPickerMinuteInterval,
+ .durationPickerSecondInterval
+ ])
+
+ apply(
+ snapshot,
+ animatingDifferences: animatingDifferences)
+ }
+
+ func reconfigureItems(_ items: [Item]) {
+ var snapshot = snapshot()
+ snapshot.reconfigureItems(items)
+ apply(snapshot)
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/DemoViewModel.swift b/Demo/DurationPickerDemo/Demo/DemoViewModel.swift
new file mode 100644
index 0000000..81630aa
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/DemoViewModel.swift
@@ -0,0 +1,90 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import DurationPicker
+import Foundation
+
+final class DemoViewModel: ObservableObject {
+
+ @Published var countDownDuration: TimeInterval = 0 {
+ didSet {
+ print("Set countDownDuration to \(countDownDuration) seconds")
+ }
+ }
+
+ @Published var duration: TimeInterval = 0 {
+ didSet {
+ print("Set duration to \(duration) seconds")
+ }
+ }
+
+ @Published var pickerMode: DurationPicker.Mode = .hourMinuteSecond {
+ didSet {
+ print("Set pickerMode to \(pickerMode)")
+ }
+ }
+
+ @Published var hourInterval: Int = 1 {
+ didSet {
+ print("Set hour interval to \(hourInterval)")
+ }
+ }
+
+ @Published var minuteInterval: Int = 1 {
+ didSet {
+ print("Set minute interval to \(minuteInterval)")
+ }
+ }
+
+ @Published var secondInterval: Int = 1 {
+ didSet {
+ print("Set second interval to \(secondInterval)")
+ }
+ }
+
+ @Published var minimumDuration: TimeInterval? {
+ didSet {
+ if let minimumDuration {
+ print("Set minimum duration to \(minimumDuration) seconds")
+ } else {
+ print("Cleared minimum duration")
+ }
+ }
+ }
+
+ @Published var maximumDuration: TimeInterval? {
+ didSet {
+ if let maximumDuration {
+ print("Set maximum duration to \(maximumDuration) seconds")
+ } else {
+ print("Cleared maximum duration")
+ }
+ }
+ }
+
+ @Published var showDatePicker = false {
+ didSet {
+ print("Set showDatePicker to \(showDatePicker)")
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Extensions/DurationPicker.Mode++.swift b/Demo/DurationPickerDemo/Demo/Extensions/DurationPicker.Mode++.swift
new file mode 100644
index 0000000..80a4f59
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Extensions/DurationPicker.Mode++.swift
@@ -0,0 +1,75 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+extension DurationPicker.Mode {
+
+ static var allCases: [DurationPicker.Mode] = [
+ .hour,
+ .hourMinute,
+ .hourMinuteSecond,
+ .minute,
+ .minuteSecond,
+ .second
+ ]
+
+ var rawValue: String {
+ switch self {
+ case .hour:
+ return "hour"
+ case .hourMinute:
+ return "hourMinute"
+ case .hourMinuteSecond:
+ return "hourMinuteSecond"
+ case .minute:
+ return "minute"
+ case .minuteSecond:
+ return "minuteSecond"
+ case .second:
+ return "second"
+ }
+ }
+
+ var asActionIdentifier: UIAction.Identifier {
+ UIAction.Identifier(rawValue: rawValue)
+ }
+
+ init(rawValue: String) {
+ if rawValue == "hour" {
+ self = .hour
+ } else if rawValue == "hourMinute" {
+ self = .hourMinute
+ } else if rawValue == "hourMinuteSecond" {
+ self = .hourMinuteSecond
+ } else if rawValue == "minute" {
+ self = .minute
+ } else if rawValue == "minuteSecond" {
+ self = .minuteSecond
+ } else if rawValue == "second" {
+ self = .second
+ } else {
+ fatalError("Provided string does not correspond to any Mode")
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Transformer/DemoDatePickerCellTransformer.swift b/Demo/DurationPickerDemo/Demo/Transformer/DemoDatePickerCellTransformer.swift
new file mode 100644
index 0000000..87b24f2
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Transformer/DemoDatePickerCellTransformer.swift
@@ -0,0 +1,40 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+enum DemoDatePickerCellTransformer {
+
+ static func makeCellRegistration(viewModel: DemoViewModel,
+ delegate: DemoCellDelegate?) -> DemoCellRegistration {
+ DemoCellRegistration { [weak delegate] cell, indexPath, item in
+ var configuration = DemoDatePickerContentConfiguration()
+ configuration.countDownDuration = viewModel.countDownDuration
+ configuration.countDownDurationUpdateHandler = { countDownDuration in
+ delegate?.datePickerCell(
+ countDownDurationDidChangeForItem: item,
+ countDownDuration: countDownDuration)
+ }
+ cell.contentConfiguration = configuration
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Transformer/DemoDurationPickerCellTransformer.swift b/Demo/DurationPickerDemo/Demo/Transformer/DemoDurationPickerCellTransformer.swift
new file mode 100644
index 0000000..663885d
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Transformer/DemoDurationPickerCellTransformer.swift
@@ -0,0 +1,47 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+enum DemoDurationPickerCellTransformer {
+
+ static func makeCellRegistration(viewModel: DemoViewModel,
+ delegate: DemoCellDelegate?) -> DemoCellRegistration {
+ DemoCellRegistration { [weak delegate] cell, indexPath, item in
+ var configuration = DemoDurationPickerContentConfiguration()
+ configuration.pickerMode = viewModel.pickerMode
+ configuration.duration = viewModel.duration
+ configuration.hourInterval = viewModel.hourInterval
+ configuration.minuteInterval = viewModel.minuteInterval
+ configuration.secondInterval = viewModel.secondInterval
+ configuration.minimumDuration = viewModel.minimumDuration
+ configuration.maximumDuration = viewModel.maximumDuration
+ configuration.durationUpdateHandler = { duration in
+ delegate?.durationPickerCell(
+ durationDidChangeForItem: item,
+ duration: duration)
+ }
+ cell.contentConfiguration = configuration
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Transformer/DemoMenuButtonCellTransformer.swift b/Demo/DurationPickerDemo/Demo/Transformer/DemoMenuButtonCellTransformer.swift
new file mode 100644
index 0000000..347d201
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Transformer/DemoMenuButtonCellTransformer.swift
@@ -0,0 +1,94 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+enum DemoMenuButtonCellTransformer {
+
+ static func makeCellRegistration(viewModel: DemoViewModel,
+ delegate: DemoCellDelegate?) -> DemoCellRegistration {
+ DemoCellRegistration { [weak delegate] cell, indexPath, item in
+ var configuration = UIListContentConfiguration.valueCell()
+ configuration.text = makeText(for: item)
+ cell.contentConfiguration = configuration
+ UIView.performWithoutAnimation {
+ cell.accessories = makeAccessories(
+ for: item,
+ pickerMode: viewModel.pickerMode,
+ delegate: delegate)
+ }
+ }
+ }
+
+ // MARK: - Private
+
+ private static func makeText(for item: DemoDataSource.Item) -> String? {
+ switch item {
+ case .durationPickerPickerMode:
+ return "Picker Mode"
+ case .durationPickerValue:
+ return "Selected Duration"
+ default:
+ return nil
+ }
+ }
+
+ private static func makeAccessories(for item: DemoDataSource.Item,
+ pickerMode: DurationPicker.Mode,
+ delegate: DemoCellDelegate?) -> [UICellAccessory] {
+ switch item {
+ case .durationPickerPickerMode:
+ let button = makePickerModeButton(
+ for: item,
+ pickerMode: pickerMode,
+ delegate: delegate)
+ let configuration = UICellAccessory.CustomViewConfiguration(
+ customView: button,
+ placement: .trailing())
+ return [.customView(configuration: configuration)]
+ default:
+ return []
+ }
+ }
+
+ private static func makePickerModeButton(for item: DemoDataSource.Item,
+ pickerMode: DurationPicker.Mode,
+ delegate: DemoCellDelegate?) -> UIButton {
+ let actions = DurationPicker.Mode.allCases.map {
+ UIAction(
+ title: $0.rawValue,
+ identifier: $0.asActionIdentifier) { [weak delegate] action in
+ delegate?.menuButtonCell(
+ didSelectMenuItemForItem: item,
+ menuItem: action.identifier)
+ }
+ }
+ var buttonConfiguration: UIButton.Configuration = .plain()
+ buttonConfiguration.title = pickerMode.rawValue
+
+ let button = UIButton(configuration: buttonConfiguration)
+ button.showsMenuAsPrimaryAction = true
+ button.menu = UIMenu(children: actions)
+ return button
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Transformer/DemoTextFieldCellTransformer.swift b/Demo/DurationPickerDemo/Demo/Transformer/DemoTextFieldCellTransformer.swift
new file mode 100644
index 0000000..636d133
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Transformer/DemoTextFieldCellTransformer.swift
@@ -0,0 +1,118 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import UIKit
+
+enum DemoTextFieldCellTransformer {
+
+ static func makeCellRegistration(viewModel: DemoViewModel,
+ delegate: DemoCellDelegate?) -> DemoCellRegistration {
+ DemoCellRegistration { [weak delegate] cell, indexPath, item in
+ var configuration = DemoTextFieldContentConfiguration()
+ configuration.title = makeTitle(for: item)
+ configuration.placeholder = makePlaceholder(for: item)
+ configuration.text = makeText(
+ for: item,
+ duration: viewModel.duration,
+ hourInterval: viewModel.hourInterval,
+ minuteInterval: viewModel.minuteInterval,
+ secondInterval: viewModel.secondInterval,
+ minimumDuration: viewModel.minimumDuration,
+ maximumDuration: viewModel.maximumDuration)
+ configuration.textUpdateHandler = { text in
+ delegate?.textFieldCell(
+ textDidChangeForItem: item,
+ text: text)
+ }
+ cell.contentConfiguration = configuration
+ }
+ }
+
+ // MARK: - Private
+
+ private static func makeTitle(for item: DemoDataSource.Item) -> String? {
+ switch item {
+ case .durationPickerHourInterval:
+ return "Hour Interval"
+ case .durationPickerMaximumDuration:
+ return "Maximum Duration"
+ case .durationPickerMinimumDuration:
+ return "Minimum Duration"
+ case .durationPickerMinuteInterval:
+ return "Minute Interval"
+ case .durationPickerSecondInterval:
+ return "Second Interval"
+ case .durationPickerValue:
+ return "Selected Duration"
+ default:
+ return nil
+ }
+ }
+
+ private static func makePlaceholder(for item: DemoDataSource.Item) -> String? {
+ switch item {
+ case .durationPickerHourInterval:
+ return "Hours"
+ case .durationPickerMaximumDuration,
+ .durationPickerMinimumDuration,
+ .durationPickerSecondInterval,
+ .durationPickerValue:
+ return "Seconds"
+ case .durationPickerMinuteInterval:
+ return "Minutes"
+ default:
+ return nil
+ }
+ }
+
+ private static func makeText(for item: DemoDataSource.Item,
+ duration: TimeInterval,
+ hourInterval: Int,
+ minuteInterval: Int,
+ secondInterval: Int,
+ minimumDuration: TimeInterval?,
+ maximumDuration: TimeInterval?) -> String? {
+ switch item {
+ case .durationPickerHourInterval:
+ return String(hourInterval)
+ case .durationPickerMinuteInterval:
+ return String(minuteInterval)
+ case .durationPickerMaximumDuration:
+ if let maximumDuration {
+ return String(maximumDuration)
+ }
+ return nil
+ case .durationPickerMinimumDuration:
+ if let minimumDuration {
+ return String(minimumDuration)
+ }
+ return nil
+ case .durationPickerSecondInterval:
+ return String(secondInterval)
+ case .durationPickerValue:
+ return String(duration)
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/Transformer/DemoTitleCellTransformer.swift b/Demo/DurationPickerDemo/Demo/Transformer/DemoTitleCellTransformer.swift
new file mode 100644
index 0000000..62df036
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/Transformer/DemoTitleCellTransformer.swift
@@ -0,0 +1,52 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import UIKit
+
+enum DemoTitleCellTransformer {
+
+ static func makeCellRegistration(viewModel: DemoViewModel) -> DemoCellRegistration {
+ DemoCellRegistration { cell, indexPath, item in
+ var configuration = UIListContentConfiguration.cell()
+ configuration.text = makeText(
+ for: item,
+ showDatePicker: viewModel.showDatePicker)
+ configuration.textProperties.color = .tintColor
+ cell.contentConfiguration = configuration
+ }
+ }
+
+ // MARK: - Private
+
+ private static func makeText(for item: DemoDataSource.Item,
+ showDatePicker: Bool) -> String? {
+ switch item {
+ case .datePickerToggle:
+ return showDatePicker
+ ? "Hide UIDatePicker"
+ : "Show UIDatePicker"
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentConfiguration.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentConfiguration.swift
new file mode 100644
index 0000000..c88850d
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentConfiguration.swift
@@ -0,0 +1,38 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+struct DemoDatePickerContentConfiguration: UIContentConfiguration {
+
+ var countDownDuration: TimeInterval = 0
+
+ var countDownDurationUpdateHandler: ((TimeInterval) -> Void)?
+
+ func makeContentView() -> UIView & UIContentView {
+ DemoDatePickerContentView(configuration: self)
+ }
+
+ func updated(for state: UIConfigurationState) -> DemoDatePickerContentConfiguration {
+ self
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentView.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentView.swift
new file mode 100644
index 0000000..c3ab807
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDatePickerContentView.swift
@@ -0,0 +1,72 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+final class DemoDatePickerContentView: UIView, UIContentView {
+
+ var configuration: UIContentConfiguration
+
+ private var datePickerConfiguration: DemoDatePickerContentConfiguration! {
+ configuration as? DemoDatePickerContentConfiguration
+ }
+
+ private let datePicker: UIDatePicker = {
+ let picker = UIDatePicker()
+ picker.datePickerMode = .countDownTimer
+ // Unlike DurationPicker, UIDatePicker is not locale-aware on a per-app basis,
+ // so setting the locale explicitly as the user's first preferred language
+ if let languageIdentifier = Locale.preferredLanguages.first {
+ picker.locale = Locale(identifier: languageIdentifier)
+ } else {
+ picker.locale = nil
+ }
+ return picker
+ }()
+
+ init(configuration: DemoDatePickerContentConfiguration) {
+ self.configuration = configuration
+ super.init(frame: .zero)
+
+ addSubview(datePicker)
+ datePicker.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ datePicker.leadingAnchor.constraint(equalTo: leadingAnchor),
+ datePicker.topAnchor.constraint(equalTo: topAnchor),
+ datePicker.trailingAnchor.constraint(equalTo: trailingAnchor),
+ datePicker.bottomAnchor.constraint(equalTo: bottomAnchor)
+ ])
+
+ let action = UIAction { [weak self] _ in
+ guard let self else { return }
+ datePickerConfiguration.countDownDurationUpdateHandler?(datePicker.countDownDuration)
+ }
+
+ datePicker.addAction(
+ action,
+ for: .valueChanged)
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentConfiguration.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentConfiguration.swift
new file mode 100644
index 0000000..d99fe5f
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentConfiguration.swift
@@ -0,0 +1,51 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import DurationPicker
+import UIKit
+
+struct DemoDurationPickerContentConfiguration: UIContentConfiguration {
+
+ var duration: TimeInterval = 0
+
+ var durationUpdateHandler: ((TimeInterval) -> Void)?
+
+ var pickerMode: DurationPicker.Mode = .hourMinuteSecond
+
+ var hourInterval: Int = 1
+
+ var minuteInterval: Int = 1
+
+ var secondInterval: Int = 1
+
+ var minimumDuration: TimeInterval?
+
+ var maximumDuration: TimeInterval?
+
+ func makeContentView() -> UIView & UIContentView {
+ DemoDurationPickerContentView(configuration: self)
+ }
+
+ func updated(for state: UIConfigurationState) -> DemoDurationPickerContentConfiguration {
+ self
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentView.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentView.swift
new file mode 100644
index 0000000..00e8f06
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoDurationPickerContentView.swift
@@ -0,0 +1,101 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import DurationPicker
+import UIKit
+
+final class DemoDurationPickerContentView: UIView, UIContentView {
+
+ var configuration: UIContentConfiguration {
+ didSet {
+ updateConfiguration(durationPickerConfiguration)
+ }
+ }
+
+ private var durationPickerConfiguration: DemoDurationPickerContentConfiguration! {
+ configuration as? DemoDurationPickerContentConfiguration
+ }
+
+ private let durationPicker = DurationPicker()
+
+ init(configuration: DemoDurationPickerContentConfiguration) {
+ self.configuration = configuration
+ super.init(frame: .zero)
+
+ addSubview(durationPicker)
+ durationPicker.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ durationPicker.leadingAnchor.constraint(equalTo: leadingAnchor),
+ durationPicker.topAnchor.constraint(equalTo: topAnchor),
+ durationPicker.trailingAnchor.constraint(equalTo: trailingAnchor),
+ durationPicker.bottomAnchor.constraint(equalTo: bottomAnchor)
+ ])
+
+ let action = UIAction { [weak self] action in
+ guard let self else { return }
+ if durationPicker.duration != durationPickerConfiguration.duration {
+ configuration.durationUpdateHandler?(durationPicker.duration)
+ }
+ }
+
+ durationPicker.addAction(
+ action,
+ for: .valueChanged)
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ private func updateConfiguration(_ configuration: DemoDurationPickerContentConfiguration) {
+ if durationPicker.pickerMode != configuration.pickerMode {
+ durationPicker.pickerMode = configuration.pickerMode
+ }
+
+ if durationPicker.hourInterval != configuration.hourInterval {
+ durationPicker.hourInterval = configuration.hourInterval
+ }
+
+ if durationPicker.minuteInterval != configuration.minuteInterval {
+ durationPicker.minuteInterval = configuration.minuteInterval
+ }
+
+ if durationPicker.secondInterval != configuration.secondInterval {
+ durationPicker.secondInterval = configuration.secondInterval
+ }
+
+ if durationPicker.minimumDuration != configuration.minimumDuration {
+ durationPicker.minimumDuration = configuration.minimumDuration
+ }
+
+ if durationPicker.maximumDuration != configuration.maximumDuration {
+ durationPicker.maximumDuration = configuration.maximumDuration
+ }
+
+ if durationPicker.duration != configuration.duration {
+ durationPicker.setDuration(
+ configuration.duration,
+ animated: true)
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentConfiguration.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentConfiguration.swift
new file mode 100644
index 0000000..6fa7ff4
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentConfiguration.swift
@@ -0,0 +1,43 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import UIKit
+
+struct DemoTextFieldContentConfiguration: UIContentConfiguration {
+
+ var title: String?
+
+ var text: String?
+
+ var textUpdateHandler: ((String?) -> Void)?
+
+ var placeholder: String?
+
+ func makeContentView() -> UIView & UIContentView {
+ DemoTextFieldContentView(configuration: self)
+ }
+
+ func updated(for state: UIConfigurationState) -> DemoTextFieldContentConfiguration {
+ self
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentView.swift b/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentView.swift
new file mode 100644
index 0000000..d652c56
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/Content View/DemoTextFieldContentView.swift
@@ -0,0 +1,139 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import UIKit
+
+final class DemoTextFieldContentView: UIView, UIContentView, UITextFieldDelegate {
+
+ var configuration: UIContentConfiguration {
+ didSet {
+ updateConfiguration(textFieldConfiguration)
+ }
+ }
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.setContentCompressionResistancePriority(.required, for: .horizontal)
+ label.setContentHuggingPriority(.required, for: .horizontal)
+ return label
+ }()
+
+ private let textField: UITextField = {
+ let textField = UITextField()
+ textField.clearButtonMode = .whileEditing
+ textField.keyboardType = .decimalPad
+
+ let doneAction = UIAction(title: "Done") { _ in
+ textField.endEditing(true)
+ }
+ let doneButton = UIBarButtonItem(
+ systemItem: .done,
+ primaryAction: doneAction)
+
+ let toolbar = UIToolbar()
+ // Set explicit size to prevent broken constraints in the console
+ toolbar.frame.size.height = 44
+ toolbar.frame.size.width = 100
+ toolbar.items = [.flexibleSpace(), doneButton]
+ textField.inputAccessoryView = toolbar
+
+ return textField
+ }()
+
+ private var textFieldConfiguration: DemoTextFieldContentConfiguration! {
+ configuration as? DemoTextFieldContentConfiguration
+ }
+
+ private static let horizontalPadding: CGFloat = 20
+
+ private static let verticalPadding: CGFloat = 12
+
+ private static let titleLabelToTextFieldPadding: CGFloat = 12
+
+ init(configuration: DemoTextFieldContentConfiguration) {
+ self.configuration = configuration
+ super.init(frame: .zero)
+
+ textField.delegate = self
+
+ addSubview(titleLabel)
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ titleLabel.leadingAnchor.constraint(
+ equalTo: leadingAnchor,
+ constant: Self.horizontalPadding),
+ titleLabel.topAnchor.constraint(
+ equalTo: topAnchor,
+ constant: Self.verticalPadding),
+ titleLabel.bottomAnchor.constraint(
+ equalTo: bottomAnchor,
+ constant: -Self.verticalPadding),
+ ])
+
+ addSubview(textField)
+ textField.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ textField.leadingAnchor.constraint(
+ equalTo: titleLabel.trailingAnchor,
+ constant: Self.titleLabelToTextFieldPadding),
+ textField.topAnchor.constraint(
+ equalTo: topAnchor,
+ constant: Self.verticalPadding),
+ textField.bottomAnchor.constraint(
+ equalTo: bottomAnchor,
+ constant: -Self.verticalPadding),
+ textField.trailingAnchor.constraint(
+ equalTo: trailingAnchor,
+ constant: -Self.horizontalPadding),
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ private func updateConfiguration(_ textFieldConfiguration: DemoTextFieldContentConfiguration) {
+ if titleLabel.text != textFieldConfiguration.title {
+ titleLabel.text = textFieldConfiguration.title
+ }
+
+ if textField.text != textFieldConfiguration.text {
+ textField.text = textFieldConfiguration.text
+ }
+
+ if textField.placeholder != textFieldConfiguration.placeholder {
+ textField.placeholder = textFieldConfiguration.placeholder
+ }
+ }
+
+ // MARK: - UITextFieldDelegate
+
+ func textFieldDidEndEditing(_ textField: UITextField) {
+ textFieldConfiguration.textUpdateHandler?(textField.text)
+ }
+
+ func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+ textField.endEditing(true)
+ return true
+ }
+}
diff --git a/Demo/DurationPickerDemo/Demo/View/DemoViewController.swift b/Demo/DurationPickerDemo/Demo/View/DemoViewController.swift
new file mode 100644
index 0000000..beb5f8c
--- /dev/null
+++ b/Demo/DurationPickerDemo/Demo/View/DemoViewController.swift
@@ -0,0 +1,329 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Combine
+import DurationPicker
+import UIKit
+
+final class DemoViewController: UICollectionViewController, DemoCellDelegate {
+
+ private let viewModel = DemoViewModel()
+
+ private lazy var dataSource = DemoDataSource(
+ collectionView: collectionView,
+ cellProvider: DemoAdapter.makeCellProvider(
+ viewModel: viewModel,
+ delegate: self))
+
+ private var disposeBag = Set()
+
+ // MARK: - Initializers
+
+ init() {
+ let layoutConfiguration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
+ let layout = UICollectionViewCompositionalLayout.list(using: layoutConfiguration)
+ super.init(collectionViewLayout: layout)
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ // MARK: - Lifecycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ navigationItem.title = "DurationPicker Demo"
+
+ collectionView.dataSource = dataSource
+ collectionView.keyboardDismissMode = .onDrag
+ collectionView.delegate = self
+
+ setupModelSubscriptions()
+
+ dataSource.reload(showDatePicker: viewModel.showDatePicker)
+ }
+
+ // MARK: - Model Subscriptions
+
+ private func setupModelSubscriptions() {
+ setupDurationSubscription()
+ setupMinimumDurationSubscription()
+ setupMaximumDurationSubscription()
+ setupPickerModeSubscription()
+ setupHourIntervalSubscription()
+ setupMinuteIntervalSubscription()
+ setupSecondIntervalSubscription()
+ setupShowDatePickerSubscription()
+ setupCountDownDurationSubscription()
+ }
+
+ private func setupDurationSubscription() {
+ viewModel.$duration
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] duration in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupMinimumDurationSubscription() {
+ viewModel.$minimumDuration
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] duration in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerMinimumDuration,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupMaximumDurationSubscription() {
+ viewModel.$maximumDuration
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] duration in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerMaximumDuration,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupPickerModeSubscription() {
+ viewModel.$pickerMode
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] pickerMode in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerMaximumDuration,
+ .durationPickerMinimumDuration,
+ .durationPickerPickerMode,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupHourIntervalSubscription() {
+ viewModel.$hourInterval
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] interval in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerHourInterval,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupMinuteIntervalSubscription() {
+ viewModel.$minuteInterval
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] interval in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerMinuteInterval,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupSecondIntervalSubscription() {
+ viewModel.$secondInterval
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] interval in
+ self?.dataSource.reconfigureItems([
+ .durationPicker,
+ .durationPickerSecondInterval,
+ .durationPickerValue
+ ])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupShowDatePickerSubscription() {
+ viewModel.$showDatePicker
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] shouldShow in
+ guard let self else { return }
+ dataSource.reload(
+ showDatePicker: shouldShow,
+ animatingDifferences: true)
+ dataSource.reconfigureItems([.datePickerToggle])
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func setupCountDownDurationSubscription() {
+ viewModel.$countDownDuration
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] duration in
+ self?.dataSource.reconfigureItems([.datePicker])
+ }
+ .store(in: &disposeBag)
+ }
+
+ // MARK: - UICollectionViewDelegate
+
+ override func collectionView(_ collectionView: UICollectionView,
+ didSelectItemAt indexPath: IndexPath) {
+ collectionView.deselectItem(at: indexPath, animated: true)
+
+ guard let item = dataSource.itemIdentifier(for: indexPath) else {
+ return
+ }
+
+ switch item {
+ case .datePickerToggle:
+ viewModel.showDatePicker.toggle()
+ default:
+ break
+ }
+ }
+
+ override func collectionView(_ collectionView: UICollectionView,
+ shouldSelectItemAt indexPath: IndexPath) -> Bool {
+ guard let item = dataSource.itemIdentifier(for: indexPath) else {
+ return true
+ }
+ return shouldSelectItem(item)
+ }
+
+ override func collectionView(_ collectionView: UICollectionView,
+ shouldHighlightItemAt indexPath: IndexPath) -> Bool {
+ guard let item = dataSource.itemIdentifier(for: indexPath) else {
+ return true
+ }
+ return shouldSelectItem(item)
+ }
+
+ private func shouldSelectItem(_ item: DemoDataSource.Item) -> Bool {
+ switch item {
+ case .datePickerToggle:
+ return true
+ default:
+ return false
+ }
+ }
+
+ // MARK: - DemoCellDelegate
+
+ func textFieldCell(textDidChangeForItem item: DemoDataSource.Item,
+ text: String?) {
+ switch item {
+ case .durationPickerHourInterval:
+ if let text,
+ let interval = Int(text) {
+ viewModel.hourInterval = interval
+ } else {
+ viewModel.hourInterval = 1
+ }
+ case .durationPickerMaximumDuration:
+ if let text,
+ let duration = Double(text) {
+ viewModel.maximumDuration = duration
+ } else {
+ viewModel.maximumDuration = nil
+ }
+ case .durationPickerMinimumDuration:
+ if let text,
+ let duration = Double(text) {
+ viewModel.minimumDuration = duration
+ } else {
+ viewModel.minimumDuration = nil
+ }
+ case .durationPickerMinuteInterval:
+ if let text,
+ let interval = Int(text) {
+ viewModel.minuteInterval = interval
+ } else {
+ viewModel.minuteInterval = 1
+ }
+ case .durationPickerSecondInterval:
+ if let text,
+ let interval = Int(text) {
+ viewModel.secondInterval = interval
+ } else {
+ viewModel.secondInterval = 1
+ }
+ case .durationPickerValue:
+ if let text,
+ let duration = Double(text) {
+ viewModel.duration = duration
+ } else {
+ viewModel.duration = 0
+ }
+ default:
+ break
+ }
+ }
+
+ func durationPickerCell(durationDidChangeForItem item: DemoDataSource.Item,
+ duration: TimeInterval) {
+ switch item {
+ case .durationPicker:
+ viewModel.duration = duration
+ default:
+ break
+ }
+ }
+
+
+ func datePickerCell(countDownDurationDidChangeForItem item: DemoDataSource.Item,
+ countDownDuration: TimeInterval) {
+ switch item {
+ case .datePicker:
+ viewModel.countDownDuration = countDownDuration
+ default:
+ break
+ }
+ }
+
+ func menuButtonCell(didSelectMenuItemForItem item: DemoDataSource.Item,
+ menuItem: UIAction.Identifier) {
+ switch item {
+ case .durationPickerPickerMode:
+ let selectedMode = DurationPicker.Mode(rawValue: menuItem.rawValue)
+ viewModel.pickerMode = selectedMode
+ default:
+ break
+ }
+ }
+}
diff --git a/Demo/DurationPickerDemo/Info.plist b/Demo/DurationPickerDemo/Info.plist
new file mode 100644
index 0000000..028418a
--- /dev/null
+++ b/Demo/DurationPickerDemo/Info.plist
@@ -0,0 +1,67 @@
+
+
+
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+
+
+
+
+ CFBundleLocalizations
+
+ ar
+ ca
+ zh-HK
+ zh_Hans
+ zh_Hant
+ hr
+ cs
+ da
+ nl
+ en
+ en-AU
+ en-IN
+ en-UK
+ fi
+ fr
+ fr-CA
+ de
+ el
+ hi
+ hu
+ id
+ it
+ ja
+ ko
+ ms
+ nb
+ pl
+ pt-BR
+ pt-PT
+ ro
+ ru
+ sk
+ es
+ es-419
+ sv
+ th
+ tr
+ uk
+ vi
+
+ CFBundleAllowMixedLocalizations
+
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d1be865
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Mac Gallagher
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..8305f89
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,24 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+ name: "DurationPicker",
+ defaultLocalization: "en",
+ platforms: [
+ .iOS(.v15)
+ ],
+ products: [
+ .library(
+ name: "DurationPicker",
+ targets: ["DurationPicker"]),
+ ],
+ dependencies: [],
+ targets: [
+ .target(
+ name: "DurationPicker",
+ dependencies: []),
+ .testTarget(
+ name: "DurationPickerTests",
+ dependencies: ["DurationPicker"]),
+ ])
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..326a011
--- /dev/null
+++ b/README.md
@@ -0,0 +1,109 @@
+# DurationPicker
+[![CI](https://github.com/mac-gallagher/DurationPicker/actions/workflows/ci.yml/badge.svg)](https://github.com/mac-gallagher/DurationPicker/actions/workflows/ci.yml)
+[![Documentation](https://github.com/mac-gallagher/DurationPicker/actions/workflows/documentation.yml/badge.svg)](https://github.com/mac-gallagher/DurationPicker/actions/workflows/documentation.yml)
+
+DurationPicker is an iOS library that provides a customizable control for inputting time values ranging between 0 and 24 hours. It serves as a drop-in replacement of [UIDatePicker](https://developer.apple.com/documentation/uikit/uidatepicker) with [countDownTimer](https://developer.apple.com/documentation/uikit/uidatepicker/mode/countdowntimer) mode with additional functionality for time input.
+
+
+
+
+
+## Features
+
+- [x] Styled to match `UIDatePicker` with `countDownTimer` mode
+- [x] Multiple modes for selection of hours, minutes, and/or seconds
+- [x] Option to specify intervals for hour, minute, and/or seconds
+- [x] Support for minimum and maximum durations
+- [x] Localization in [26+ languages](https://mac-gallagher.github.io/DurationPicker/documentation/durationpicker/localization)
+- [x] Built-in support for accessibility and VoiceOver
+
+## Usage
+
+To use DurationPicker, simply create an instance of `DurationPicker` and add it to your view hierarchy. You can customize your picker using the following properties:
+
+- `pickerMode`: The mode of the picker, determines whether the duration picker allows selection of hours, minutes, and/or seconds
+- `{hour|minute|second}Interval`: The intervals at which the duration picker should display
+- `{minimum|maximum}Duration`: The minimum/maximum duration that the picker can show
+
+The code below will produce the following duration picker.
+
+
+
+
+
+```swift
+import DurationPicker
+
+let picker = DurationPicker()
+addSubview(picker)
+
+picker.pickerMode = .minuteSecond
+picker.minuteInterval = 5
+picker.secondInterval = 30
+picker.minimumDuration = (15 * 60) // 15 minutes
+picker.maximumDuration = (45 * 60) + 30 // 45 minutes, 30 seconds
+```
+
+The selected duration can also be set programmatically through the `duration` property.
+
+```swift
+picker.duration = (30 * 60) + 30 // 30 minutes, 30 seconds
+```
+
+You can react to changes in the duration's picker value using [UIActions](https://developer.apple.com/documentation/uikit/uiaction).
+
+```swift
+let action = UIAction { [weak picker] _ in
+ guard let picker else { return }
+ print(picker.duration) // 1830
+ },
+ for: .primaryAction)
+
+picker.addAction(action)
+```
+
+## Demo
+
+
+
+To see `DurationPicker` in action, clone the repository and open the `DurationPickerDemo` project.
+
+In addition to demonstrating the features of `DurationPicker`, the demo app serves as an example of how a `DurationPicker` can be used in a [modern collection view](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views).
+
+## Documentation
+Full documentation available on [Github Pages](https://mac-gallagher.github.io/DurationPicker/documentation/durationpicker).
+
+## Installation
+
+### Swift Package Manager
+DurationPicker is available through [Swift PM](). To install it, simply add the package as a dependency in `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/mac-gallagher/DurationPicker.git", from: "1.0.0"),
+]
+```
+
+### Manual
+Download and drop the `Sources` directory into your project.
+
+## Requirements
+- iOS 15.0+
+- XCode 15.0+
+- Swift 5.9+
+
+## License
+
+DurationPicker is available under the MIT license. See [LICENSE](LICENSE) for more information.
+
+## Contributing
+Contributions are welcome! Fork the repo, make your changes, and submit a pull request.
diff --git a/Sources/DurationPicker/Internal/DurationPickerContentView.swift b/Sources/DurationPicker/Internal/DurationPickerContentView.swift
new file mode 100644
index 0000000..067e190
--- /dev/null
+++ b/Sources/DurationPicker/Internal/DurationPickerContentView.swift
@@ -0,0 +1,89 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+/// A view which displays a single monospaced, right-aligned label. Used as a row for `DurationPickerView`.
+final class DurationPickerContentView: UIView {
+
+ private let label: UILabel = {
+ let label = UILabel()
+ label.textAlignment = .right
+ label.font = labelFont
+ return label
+ }()
+
+ /// The font of the label.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let labelFont = UIFont.monospacedDigitSystemFont(
+ ofSize: 23.5,
+ weight: .regular)
+
+ /// The baseline offset of the label.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let labelBaselineOffset = CGFloat(1.5).roundedToNearestPixel()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ addSubview(label)
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ addSubview(label)
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ label.frame.size = Self.labelBoundingRect().size
+ // Vertically center the label but shift up if its origin lies between two pixels
+ label.center.y = center.y
+ label.frame.origin.y.roundToNearestPixel()
+ }
+
+ /// Sets the text on the content view's label.
+ ///
+ /// - Parameters:
+ /// - text: The text to set on the label.
+ /// - muted: A boolean indicating if a muted color should be applied to the text.
+ /// - accessibilityLabel: A succinct label in a localized string that identifies the label.
+ func setText(_ text: String?,
+ muted: Bool,
+ accessibilityLabel: String?) {
+ label.text = text
+ label.textColor = muted ? .tertiaryLabel : .label
+ label.accessibilityLabel = accessibilityLabel
+ }
+
+ /// Returns the bounding rectangle for the label in the content view, rounded up to the nearest pixel.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ ///
+ /// - Returns: The bounding rectangle for the label in the content view, rounded up to the nearest pixel.
+ static func labelBoundingRect() -> CGRect {
+ "00".boundingRectRoundedToNearestPixel(
+ font: Self.labelFont,
+ baselineOffset: Self.labelBaselineOffset)
+ }
+}
diff --git a/Sources/DurationPicker/Internal/DurationPickerView.swift b/Sources/DurationPicker/Internal/DurationPickerView.swift
new file mode 100644
index 0000000..5e16f8b
--- /dev/null
+++ b/Sources/DurationPicker/Internal/DurationPickerView.swift
@@ -0,0 +1,739 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+/// A subclass of `UIPickerView` which to obfuscates the `UIPickerViewDataSource` and `UIPickerViewDelegate` conformance from the public `DurationPicker` interface.
+final class DurationPickerView: UIPickerView, UIPickerViewDataSource, UIPickerViewDelegate, UIPickerViewAccessibilityDelegate {
+
+ // MARK: - Configuration Properties
+
+ var pickerMode: DurationPicker.Mode {
+ get { internalPickerMode }
+ set { setPickerMode(newValue) }
+ }
+
+ private var internalPickerMode: DurationPicker.Mode = .hourMinuteSecond
+
+ var duration: Int {
+ get { timeComponents.duration }
+ set { setDuration(newValue, animated: false) }
+ }
+
+ private var timeComponents = TimeComponents()
+
+ var durationUpdateBlock: ((Int) -> Void)?
+
+ var minimumDuration: Int? {
+ set {
+ setDurationRange(
+ minimumDuration: newValue,
+ maximumDuration: maximumDuration)
+ }
+ get { internalMinimumDuration }
+ }
+
+ private var internalMinimumDuration: Int?
+
+ /// The minimum components displayed on the duration picker.
+ ///
+ /// > Important This may not align with `internalMinimumDuration`, for instance when the minimum duration is larger than the maximum duration
+ private lazy var minimumDurationComponents = makeAbsoluteMinimumDurationComponents()
+
+ var maximumDuration: Int? {
+ set {
+ setDurationRange(
+ minimumDuration: minimumDuration,
+ maximumDuration: newValue)
+ }
+ get { internalMaximumDuration }
+ }
+
+ private var internalMaximumDuration: Int?
+
+ /// The maimum components displayed on the duration picker.
+ ///
+ /// > Important This may not align with `internalMaximumDuration`, for instance when the minimum duration is larger than the maximum duration
+ private lazy var maximumDurationComponents = makeAbsoluteMaximumDurationComponents()
+
+ var secondInterval: Int {
+ get { internalSecondInterval }
+ set { setInterval(newValue, forComponentType: .second) }
+ }
+
+ private var internalSecondInterval: Int = 1
+
+ var minuteInterval: Int {
+ get { internalMinuteInterval }
+ set { setInterval(newValue, forComponentType: .minute) }
+ }
+
+ private var internalMinuteInterval: Int = 1
+
+ var hourInterval: Int {
+ get { internalHourInterval }
+ set { setInterval(newValue, forComponentType: .hour) }
+ }
+
+ private var internalHourInterval: Int = 1
+
+ // MARK: - Layout Properties
+
+ private let hourUnitLabel = makeUnitLabel()
+
+ private let minuteUnitLabel = makeUnitLabel()
+
+ private let secondUnitLabel = makeUnitLabel()
+
+ private let formatter = DurationFormatter()
+
+ // MARK: - Initializers & View Lifecycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ commonInit()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ commonInit()
+ }
+
+ private func commonInit() {
+ delegate = self
+ dataSource = self
+
+ addSubview(hourUnitLabel)
+ addSubview(minuteUnitLabel)
+ addSubview(secondUnitLabel)
+
+ setUnitLabelsText()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ positionUnitLabels()
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ // In the orientation is changed while the picker wheel is still animating,
+ // reset it to the original duration
+ if previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
+ || previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass {
+ refreshDuration()
+ }
+ }
+
+ // MARK: - Unit Labels
+
+ /// The font of each unit label.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let unitLabelFont = UIFont.systemFont(
+ ofSize: 17,
+ weight: .semibold)
+
+ /// The spacing between a row's content view label and its corresponding unit label.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let contentViewLabelToUnitLabelSpacing: CGFloat = 6
+
+ /// The spacing between the columns in `DurationPickerView`.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker with `countDownTimer` mode.
+ private static let columnSpacing: CGFloat = 5
+
+ /// The baseline offset of the unit label.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let unitLabelBaselineOffset: CGFloat = 1
+
+ /// The vertical offset to align the baseline of the unit label with the label in `DurationPickerContentView`.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let unitLabelVerticalPositionAdjustment: CGFloat = UIScreen.main.scale == 2 ? 2 : 1
+
+ private static func makeUnitLabel() -> UILabel {
+ let label = UILabel()
+ label.font = unitLabelFont
+ label.isOpaque = false
+ return label
+ }
+
+ private func setUnitLabelsText() {
+ UIView.performWithoutAnimation { [weak self] in
+ guard let self else { return }
+
+ let hourText = formatter.unitString(
+ fromValue: timeComponents.hour,
+ unit: .hour)
+ hourUnitLabel.setAnimatedText(hourText)
+ hourUnitLabel.sizeToFitNearestPixel(baselineOffset: Self.unitLabelBaselineOffset)
+
+ let minuteText = formatter.unitString(
+ fromValue: timeComponents.minute,
+ unit: .minute)
+ minuteUnitLabel.setAnimatedText(minuteText)
+ minuteUnitLabel.sizeToFitNearestPixel(baselineOffset: Self.unitLabelBaselineOffset)
+
+ let secondText = formatter.unitString(
+ fromValue: timeComponents.second,
+ unit: .second)
+ secondUnitLabel.setAnimatedText(secondText)
+ secondUnitLabel.sizeToFitNearestPixel(baselineOffset: Self.unitLabelBaselineOffset)
+ }
+ }
+
+ private func positionUnitLabels() {
+ UIView.performWithoutAnimation { [weak self] in
+ guard let self else { return }
+
+ // Favor shifting labels down if its origin lies between two pixels
+ hourUnitLabel.frame.origin.x = unitLabelOriginX(forComponent: pickerMode.hourComponent)
+ hourUnitLabel.center.y = center.y + Self.unitLabelVerticalPositionAdjustment
+ hourUnitLabel.frame.origin.y.roundToNearestPixel()
+
+ minuteUnitLabel.frame.origin.x = unitLabelOriginX(forComponent: pickerMode.minuteComponent)
+ minuteUnitLabel.center.y = center.y + Self.unitLabelVerticalPositionAdjustment
+ minuteUnitLabel.frame.origin.y.roundToNearestPixel()
+
+ secondUnitLabel.frame.origin.x = unitLabelOriginX(forComponent: pickerMode.secondComponent)
+ secondUnitLabel.center.y = center.y + Self.unitLabelVerticalPositionAdjustment
+ secondUnitLabel.frame.origin.y.roundToNearestPixel()
+ }
+ }
+
+ private func unitLabelOriginX(forComponent component: Int?) -> CGFloat {
+ guard let component else {
+ return 0
+ }
+ let numberOfColumns = CGFloat(pickerMode.numberOfComponents)
+ let rowWidth = rowWidth()
+ let pickerWidth = numberOfColumns * rowWidth
+ + (numberOfColumns - 1) * Self.columnSpacing
+ let firstRowOriginX = (frame.width - pickerWidth) / 2
+
+ let originX = firstRowOriginX
+ + CGFloat(component) * (rowWidth + Self.columnSpacing)
+ + DurationPickerContentView.labelBoundingRect().width
+ + Self.contentViewLabelToUnitLabelSpacing
+
+ // Favor shifting labels to the right if values lie between two pixels
+ return originX.roundedToNearestPixel(roundingRule: .up)
+ }
+
+ // MARK: - Duration
+
+ func setDuration(_ duration: Int,
+ animated: Bool) {
+ timeComponents = TimeComponents.components(
+ fromDuration: duration,
+ pickerMode: pickerMode,
+ minimumDuration: minimumDurationComponents.duration,
+ maximumDuration: maximumDurationComponents.duration,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ // Set unit label text in case the text may have changed, e.g. "hour" to "hours"
+ setUnitLabelsText()
+
+ durationUpdateBlock?(timeComponents.duration)
+
+ // Select hour row
+ if let hourComponent = pickerMode.hourComponent {
+ selectRow(
+ timeComponents.hour / hourInterval,
+ inComponent: hourComponent,
+ animated: animated)
+ }
+
+ // Select minute row
+ if let minuteComponent = pickerMode.minuteComponent {
+ selectRow(
+ timeComponents.minute / minuteInterval,
+ inComponent: minuteComponent,
+ animated: animated)
+ }
+
+ // Select second row
+ if let secondComponent = pickerMode.secondComponent {
+ selectRow(
+ timeComponents.second / secondInterval,
+ inComponent: secondComponent,
+ animated: animated)
+ }
+ }
+
+ private func refreshDuration() {
+ setDuration(
+ duration,
+ animated: false)
+ }
+
+ private func setDurationRange(minimumDuration: Int?,
+ maximumDuration: Int?) {
+ internalMinimumDuration = minimumDuration
+ internalMaximumDuration = maximumDuration
+
+ switch (minimumDuration, maximumDuration) {
+ case (.some(let minimum), .some(let maximum)):
+ let absoluteMaximum = TimeUtils.absoluteMaximumDuration(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+ let isValidRange = maximum >= 0
+ && minimum <= absoluteMaximum
+ && minimum <= maximum
+ if isValidRange {
+ minimumDurationComponents = makeMinimumDurationComponents(fromMinimum: minimum)
+ maximumDurationComponents = makeMaximumDurationComponents(fromMaximum: maximum)
+ } else {
+ minimumDurationComponents = makeAbsoluteMinimumDurationComponents()
+ maximumDurationComponents = makeAbsoluteMaximumDurationComponents()
+ }
+ case (.some(let minimum), .none):
+ minimumDurationComponents = makeMinimumDurationComponents(fromMinimum: minimum)
+ maximumDurationComponents = makeAbsoluteMaximumDurationComponents()
+ case (.none, .some(let maximum)):
+ minimumDurationComponents = makeAbsoluteMinimumDurationComponents()
+ maximumDurationComponents = makeMaximumDurationComponents(fromMaximum: maximum)
+ case (.none, .none):
+ minimumDurationComponents = makeAbsoluteMinimumDurationComponents()
+ maximumDurationComponents = makeAbsoluteMaximumDurationComponents()
+ }
+
+ // Reload the components since the muted rows may have changed
+ reloadAllComponents()
+
+ // Reset the current duration in case it doesn't fit within the new range
+ refreshDuration()
+ }
+
+ private func refreshDurationRange() {
+ setDurationRange(
+ minimumDuration: internalMinimumDuration,
+ maximumDuration: internalMaximumDuration)
+ }
+
+ private func makeAbsoluteMinimumDurationComponents() -> TimeComponents {
+ TimeComponents()
+ }
+
+ private func makeMinimumDurationComponents(fromMinimum minimum: Int) -> TimeComponents {
+ let absoluteMaximum = TimeUtils.absoluteMaximumDuration(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ guard minimum <= absoluteMaximum else {
+ return makeAbsoluteMinimumDurationComponents()
+ }
+
+ return .components(
+ fromDuration: minimum,
+ pickerMode: pickerMode,
+ roundingMode: .up,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+ }
+
+ private func makeAbsoluteMaximumDurationComponents() -> TimeComponents {
+ TimeComponents(
+ uncheckedHour: TimeUtils.maximumNumberOfHours(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval),
+ uncheckedMinute: TimeUtils.maximumNumberOfMinutes(
+ forPickerMode: pickerMode,
+ minuteInterval: minuteInterval),
+ uncheckedSecond: TimeUtils.maximumNumberOfSeconds(
+ forPickerMode: pickerMode,
+ secondInterval: secondInterval))
+ }
+
+ private func makeMaximumDurationComponents(fromMaximum maximum: Int) -> TimeComponents {
+ guard maximum >= 0 else {
+ return makeAbsoluteMaximumDurationComponents()
+ }
+ return .components(
+ fromDuration: maximum,
+ pickerMode: pickerMode,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+ }
+
+ // MARK: - Picker Mode
+
+ func setPickerMode(_ pickerMode: DurationPicker.Mode) {
+ internalPickerMode = pickerMode
+
+ // Set unit label visibility
+ switch pickerMode {
+ case .hour:
+ hourUnitLabel.isHidden = false
+ minuteUnitLabel.isHidden = true
+ secondUnitLabel.isHidden = true
+ case .hourMinute:
+ hourUnitLabel.isHidden = false
+ minuteUnitLabel.isHidden = false
+ secondUnitLabel.isHidden = true
+ case .hourMinuteSecond:
+ hourUnitLabel.isHidden = false
+ minuteUnitLabel.isHidden = false
+ secondUnitLabel.isHidden = false
+ case .minute:
+ hourUnitLabel.isHidden = true
+ minuteUnitLabel.isHidden = false
+ secondUnitLabel.isHidden = true
+ case .minuteSecond:
+ hourUnitLabel.isHidden = true
+ minuteUnitLabel.isHidden = false
+ secondUnitLabel.isHidden = false
+ case .second:
+ hourUnitLabel.isHidden = true
+ minuteUnitLabel.isHidden = true
+ secondUnitLabel.isHidden = false
+ }
+
+ reloadAllComponents()
+
+ // UIPickerView does not request the delegate for new row widths when the number of components has not changed.
+ // However, we observed that the adding an additional call to layoutIfNeeded() will always fetch the updated widths
+ // Calling this will also update the unit labels' positions
+ setNeedsLayout()
+
+ // Reset the duration range so that the minimum and maximum components use the new picker mode
+ // Note: This will also call setDuration(:animated:) using the new picker mode, so we don't explictly call it here
+ refreshDurationRange()
+ }
+
+ // MARK: - Intervals
+
+ private func setInterval(_ interval: Int,
+ forComponentType componentType: DurationPickerComponentType) {
+ switch componentType {
+ case .hour:
+ if (1...NumberOfHours / 2).contains(interval)
+ && NumberOfHours.isMultiple(of: interval) {
+ internalHourInterval = interval
+ } else {
+ internalHourInterval = 1
+ }
+ case .minute:
+ if (1...NumberOfMinutes / 2).contains(interval)
+ && NumberOfMinutes.isMultiple(of: interval) {
+ internalMinuteInterval = interval
+ } else {
+ internalMinuteInterval = 1
+ }
+ case .second:
+ if (1...NumberOfSeconds / 2).contains(interval)
+ && NumberOfSeconds.isMultiple(of: interval) {
+ internalSecondInterval = interval
+ } else {
+ internalSecondInterval = 1
+ }
+ }
+
+ // Reset the duration range so that the minimum and maximum components use the new interval
+ // Note: This will also call setDuration(:animated:) using the new interval, so we don't explictly call it here
+ refreshDurationRange()
+ }
+
+ // MARK: - UIPickerViewDelegate
+
+ /// The height of each row in the picker.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ private static let rowHeight: CGFloat = 32
+
+ func pickerView(_ pickerView: UIPickerView,
+ viewForRow row: Int,
+ forComponent component: Int,
+ reusing view: UIView?) -> UIView {
+ let contentView = view as? DurationPickerContentView ?? DurationPickerContentView()
+
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return contentView
+ }
+
+ let rowValue = value(
+ forRow: row,
+ inComponent: component)
+
+ let shouldMuteTitle = shouldMuteTitle(
+ forRow: row,
+ inComponent: component)
+
+ let accessiblityLabel: String? = {
+ switch componentType {
+ case .hour:
+ formatter.string(
+ fromValue: rowValue,
+ unit: .hour)
+ case .minute:
+ formatter.string(
+ fromValue: rowValue,
+ unit: .minute)
+ case .second:
+ formatter.string(
+ fromValue: rowValue,
+ unit: .second)
+ }
+ }()
+
+ let rowText = formatter.string(fromValue: rowValue)
+ contentView.setText(
+ rowText,
+ muted: shouldMuteTitle,
+ accessibilityLabel: accessiblityLabel)
+
+ return contentView
+ }
+
+ private func shouldMuteTitle(forRow row: Int,
+ inComponent component: Int) -> Bool {
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return false
+ }
+
+ let rowValue = value(
+ forRow: row,
+ inComponent: component)
+
+ switch componentType {
+ case .hour:
+ let rowAtLeastMinimum = rowValue >= minimumDurationComponents.hour
+ let rowAtMostMaximum = rowValue <= maximumDurationComponents.hour
+ return !rowAtLeastMinimum || !rowAtMostMaximum
+ case .minute:
+ let selectedHour = selectedRow(forComponentType: .hour)
+ let rowAtLeastMinimum = selectedHour > minimumDurationComponents.hour
+ || rowValue >= minimumDurationComponents.minute
+ let rowAtMostMaximum = selectedHour < maximumDurationComponents.hour
+ || rowValue <= maximumDurationComponents.minute
+ return !rowAtLeastMinimum || !rowAtMostMaximum
+ case .second:
+ let selectedHour = selectedRow(forComponentType: .hour)
+ let selectedMinute = selectedRow(forComponentType: .minute)
+ let rowAtLeastMinimum = selectedHour > minimumDurationComponents.hour
+ || selectedMinute > minimumDurationComponents.minute
+ || rowValue >= minimumDurationComponents.second
+ let rowAtMostMaximum = selectedHour < maximumDurationComponents.hour
+ || selectedMinute < maximumDurationComponents.minute
+ || rowValue <= maximumDurationComponents.second
+ return !rowAtLeastMinimum || !rowAtMostMaximum
+ }
+ }
+
+ private func value(forRow row: Int,
+ inComponent component: Int) -> Int {
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return 0
+ }
+ switch componentType {
+ case .hour:
+ return row * hourInterval
+ case .minute:
+ return row * minuteInterval
+ case .second:
+ return row * secondInterval
+ }
+ }
+
+ private func selectedRow(forComponentType componentType: DurationPickerComponentType) -> Int {
+ switch componentType {
+ case .hour:
+ if let hourComponent = pickerMode.hourComponent {
+ return value(
+ forRow: selectedRow(inComponent: hourComponent),
+ inComponent: hourComponent)
+ }
+ case .minute:
+ if let minuteComponent = pickerMode.minuteComponent {
+ return value(
+ forRow: selectedRow(inComponent: minuteComponent),
+ inComponent: minuteComponent)
+ }
+ case .second:
+ if let secondComponent = pickerMode.secondComponent {
+ return value(
+ forRow: selectedRow(inComponent: secondComponent),
+ inComponent: secondComponent)
+ }
+ }
+ return 0
+ }
+
+ func pickerView(_ pickerView: UIPickerView,
+ didSelectRow row: Int,
+ inComponent component: Int) {
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return
+ }
+ switch componentType {
+ case .hour:
+ // If hour is selected, reload minute and second components since the mute states may have changed
+ if let minuteComponent = pickerMode.minuteComponent {
+ reloadComponent(minuteComponent)
+ }
+ if let secondComponent = pickerMode.secondComponent {
+ reloadComponent(secondComponent)
+ }
+
+ // Set rows
+ let hours = value(
+ forRow: row,
+ inComponent: component)
+ let newDuration = TimeUtils.seconds(
+ fromHours: hours,
+ minutes: selectedRow(forComponentType: .minute),
+ seconds: selectedRow(forComponentType: .second))
+ setDuration(
+ newDuration,
+ animated: true)
+ case .minute:
+ // If minute is selected, reload second component since the mute state may have changed
+ if let secondComponent = pickerMode.secondComponent {
+ reloadComponent(secondComponent)
+ }
+
+ // Set rows
+ let minutes = value(
+ forRow: row,
+ inComponent: component)
+ let newDuration = TimeUtils.seconds(
+ fromHours: selectedRow(forComponentType: .hour),
+ minutes: minutes,
+ seconds: selectedRow(forComponentType: .second))
+ setDuration(
+ newDuration,
+ animated: true)
+ case .second:
+ // Set rows
+ let seconds = value(
+ forRow: row,
+ inComponent: component)
+ let newDuration = TimeUtils.seconds(
+ fromHours: selectedRow(forComponentType: .hour),
+ minutes: selectedRow(forComponentType: .minute),
+ seconds: seconds)
+ setDuration(
+ newDuration,
+ animated: true)
+ }
+ }
+
+ func pickerView(_ pickerView: UIPickerView,
+ rowHeightForComponent component: Int) -> CGFloat {
+ Self.rowHeight
+ }
+
+ func pickerView(_ pickerView: UIPickerView,
+ widthForComponent component: Int) -> CGFloat {
+ rowWidth()
+ }
+
+ /// The width of each row in the picker.
+ ///
+ /// Observed from the view hierarchy of `UIDatePicker` with `countDownTimer` mode.
+ ///
+ /// > Important: The row width `UIDatePicker` with `countDownTimer` mode was observed to be smaller for iOS 17 (80) when compared to iOS 16 (106). Because we have up to three components, we elected to use the smaller width for both iOS versions.
+ private func rowWidth() -> CGFloat {
+ // We observed that UIDatePicker always has an integer row width, hence we round down to the nearest integer.
+ // As a result, any pixel rounding will overflow past the bounds of the row
+ let width = DurationPickerContentView.labelBoundingRect().width
+ + Self.contentViewLabelToUnitLabelSpacing
+ + maximumUnitLabelBoundingWidth()
+ return width.rounded(.down)
+ }
+
+ private func maximumUnitLabelBoundingWidth() -> CGFloat {
+ let unitStrings: [String]
+ switch pickerMode {
+ case .hour:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .hour)
+ case .hourMinute:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .hour)
+ + formatter.possibleUnitStrings(forUnit: .minute)
+ case .hourMinuteSecond:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .hour)
+ + formatter.possibleUnitStrings(forUnit: .minute)
+ + formatter.possibleUnitStrings(forUnit: .second)
+ case .minute:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .minute)
+ case .minuteSecond:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .minute)
+ + formatter.possibleUnitStrings(forUnit: .second)
+ case .second:
+ unitStrings = formatter.possibleUnitStrings(forUnit: .second)
+ }
+
+ return unitStrings.reduce(0) { partialResult, text in
+ let boundingRect = text.boundingRectRoundedToNearestPixel(font: Self.unitLabelFont)
+ return max(partialResult, boundingRect.width)
+ }
+ }
+
+ // MARK: - UIPickerViewDataSource
+
+ func numberOfComponents(in pickerView: UIPickerView) -> Int {
+ pickerMode.numberOfComponents
+ }
+
+ func pickerView(_ pickerView: UIPickerView,
+ numberOfRowsInComponent component: Int) -> Int {
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return 0
+ }
+ switch componentType {
+ case .hour:
+ return NumberOfHours / hourInterval
+ case .minute:
+ return NumberOfMinutes / minuteInterval
+ case .second:
+ return NumberOfSeconds / secondInterval
+ }
+ }
+
+ // MARK: - UIPickerViewAccessibilityDelegate
+
+ func pickerView(_ pickerView: UIPickerView,
+ accessibilityUserInputLabelsForComponent component: Int) -> [String] {
+ guard let componentType = pickerMode.componentType(fromComponent: component) else {
+ return []
+ }
+ switch componentType {
+ case .hour:
+ return [String(localizedFromModule: "hour_a11y_label")]
+ case .minute:
+ return [String(localizedFromModule: "minute_a11y_label")]
+ case .second:
+ return [String(localizedFromModule: "second_a11y_label")]
+ }
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Extensions/CGFloat++.swift b/Sources/DurationPicker/Internal/Extensions/CGFloat++.swift
new file mode 100644
index 0000000..32a995a
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Extensions/CGFloat++.swift
@@ -0,0 +1,41 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+extension CGFloat {
+
+ /// Returns the value rounded to the nearest pixel based on the scale of the current screen.
+ ///
+ /// - Parameter roundingRule: The rounding rule to use.
+ func roundedToNearestPixel(roundingRule: FloatingPointRoundingRule = .down) -> CGFloat {
+ let scale = UIScreen.main.scale
+ return (self * scale).rounded(roundingRule) / scale
+ }
+
+ /// Rounds the value to the nearest pixel based on the scale of the current screen.
+ ///
+ /// - Parameter roundingRule: The rounding rule to use.
+ mutating func roundToNearestPixel(roundingRule: FloatingPointRoundingRule = .down) {
+ self = roundedToNearestPixel(roundingRule: roundingRule)
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Extensions/CGSize++.swift b/Sources/DurationPicker/Internal/Extensions/CGSize++.swift
new file mode 100644
index 0000000..d2568f9
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Extensions/CGSize++.swift
@@ -0,0 +1,31 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+extension CGSize {
+
+ /// A size with infinite width and height.
+ static let infinity = CGSize(
+ width: CGFloat.infinity,
+ height: CGFloat.infinity)
+}
diff --git a/Sources/DurationPicker/Internal/Extensions/Numbers++.swift b/Sources/DurationPicker/Internal/Extensions/Numbers++.swift
new file mode 100644
index 0000000..f6eae0e
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Extensions/Numbers++.swift
@@ -0,0 +1,88 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+extension TimeInterval? {
+
+ var asInteger: Int? {
+ switch self {
+ case .some(let value): Int(value)
+ case .none: nil
+ }
+ }
+}
+
+extension Int? {
+
+ var asTimeInterval: TimeInterval? {
+ switch self {
+ case .some(let value): TimeInterval(value)
+ case .none: nil
+ }
+ }
+}
+
+extension Comparable {
+
+ /// Clamps the value within the provided range, returning the value if it falls within the range or the nearest bound if it exceeds the range.
+ ///
+ /// - Parameter bounds: A closed range within which the value should be clamped.
+ ///
+ /// - Returns: The clamped value within the specified closed range.
+ func clamped(to bounds: ClosedRange) -> Self {
+ min(max(self, bounds.lowerBound), bounds.upperBound)
+ }
+}
+
+extension Double {
+
+ /// Rounds the receiver to the nearest multiple of the specified value using the provided rounding rule.
+ ///
+ /// - Parameters:
+ /// - value: The value to which the receiver should be rounded.
+ /// - roundingRule: The rounding rule to use; the default is `toNearestOrAwayFromZero`.
+ ///
+ /// - Returns: The rounded value.
+ func rounded(toNearest value: Self,
+ roundingRule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self {
+ (self / value).rounded(roundingRule) * value
+ }
+}
+
+extension Int {
+
+ /// Rounds the receiver to the nearest multiple of the specified value using the provided rounding rule.
+ ///
+ /// - Parameters:
+ /// - value: The value to which the receiver should be rounded.
+ /// - roundingRule: The rounding rule to use; the default is `toNearestOrAwayFromZero`.
+ ///
+ /// - Returns: The rounded value.
+ func rounded(toNearest value: Self,
+ roundingRule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self {
+ let roundedValue = Double(self).rounded(
+ toNearest: Double(value),
+ roundingRule: roundingRule)
+ return Int(roundedValue)
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Extensions/String++.swift b/Sources/DurationPicker/Internal/Extensions/String++.swift
new file mode 100644
index 0000000..e0b2912
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Extensions/String++.swift
@@ -0,0 +1,73 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+import UIKit
+
+extension String {
+
+ /// Creates a localized string instance from a specified localization key or string, using the default localization bundle associated with the module.
+ ///
+ /// - Parameter localized: A value representing the localization key or string to be localized.
+ init(localizedFromModule localized: String.LocalizationValue) {
+ self.init(
+ localized: localized,
+ bundle: .module)
+ }
+
+ /// Calculates and returns the bounding rectangle for a given text string when rendered with a specified font and baseline offset, rounded to the nearest pixel.
+ ///
+ /// - Parameters:
+ /// - font: The font applied to the text.
+ /// - baselineOffset: An optional offset applied to the baseline of the text, defaults to 0.
+ ///
+ /// - Returns: The bounding rectangle that encloses the given text, rounded to the nearest pixel.
+ func boundingRectRoundedToNearestPixel(font: UIFont,
+ baselineOffset: CGFloat = 0) -> CGRect {
+ let boundingRect = boundingRect(
+ font: font,
+ baselineOffset: baselineOffset)
+ return CGRect(
+ origin: boundingRect.origin,
+ size: CGSize(
+ width: boundingRect.width.roundedToNearestPixel(roundingRule: .up),
+ height: boundingRect.height.roundedToNearestPixel(roundingRule: .up)))
+ }
+
+ /// Calculates and returns the bounding rectangle for a given text string when rendered with a specified font and baseline offset.
+ ///
+ /// - Parameters:
+ /// - font: The font applied to the text.
+ /// - baselineOffset: An optional offset applied to the baseline of the text, defaults to 0.
+ ///
+ /// - Returns: The bounding rectangle that encloses the given text.
+ func boundingRect(font: UIFont,
+ baselineOffset: CGFloat = 0) -> CGRect {
+ boundingRect(
+ with: CGSize.infinity,
+ options: [],
+ attributes: [
+ .font: font,
+ .baselineOffset: baselineOffset],
+ context: nil)
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Extensions/UILabel++.swift b/Sources/DurationPicker/Internal/Extensions/UILabel++.swift
new file mode 100644
index 0000000..7596c15
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Extensions/UILabel++.swift
@@ -0,0 +1,50 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+extension UILabel {
+
+ /// Sets the text on the label animating between the old and new values.
+ ///
+ /// - Parameter text: The text to set on the label
+ ///
+ /// - Note: The animation style matches that of the unit labels on `UIDatePicker` with `countDownTimer` mode.
+ func setAnimatedText(_ text: String) {
+ UIView.transition(
+ with: self,
+ duration: 0.3,
+ options: [.transitionCrossDissolve, .curveEaseInOut]) { [weak self] in
+ self?.text = text
+ }
+ }
+
+ /// Adjusts the size of the label to fit its content, considering the nearest pixel accuracy for rendering.
+ ///
+ /// - Parameter baselineOffset: An optional offset applied to the baseline of the text, defaults to 0.
+ func sizeToFitNearestPixel(baselineOffset: CGFloat = 0) {
+ let boundingRect = (text ?? "").boundingRectRoundedToNearestPixel(
+ font: font,
+ baselineOffset: baselineOffset)
+ frame.size = boundingRect.size
+ }
+}
diff --git a/Sources/DurationPicker/Internal/TimeComponents.swift b/Sources/DurationPicker/Internal/TimeComponents.swift
new file mode 100644
index 0000000..1dad17b
--- /dev/null
+++ b/Sources/DurationPicker/Internal/TimeComponents.swift
@@ -0,0 +1,232 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+/// A model representing the duration displayed on the duration picker.
+struct TimeComponents: Equatable {
+
+ /// An enum describing how the components should be rounded.
+ ///
+ /// Only applies when the hour, minute, and/or second intervals are not equal to 1.
+ enum RoundingMode {
+
+ /// Round up to the nearest allowed value.
+ case up
+
+ /// Round down to the nearest allowed value.
+ case down
+ }
+
+ /// An integer representing the hour component of the duration picker. Between 0 and 23.
+ var hour: Int
+
+ /// An integer representing the minute component of the duration picker. Between 0 and 59.
+ var minute: Int
+
+ /// An integer representing the second component of the duration picker. Between 0 and 59.
+ var second: Int
+
+ /// The total duration (in seconds) represented by the time components.
+ var duration: Int {
+ TimeUtils.seconds(
+ fromHours: hour,
+ minutes: minute,
+ seconds: second)
+ }
+
+ /// Creates a ``TimeComponents`` instance with the provided hour, minute, and second components.
+ ///
+ /// - Parameters:
+ /// - uncheckedHour: The hour component. must be within 0 and 23.
+ /// - uncheckedMinute: The minute component, must be within 0 and 59.
+ /// - uncheckedSecond: The second component, must be within 0 and 59.
+ init(uncheckedHour: Int = 0,
+ uncheckedMinute: Int = 0,
+ uncheckedSecond: Int = 0) {
+ precondition(
+ (0.. TimeComponents {
+ let roundedMinimumDuration = TimeUtils.roundedDuration(
+ minimumDuration ?? 0,
+ forPickerMode: pickerMode,
+ roundingRule: .up,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ let absoluteMaximumDuration = TimeUtils.absoluteMaximumDuration(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ let roundedMaximumDuration = TimeUtils.roundedDuration(
+ maximumDuration ?? absoluteMaximumDuration,
+ forPickerMode: pickerMode,
+ roundingRule: .down,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ let isMinMaxOrderValid = roundedMinimumDuration <= roundedMaximumDuration
+
+ let roundedComponents = roundingMode == .down
+ ? roundedDownComponents(
+ fromDuration: duration,
+ minimumDuration: isMinMaxOrderValid ? roundedMinimumDuration : 0,
+ maximumDuration: isMinMaxOrderValid ? roundedMaximumDuration : absoluteMaximumDuration,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+ : roundedUpComponents(
+ fromDuration: duration,
+ minimumDuration: isMinMaxOrderValid ? roundedMinimumDuration : 0,
+ maximumDuration: isMinMaxOrderValid ? roundedMaximumDuration : absoluteMaximumDuration,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ // Zero out specific components based on the picker mode
+ switch pickerMode {
+ case .hour:
+ return TimeComponents(uncheckedHour: roundedComponents.hour)
+ case .hourMinute:
+ return TimeComponents(
+ uncheckedHour: roundedComponents.hour,
+ uncheckedMinute: roundedComponents.minute)
+ case .hourMinuteSecond:
+ return roundedComponents
+ case .minute:
+ return TimeComponents(uncheckedMinute: roundedComponents.minute)
+ case .minuteSecond:
+ return TimeComponents(
+ uncheckedMinute: roundedComponents.minute,
+ uncheckedSecond: roundedComponents.second)
+ case .second:
+ return TimeComponents(uncheckedSecond: roundedComponents.second)
+ }
+ }
+
+ private static func roundedDownComponents(fromDuration duration: Int,
+ minimumDuration: Int,
+ maximumDuration: Int,
+ hourInterval: Int,
+ minuteInterval: Int,
+ secondInterval: Int) -> TimeComponents {
+ let clampedDuration = duration.clamped(to: minimumDuration...maximumDuration)
+
+ let hour = clampedDuration.quotientAndRemainder(dividingBy: hourInterval * OneHour)
+ let hourRemainder = min(hour.remainder, OneHour - minuteInterval)
+
+ let minute = hourRemainder.quotientAndRemainder(dividingBy: minuteInterval * NumberOfSeconds)
+ let minuteRemainder = min(minute.remainder, NumberOfSeconds - secondInterval)
+
+ let second = minuteRemainder.quotientAndRemainder(dividingBy: secondInterval)
+
+ return TimeComponents(
+ uncheckedHour: hour.quotient * hourInterval,
+ uncheckedMinute: minute.quotient * minuteInterval,
+ uncheckedSecond: second.quotient * secondInterval)
+ }
+
+ private static func roundedUpComponents(fromDuration duration: Int,
+ minimumDuration: Int,
+ maximumDuration: Int,
+ hourInterval: Int,
+ minuteInterval: Int,
+ secondInterval: Int) -> TimeComponents {
+ let clampedDuration = duration.clamped(to: minimumDuration...maximumDuration)
+
+ // There are some special cases where we actually round _down_
+ // For example, if clampedDuration is equal to the absolute maximum 23:59:59 and secondInterval == 2,
+ // we cannot round up to 24:00:00
+ if clampedDuration == maximumDuration {
+ return roundedDownComponents(
+ fromDuration: clampedDuration,
+ minimumDuration: minimumDuration,
+ maximumDuration: maximumDuration,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+ }
+
+ let hour = clampedDuration.quotientAndRemainder(dividingBy: hourInterval * OneHour)
+ // If we cannot fit the hour remainder into the minute and second components, make space in the hour component
+ let additionalHourInterval = hour.remainder <= OneHour - minuteInterval * NumberOfSeconds ? 0 : hourInterval
+ let hourRemainder = max(hour.remainder - additionalHourInterval * OneHour, 0)
+ let numberOfHours = min(
+ NumberOfHours - hourInterval,
+ hour.quotient * hourInterval + additionalHourInterval)
+
+ let minute = hourRemainder.quotientAndRemainder(dividingBy: minuteInterval * NumberOfSeconds)
+ // If we cannot fit the minute remainder into the second component, make space in the minute component
+ let additionalMinuteInterval = minute.remainder <= NumberOfSeconds - secondInterval ? 0 : minuteInterval
+ let minuteRemainder = max(minute.remainder - additionalMinuteInterval * NumberOfSeconds, 0)
+ let numberOfMinutes = min(
+ NumberOfMinutes - minuteInterval,
+ minute.quotient * minuteInterval + additionalMinuteInterval)
+
+ let second = minuteRemainder.quotientAndRemainder(dividingBy: secondInterval)
+ let additionalSecondInterval = second.remainder == 0 ? 0 : secondInterval
+ let numberOfSeconds = min(
+ NumberOfSeconds - secondInterval,
+ second.quotient * secondInterval + additionalSecondInterval)
+
+ return TimeComponents(
+ uncheckedHour: numberOfHours,
+ uncheckedMinute: numberOfMinutes,
+ uncheckedSecond: numberOfSeconds)
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Utilities/DurationFormatter.swift b/Sources/DurationPicker/Internal/Utilities/DurationFormatter.swift
new file mode 100644
index 0000000..11ef65a
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Utilities/DurationFormatter.swift
@@ -0,0 +1,144 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+/// A formatter that provides localized representations of duration measurements.
+final class DurationFormatter {
+
+ /// The list of all units of duration supported by the duration picker.
+ enum UnitType {
+
+ /// Represents the hour unit.
+ case hour
+
+ /// Represents the minute unit.
+ case minute
+
+ /// Represents the second unit.
+ case second
+ }
+
+ private let numberFormatter: NumberFormatter = {
+ let formatter = NumberFormatter()
+ // We currently only support languages with Western Arabic numerals
+ formatter.locale = Locale(identifier: "en")
+ return formatter
+ }()
+
+ /// Creates and returns a localized string representation of the provided value.
+ ///
+ /// - Parameter value: The value to be represented.
+ /// - Parameter unit: The unit of duration to be represented.
+ ///
+ /// - Returns: A user-readable string that represents the value.
+ func string(fromValue value: Int,
+ unit: UnitType) -> String? {
+ guard let valueString = string(fromValue: value) else {
+ return nil
+ }
+ let unitString = unitString(
+ fromValue: value,
+ unit: unit)
+ return valueString + " " + unitString
+ }
+
+ /// Creates and returns a localized string representation of the provided value, not including the unit string.
+ ///
+ /// - Parameter value: The value to be represented.
+ ///
+ /// - Returns: A user-readable string that represents the value, not including the unit string.
+ func string(fromValue value: Int) -> String? {
+ numberFormatter.string(from: NSNumber(value: value))
+ }
+
+ /// Creates and returns a localized string representation of the provided unit of duration.
+ ///
+ /// - Parameters:
+ /// - value: The value corresponding to the unit.
+ /// - unit: The unit of duration to be represented.
+ ///
+ /// - Returns: A user-readable string that represents the unit of duration.
+ func unitString(fromValue value: Int,
+ unit: UnitType) -> String {
+ switch unit {
+ case .hour:
+ hourUnitText(fromHour: value)
+ case .minute:
+ minuteUnitText(fromMinute: value)
+ case .second:
+ secondUnitText(fromSecond: value)
+ }
+ }
+
+ /// Returns all possible unit strings for the provided unit of duration.
+ ///
+ /// - Parameter unit: The unit of duration for which unit strings are requested.
+ ///
+ /// - Returns: An array containing all possible localized unit strings for the specified unit.
+ func possibleUnitStrings(forUnit unit: UnitType) -> [String] {
+ switch unit {
+ case .hour:
+ [String(localizedFromModule: "hour_zero"),
+ String(localizedFromModule: "hour_singular"),
+ String(localizedFromModule: "hour_plural")]
+ case .minute:
+ [String(localizedFromModule: "minute_zero"),
+ String(localizedFromModule: "minute_singular"),
+ String(localizedFromModule: "minute_plural")]
+ case .second:
+ [String(localizedFromModule: "second_zero"),
+ String(localizedFromModule: "second_singular"),
+ String(localizedFromModule: "second_plural")]
+ }
+ }
+
+ private func hourUnitText(fromHour hour: Int) -> String {
+ if hour == 0 {
+ return String(localizedFromModule: "hour_zero")
+ }
+ if hour == 1 {
+ return String(localizedFromModule: "hour_singular")
+ }
+ return String(localizedFromModule: "hour_plural")
+ }
+
+ private func minuteUnitText(fromMinute minute: Int) -> String {
+ if minute == 0 {
+ return String(localizedFromModule: "minute_zero")
+ }
+ if minute == 1 {
+ return String(localizedFromModule: "minute_singular")
+ }
+ return String(localizedFromModule: "minute_plural")
+ }
+
+ private func secondUnitText(fromSecond second: Int) -> String {
+ if second == 0 {
+ return String(localizedFromModule: "second_zero")
+ }
+ if second == 1 {
+ return String(localizedFromModule: "second_singular")
+ }
+ return String(localizedFromModule: "second_plural")
+ }
+}
diff --git a/Sources/DurationPicker/Internal/Utilities/TimeConstants.swift b/Sources/DurationPicker/Internal/Utilities/TimeConstants.swift
new file mode 100644
index 0000000..b0b1589
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Utilities/TimeConstants.swift
@@ -0,0 +1,41 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+/// The number of hours in a day.
+let NumberOfHours: Int = 24
+
+/// The number of minutes in an hour.
+let NumberOfMinutes: Int = 60
+
+/// The number of seconds in a minute.
+let NumberOfSeconds: Int = 60
+
+/// The number of seconds in one day.
+let OneDay = NumberOfHours * NumberOfMinutes * NumberOfSeconds
+
+/// The number of seconds in one hour.
+let OneHour = NumberOfMinutes * NumberOfSeconds
+
+/// The number of seconds in one hour.
+let OneMinute = NumberOfSeconds
diff --git a/Sources/DurationPicker/Internal/Utilities/TimeUtils.swift b/Sources/DurationPicker/Internal/Utilities/TimeUtils.swift
new file mode 100644
index 0000000..3c80030
--- /dev/null
+++ b/Sources/DurationPicker/Internal/Utilities/TimeUtils.swift
@@ -0,0 +1,168 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+/// Utility methods for working with time-related calculations and durations.
+enum TimeUtils {
+
+ /// Calculates the total number of seconds from the provided hours, minutes, and seconds.
+ ///
+ /// - Parameters:
+ /// - hours: The number of hours.
+ /// - minutes: The number of minutes.
+ /// - seconds: The number of seconds.
+ ///
+ /// - Returns: The total number of seconds.
+ static func seconds(fromHours hours: Int = 0,
+ minutes: Int = 0,
+ seconds: Int = 0) -> Int {
+ hours * OneHour
+ + minutes * OneMinute
+ + seconds
+ }
+
+ /// Calculates the absolute maximum duration that can be displayed for the given picker mode and intervals.
+ ///
+ /// - Parameters:
+ /// - pickerMode: The mode of the duration picker.
+ /// - hourInterval: The interval for hours, defaults to 1.
+ /// - minuteInterval: The interval for minutes, defaults to 1.
+ /// - secondInterval: The interval for seconds, defaults to 1.
+ ///
+ /// - Returns: The absolute maximum duration in seconds.
+ static func absoluteMaximumDuration(forPickerMode pickerMode: DurationPicker.Mode,
+ hourInterval: Int = 1,
+ minuteInterval: Int = 1,
+ secondInterval: Int = 1) -> Int {
+ seconds(
+ fromHours: maximumNumberOfHours(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval),
+ minutes: maximumNumberOfMinutes(
+ forPickerMode: pickerMode,
+ minuteInterval: minuteInterval),
+ seconds: maximumNumberOfSeconds(
+ forPickerMode: pickerMode,
+ secondInterval: secondInterval))
+ }
+
+ /// Rounds the provided duration to the nearest allowed value based on the picker mode and intervals.
+ ///
+ /// - Parameters:
+ /// - duration: The duration to be rounded.
+ /// - pickerMode: The mode of the duration picker.
+ /// - roundingRule: The rounding rule to apply.
+ /// - hourInterval: The interval for hours, defaults to 1.
+ /// - minuteInterval: The interval for minutes, defaults to 1.
+ /// - secondInterval: The interval for seconds, defaults to 1.
+ ///
+ /// - Returns: The rounded duration.
+ static func roundedDuration(_ duration: Int,
+ forPickerMode pickerMode: DurationPicker.Mode,
+ roundingRule: FloatingPointRoundingRule,
+ hourInterval: Int = 1,
+ minuteInterval: Int = 1,
+ secondInterval: Int = 1) -> Int {
+ let absoluteMaximumDuration = absoluteMaximumDuration(
+ forPickerMode: pickerMode,
+ hourInterval: hourInterval,
+ minuteInterval: minuteInterval,
+ secondInterval: secondInterval)
+
+ switch pickerMode {
+ case .hour:
+ let roundedDuration = duration.rounded(
+ toNearest: hourInterval * OneHour,
+ roundingRule: roundingRule)
+ return roundedDuration.clamped(to: 0...absoluteMaximumDuration)
+ case .hourMinute,
+ .minute:
+ let roundedDuration = duration.rounded(
+ toNearest: minuteInterval * OneMinute,
+ roundingRule: roundingRule)
+ return roundedDuration.clamped(to: 0...absoluteMaximumDuration)
+ case .hourMinuteSecond,
+ .minuteSecond,
+ .second:
+ let roundedDuration = duration.rounded(
+ toNearest: secondInterval,
+ roundingRule: roundingRule)
+ return roundedDuration.clamped(to: 0...absoluteMaximumDuration)
+ }
+ }
+
+ /// Calculates the maximum number of hours that can be displayed for the given picker mode and interval.
+ ///
+ /// - Parameters:
+ /// - pickerMode: The mode of the duration picker.
+ /// - hourInterval: The hour interval.
+ ///
+ /// - Returns: The maximum number of hours that can be displayed.
+ static func maximumNumberOfHours(forPickerMode pickerMode: DurationPicker.Mode,
+ hourInterval: Int) -> Int {
+ switch pickerMode {
+ case .hour,
+ .hourMinute,
+ .hourMinuteSecond:
+ NumberOfHours - hourInterval
+ default: 0
+ }
+ }
+
+ /// Calculates the maximum number of minutes that can be displayed for the given picker mode and interval.
+ ///
+ /// - Parameters:
+ /// - pickerMode: The mode of the duration picker.
+ /// - minuteInterval: The minute interval.
+ ///
+ /// - Returns: The maximum number of minutes that can be displayed.
+ static func maximumNumberOfMinutes(forPickerMode pickerMode: DurationPicker.Mode,
+ minuteInterval: Int) -> Int {
+ switch pickerMode {
+ case .hourMinuteSecond,
+ .hourMinute,
+ .minute,
+ .minuteSecond:
+ NumberOfMinutes - minuteInterval
+ default: 0
+ }
+ }
+
+ /// Calculates the maximum number of seconds that can be displayed for the given picker mode and interval.
+ ///
+ /// - Parameters:
+ /// - pickerMode: The mode of the duration picker.
+ /// - minuteInterval: The second interval.
+ ///
+ /// - Returns: The maximum number of seconds that can be displayed.
+ static func maximumNumberOfSeconds(forPickerMode pickerMode: DurationPicker.Mode,
+ secondInterval: Int) -> Int {
+ switch pickerMode {
+ case .hourMinuteSecond,
+ .minuteSecond,
+ .second:
+ NumberOfSeconds - secondInterval
+ default: 0
+ }
+ }
+}
diff --git a/Sources/DurationPicker/Public/DurationPicker+Mode.swift b/Sources/DurationPicker/Public/DurationPicker+Mode.swift
new file mode 100644
index 0000000..28cbe8c
--- /dev/null
+++ b/Sources/DurationPicker/Public/DurationPicker+Mode.swift
@@ -0,0 +1,133 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import Foundation
+
+extension DurationPicker {
+
+ /// The mode displayed by the duration picker.
+ ///
+ /// The mode determines which combination of hours, minutes, and seconds are displayed. You can set and retrieve the mode value through the ``DurationPicker/pickerMode`` property.
+ public enum Mode {
+
+ /// A mode that displays hour values, for example [ 1 ].
+ case hour
+
+ /// A mode that displays hour and minute values, for example [ 1 | 25 ].
+ case hourMinute
+
+ /// A mode that displays hour, minute, and second values, for example [ 1 | 25 | 41 ].
+ case hourMinuteSecond
+
+ /// A mode that displays minute values, for example [ 25 ].
+ case minute
+
+ /// A mode that displays minute and second values, for example [ 25 | 41 ].
+ case minuteSecond
+
+ /// A mode that displays second values, for example [ 41 ].
+ case second
+ }
+}
+
+// MARK: - Picker Mode + Components
+
+/// The type of component displayed by the picker view.
+enum DurationPickerComponentType {
+
+ /// The component which displays the hour values.
+ case hour
+
+ /// The component which displays the minute values.
+ case minute
+
+ /// The component which displays the second values.
+ case second
+}
+
+extension DurationPicker.Mode {
+
+ /// A zero-indexed number identifing the the hour component of the picker view, or `nil` if the component is not present for the mode.
+ var hourComponent: Int? {
+ switch self {
+ case .hour,
+ .hourMinute,
+ .hourMinuteSecond:
+ return 0
+ default:
+ return nil
+ }
+ }
+
+ /// A zero-indexed number identifing the the minute component of the picker view, or `nil` if the component is not present for the mode.
+ var minuteComponent: Int? {
+ switch self {
+ case .hourMinute,
+ .hourMinuteSecond:
+ return 1
+ case .minute,
+ .minuteSecond:
+ return 0
+ default:
+ return nil
+ }
+ }
+
+ /// A zero-indexed number identifing the the second component of the picker view, or `nil` if the component is not present for the mode.
+ var secondComponent: Int? {
+ switch self {
+ case .hourMinuteSecond:
+ return 2
+ case .minuteSecond:
+ return 1
+ case .second:
+ return 0
+ default:
+ return nil
+ }
+ }
+
+ /// The number of components for the mode.
+ var numberOfComponents: Int {
+ switch self {
+ case .hourMinuteSecond:
+ return 3
+ case .hourMinute,
+ .minuteSecond:
+ return 2
+ case .hour,
+ .minute,
+ .second:
+ return 1
+ }
+ }
+
+ /// Returns the component type for the component of the picker view.
+ ///
+ /// - Parameter component: The component of the picker view.
+ func componentType(fromComponent component: Int) -> DurationPickerComponentType? {
+ if component == hourComponent { return .hour }
+ if component == minuteComponent { return .minute }
+ if component == secondComponent { return .second }
+ return nil
+ }
+}
diff --git a/Sources/DurationPicker/Public/DurationPicker.swift b/Sources/DurationPicker/Public/DurationPicker.swift
new file mode 100644
index 0000000..73feb06
--- /dev/null
+++ b/Sources/DurationPicker/Public/DurationPicker.swift
@@ -0,0 +1,268 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+import UIKit
+
+/// A customizable control for inputting time values ranging between 0 and 24 hours. It serves as a drop-in replacement of [UIDatePicker](https://developer.apple.com/documentation/uikit/uidatepicker) with [countDownTimer](https://developer.apple.com/documentation/uikit/uidatepicker/mode/countdowntimer) mode with additional functionality for time input.
+///
+/// You can use a duration picker to allow a user to enter a time interval between 0 and 24 hours. The duration picker reports interactions to its associated target object.
+///
+/// To add a duration picker to your interface:
+///
+/// * Set the duration picker mode at creation time.
+/// * Supply additional configuration options such as minimum and maximum durations if required.
+/// * Connect an action method to the duration picker.
+/// * Set up Auto Layout rules to govern the position of the duration picker in your interface.
+///
+/// You use a duration picker only for handling the selection of times between 0 and 24 hours. If you want to handle the selection of dates from a list, use a [`UIDatePicker`](https://developer.apple.com/documentation/uikit/uidatepicker) object.
+///
+/// ### Configure a DurationPicker
+///
+/// The ``DurationPicker/pickerMode`` property determines the configuration of a duration picker. The ``DurationPicker/duration`` property represents the currently selected duration in seconds. The duration picker uses locale information when formatting duration values for the current user, and defaults to the app's preferred language setting.
+///
+/// To limit the duration that the user can select, assign values to the ``DurationPicker/minimumDuration`` and ``DurationPicker/maximumDuration`` properties. You can also use the ``DurationPicker/hourInterval``, ``DurationPicker/minuteInterval``, and ``DurationPicker/secondInterval`` properties to allow only specific time increments.
+///
+/// The figure below shows a duration picker configured with the ``DurationPicker/pickerMode`` property set to ``DurationPicker/Mode/hourMinuteSecond``, the ``DurationPicker/minuteInterval`` property set to 10, and the ``DurationPicker/secondInterval`` property set to 15. The value of ``DurationPicker/duration`` is currently 5445.
+///
+/// ![A screenshot of a duration picker showing the selected value of 1 hour, 30 minutes, and 45 seconds.](duration_picker)
+///
+/// > When used as a countdown timer, you can use a ``DurationPicker`` object for the selection of a time interval, but you must use a [Timer](https://developer.apple.com/documentation/foundation/timer) object to implement the actual timer behavior. For more information, see [Timer](https://developer.apple.com/documentation/foundation/timer).
+///
+/// ### Respond to user interaction
+/// Duration pickers use the target-action design pattern to notify your app when the user changes the selected duration. To be notified when the duration picker’s value changes, register your action method with the either the [valueChanged](https://developer.apple.com/documentation/uikit/uicontrol/event/1618238-valuechanged) or [primaryActionTriggered](https://developer.apple.com/documentation/uikit/uicontrol/event/1618222-primaryactiontriggered) event. At runtime the duration picker calls your methods in response to the user selecting a duration.
+///
+/// You connect a date picker to your action method using the [addTarget(_:action:for:)](https://developer.apple.com/documentation/uikit/uicontrol/1618259-addtarget) method. The signature of an action method takes one of three forms, as shown in the following code. Choose the form that provides the information that you need to respond to the value change in the duration picker.
+///
+/// ```swift
+/// @objc func doSomething()
+/// @objc func doSomething(sender: DurationPicker)
+/// @objc func doSomething(sender: DurationPicker, forEvent event: UIEvent)
+/// ```
+///
+/// Alternatively, you can react to changes in the duration's picker value using [UIActions](https://developer.apple.com/documentation/uikit/uiaction). The code below shows how an action can be registered during initialization or later with the [addAction(_:for:)](https://developer.apple.com/documentation/uikit/uicontrol/3600490-addaction) method.
+///
+/// ```swift
+/// let picker = DurationPicker(
+/// frame: .zero,
+/// primaryAction: UIAction { _ in
+/// // Do something
+/// })
+/// ```
+///
+/// ```swift
+/// let picker = DurationPicker()
+/// picker.addAction(
+/// UIAction { _ in
+/// // Do something
+/// },
+/// for: .primaryActionTriggered)
+/// ```
+///
+/// ### Debug duration pickers
+/// When debugging issues with duration pickers, watch for these common pitfalls:
+///
+/// - **The minimum duration must be at most the maximum duration.** Check the bounds of your ``DurationPicker/minimumDuration`` and ``DurationPicker/maximumDuration`` properties. If the maximum duration is less than the minimum duration, both properties are ignored, and the duration picker allows the selection of any duration value.
+/// - **There must be at least one value defined between the minimum duration and maximum duration.** Check that the range of values determined by the ``DurationPicker/hourInterval``, ``DurationPicker/minuteInterval``, and ``DurationPicker/secondInterval`` properties includes at least one value between the minimum and maximum durations. Otherwise, the ``DurationPicker/minimumDuration`` and ``DurationPicker/maximumDuration`` properties are ignored.
+///- **The hour, minute, and second intervals must evenly divide their respective totals.** Check that the ``DurationPicker/minuteInterval`` and ``DurationPicker/secondInterval`` values can be evenly divided into 60, and that the ``DurationPicker/hourInterval`` value can be evenly divided into 24. Otherwise, the default value is used (1).
+///
+/// ### Support accessibility and VoiceOver
+/// Duration pickers are accessible by default. Each time component in the duration picker is its own accessibility element and has the Adjustable ([adjustable](https://developer.apple.com/documentation/uikit/uiaccessibilitytraits/1620177-adjustable)) trait.
+///
+/// The device reads the accessibility value, traits, and hint out loud for each date picker when the user enables VoiceOver. VoiceOver speaks this information when a user taps on a picker wheel. For example, when a user taps the hours column, VoiceOver speaks the following:
+///
+/// ```swift
+/// "2 hours. Picker Item. Adjustable. 1 of 24. Swipe up or down with one finger to adjust the value."
+/// ```
+///
+///For further information about making iOS controls accessible, see [Building accessible apps](https://developer.apple.com/accessibility/).
+///
+/// ## Topics
+///
+/// ### Managing the duration and locale
+/// -
+/// - ``DurationPicker/duration``
+/// - ``DurationPicker/setDuration(_:animated:)``
+///
+/// ### Configuring the duration picker mode
+/// - ``DurationPicker/pickerMode``
+/// - ``DurationPicker/Mode``
+///
+/// ### Configuring the temporal attributes
+/// - ``DurationPicker/minimumDuration``
+/// - ``DurationPicker/maximumDuration``
+/// - ``DurationPicker/secondInterval``
+/// - ``DurationPicker/minuteInterval``
+/// - ``DurationPicker/hourInterval``
+open class DurationPicker: UIControl {
+
+ /// The mode of the duration picker.
+ ///
+ /// Use this property to change the type of information displayed by the duration picker. It determines whether the duration picker allows selection of hours, minutes, and/or seconds. The default mode is ``DurationPicker/Mode/hourMinuteSecond``. See ``DurationPicker/Mode`` for a list of mode constants.
+ public var pickerMode: Mode {
+ get { pickerView.pickerMode }
+ set { pickerView.pickerMode = newValue }
+ }
+
+ /// The minimum duration that the picker can show.
+ ///
+ /// Use this property to configure the minimum duration that is selected in the duration picker interface. The property is an integer measured in seconds or `nil` (the default), which means no minimum duration. This property, along with the ``maximumDuration`` property, lets you specify a valid duration range. The range must contains at least one allowed value as determined by the ``DurationPicker/hourInterval``, ``DurationPicker/minuteInterval``, and ``DurationPicker/secondInterval`` properties. If the minimum duration value is greater than the maximum duration value, or if the time range contains no allowed values, both properties are ignored.
+ public var minimumDuration: TimeInterval? {
+ get { pickerView.minimumDuration.asTimeInterval }
+ set { pickerView.minimumDuration = newValue.asInteger }
+ }
+
+ /// The maximum duration that the picker can show.
+ ///
+ /// Use this property to configure the maximum duration that is selected in the duration picker interface. The property is an integer measured in seconds or `nil` (the default), which means no maximum duration. This property, along with the ``minimumDuration`` property, lets you specify a valid duration range. The range must contains at least one allowed value as determined by the ``DurationPicker/hourInterval``, ``DurationPicker/minuteInterval``, and ``DurationPicker/secondInterval`` properties. If the minimum duration value is greater than the maximum duration value, or if the time range contains no allowed values, both properties are ignored.
+ public var maximumDuration: TimeInterval? {
+ get { pickerView.maximumDuration.asTimeInterval }
+ set { pickerView.maximumDuration = newValue.asInteger }
+ }
+
+ /// The interval at which the duration picker should display seconds.
+ ///
+ /// Use this property to set the interval displayed by the seconds wheel (for example, 15 seconds). The interval value must be evenly divided into 60; if it isn’t, the default value is used. The default and minimum values are 1; the maximum value is 30.
+ public var secondInterval: Int {
+ get { pickerView.secondInterval }
+ set { pickerView.secondInterval = newValue }
+ }
+
+ /// The interval at which the duration picker should display minutes.
+ ///
+ /// Use this property to set the interval displayed by the minutes wheel (for example, 15 minutes). The interval value must be evenly divided into 60; if it isn’t, the default value is used. The default and minimum values are 1; the maximum value is 30.
+ public var minuteInterval: Int {
+ get { pickerView.minuteInterval }
+ set { pickerView.minuteInterval = newValue }
+ }
+
+ /// The interval at which the duration picker should display hours.
+ ///
+ /// Use this property to set the interval displayed by the hours wheel (for example, 4 hours). The interval value must be evenly divided into 24; if it isn’t, the default value is used. The default and minimum values are 1; the maximum value is 12.
+ public var hourInterval: Int {
+ get { pickerView.hourInterval }
+ set { pickerView.hourInterval = newValue }
+ }
+
+ /// The value displayed by the duration picker.
+ ///
+ /// Use this property to get and set the currently selected value. This property is of type `TimeInterval` and therefore is measured in seconds. The default value is 0.0 and the maximum value is 23:59:59 (86,399 seconds).
+ public var duration: TimeInterval {
+ get { TimeInterval(pickerView.duration) }
+ set { setDuration(newValue, animated: false) }
+ }
+
+ private let pickerView = DurationPickerView()
+
+ // MARK: - Initializers & View Lifecycle
+
+ @_documentation(visibility: internal)
+ public override init(frame: CGRect) {
+ super.init(frame: CGRect(
+ origin: frame.origin,
+ size: pickerView.intrinsicContentSize))
+ commonInit()
+ }
+
+ @_documentation(visibility: internal)
+ public required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ commonInit()
+ }
+
+ private func commonInit() {
+ addSubview(pickerView)
+
+ pickerView.durationUpdateBlock = { [weak self] _ in
+ guard let self else { return }
+ // Objective-C target/action event
+ sendActions(for: .valueChanged)
+ // UIAction event
+ sendActions(for: .primaryActionTriggered)
+ }
+ }
+
+ @_documentation(visibility: internal)
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+ pickerView.frame = bounds
+ }
+
+ // MARK: - Setters
+
+ /// Sets the time to display in the duration picker, with an option to animate the setting.
+ ///
+ /// Values are rounded down to the closest allowed value, e.g. if the mode is ``DurationPicker/Mode/hourMinute`` the value 119 will be rounded down to 60.
+ ///
+ /// - Parameters:
+ /// - duration: The new duration (in seconds) to display in the duration picker.
+ /// - animated: `true` to animate the setting of the new time, otherwise `false`. The animation rotates the wheels until the new time is shown under the highlight rectangle.
+ open func setDuration(_ duration: TimeInterval,
+ animated: Bool) {
+ pickerView.setDuration(
+ Int(duration),
+ animated: animated)
+ }
+
+ // MARK: - Sizing
+
+ @_documentation(visibility: internal)
+ open override var intrinsicContentSize: CGSize {
+ pickerView.intrinsicContentSize
+ }
+
+ @_documentation(visibility: internal)
+ open override func invalidateIntrinsicContentSize() {
+ super.invalidateIntrinsicContentSize()
+ pickerView.invalidateIntrinsicContentSize()
+ }
+
+ @_documentation(visibility: internal)
+ open override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
+ pickerView.systemLayoutSizeFitting(targetSize)
+ }
+
+ @_documentation(visibility: internal)
+ open override func systemLayoutSizeFitting(_ targetSize: CGSize,
+ withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
+ verticalFittingPriority: UILayoutPriority) -> CGSize {
+ pickerView.systemLayoutSizeFitting(
+ targetSize,
+ withHorizontalFittingPriority: horizontalFittingPriority,
+ verticalFittingPriority: verticalFittingPriority)
+ }
+
+ @_documentation(visibility: internal)
+ open override func sizeThatFits(_ size: CGSize) -> CGSize {
+ pickerView.sizeThatFits(size)
+ }
+
+ @_documentation(visibility: internal)
+ open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ // Invalidate the intrinsic content size since UIPicker has different heights depending on its
+ // horizontal and vertical size classes
+ if previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
+ || previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass {
+ invalidateIntrinsicContentSize()
+ }
+ }
+}
diff --git a/Sources/DurationPicker/Resources/Documentation.docc/Localization.md b/Sources/DurationPicker/Resources/Documentation.docc/Localization.md
new file mode 100644
index 0000000..c16aa45
--- /dev/null
+++ b/Sources/DurationPicker/Resources/Documentation.docc/Localization.md
@@ -0,0 +1,91 @@
+# Localization
+
+Learn how DurationPicker supports multiple languages and regions.
+
+Locales encapsulate information about facets of a language or culture, including the way duration measurements are formatted. For apps which target iOS 13 and above, explictily setting a locale is discouraged by Apple in favor of using [per-app language settings](https://developer.apple.com/news/?id=u2cfuj88). Hence, the language that ``DurationPicker`` uses for display is determined entirely by the system locale. This is unlike [`UIDatePicker`](https://developer.apple.com/documentation/uikit/uidatepicker) whose [locale](https://developer.apple.com/documentation/uikit/uidatepicker/1615995-locale) can be overridden.
+
+## Supported locales
+Below is a list of locales supported by the library.
+
+| Locale | Identifier | Supported |
+|--|--|--|
+| Arabic | `ar` | No |
+| Catalan | `ca` | Yes |
+| Chinese (Hong-Kong) | `zh-HK` | Yes |
+| Chinese, Simplified | `zh-Hans` | Yes |
+| Chinese, Traditional | `zh-Hant` | Yes |
+| Croatian | `hr` | Yes |
+| Czech | `cs` | Yes |
+| Danish | `da` | Yes |
+| Dutch | `nl` | Yes |
+| English | `en` | Yes |
+| English (Australia) | `en-AU` | Yes |
+| English (India) | `en-IN` | Yes |
+| English (United Kingdom) | `en-UK` | Yes |
+| Finnish | `fi` | Yes |
+| French | `fr` | Yes |
+| French (Canada) | `fr-CA` | Yes\* |
+| German | `de` | Yes |
+| Greek | `el` | Yes |
+| Hebrew | `he` | No |
+| Hindi | `hi` | Yes |
+| Hungarian | `hu` | Yes |
+| Indonesian | `id` | Yes |
+| Italian | `it` | Yes |
+| Japanese | `ja` | Yes |
+| Korean | `ko` | Yes |
+| Malay | `ms` | Yes |
+| Norwegian Bokmål | `nb` | Yes |
+| Polish | `pl` | Yes |
+| Portuguese (Brazil) | `pt-BR` | Yes |
+| Portuguese (Portugal) | `pt-PT` | Yes |
+| Romainian | `ro` | Yes |
+| Russian | `ru` | Yes |
+| Slovak | `sk` | Yes |
+| Spanish | `es` | Yes\* |
+| Spanish (Latin America) | `es-419` | Yes\* |
+| Swedish | `sv` | Yes |
+| Thai | `th` | Yes |
+| Turkish | `tr` | Yes |
+| Ukrainian | `uk` | Yes |
+| Vietnamese | `vi` | Yes |
+
+> Support with an asterisk \* indicates that at least one string varies from what is shown on `UIDatePicker` with `countDownTimer` mode. This is done to ensure that the unit labels fit within the duration picker's bounds with `hourMinuteSecond` mode.
+
+If your app includes a language which is not supported, iOS will instead localize using the next language in the user's language preferences. See [How iOS Determines the Language For Your App](https://developer.apple.com/library/archive/qa/qa1828/_index.html) for more information.
+
+For more general information, see [Internationalization and Localization Guide](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/Introduction/Introduction.html#//apple_ref/doc/uid/10000171i).
+
+## How we localized DurationPicker
+
+The only localized strings in the library are the _hour_, _minute_, and _second_ unit strings displayed between the picker rows on ``DurationPicker``. The _hour_ and _minute_ unit strings are observed directly from [UIDatePicker](https://developer.apple.com/documentation/uikit/uidatepicker) with [countDownTimer]([countDownTimer](https://developer.apple.com/documentation/uikit/uidatepicker/mode/countdowntimer)) mode. The _second_ unit strings are localized based on the localized strings returned by [string(from:)](https://developer.apple.com/documentation/foundation/measurementformatter/1642059-string) on [MeasurementFormatter](https://developer.apple.com/documentation/foundation/measurementformatter) when formatting 0, 1, and 2 seconds (zero, singular, and plural). These strings are compared against the localized _hour_ and _minute_ strings from [UIDatePicker](https://developer.apple.com/documentation/uikit/uidatepicker) to determine which [unitStyle](https://developer.apple.com/documentation/foundation/measurementformatter/1642067-unitstyle) is most apppropriate. In general, we observed that the _hour_ [unitStyle](https://developer.apple.com/documentation/foundation/measurementformatter/1642067-unitstyle) was longer than the _minute_ unit style and never shorter. To this end, we assume that _minutes_ and _seconds_ share the same unit style.
+
+For example, to determine the unit string for _one second_ in German, we would write
+
+```swift
+let formatter = MeasurementFormatter()
+formatter.unitOptions = .providedUnit
+formatter.locale = Locale(identifier: "de")
+
+let oneHour = Measurement(value: 1, unit: . hours)
+let oneMinute = Measurement(value: 1, unit: .minutes)
+let oneSecond = Measurement(value: 1, unit: .seconds)
+
+formatter.unitStyle = .long
+formatter.string(from: oneHour) // "1 Stunde"
+formatter.string(from: oneMinute) // "1 Minute"
+formatter.string(from: oneSecond) // "1 Sekunde"
+
+formatter.unitStyle = .medium
+formatter.string(from: oneHour) // "1 Std."
+formatter.string(from: oneMinute) // "1 Min."
+formatter.string(from: oneSecond) // "1 Sek."
+
+formatter.unitStyle = .short
+formatter.string(from: oneHour) // "1h"
+formatter.string(from: oneMinute) // "1min"
+formatter.string(from: oneSecond) // "1s"
+```
+The observed _minute_ and _second_ unit strings on `UIDatePicker` are "Stunde" and "Min.", corresponding to the [long](https://developer.apple.com/documentation/foundation/formatter/unitstyle/long) and [medium](https://developer.apple.com/documentation/foundation/formatter/unitstyle/medium) unit styles for hours and minutes, respectively. Hence, we would choose the [medium](https://developer.apple.com/documentation/foundation/formatter/unitstyle/medium) unit style for seconds, or "Sek.".
+
+> If you believe there is a localization error, please [open an issue](https://github.com/mac-gallagher/DurationPicker/issues/new) on GitHub.
diff --git a/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker.png b/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker.png
new file mode 100644
index 0000000..aadf5da
Binary files /dev/null and b/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker.png differ
diff --git a/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker~dark.png b/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker~dark.png
new file mode 100644
index 0000000..2ecba3e
Binary files /dev/null and b/Sources/DurationPicker/Resources/Documentation.docc/Resources/duration_picker~dark.png differ
diff --git a/Sources/DurationPicker/Resources/Localizable.xcstrings b/Sources/DurationPicker/Resources/Localizable.xcstrings
new file mode 100644
index 0000000..2aa5392
--- /dev/null
+++ b/Sources/DurationPicker/Resources/Localizable.xcstrings
@@ -0,0 +1,2802 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "hour_a11y_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hores"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hodiny"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Timer"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stunden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ώρες"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hours"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hours"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hours"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hours"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Horas"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Horas"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tunnit"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Heures"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Heures"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "घंटे"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sati"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Órák"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jam"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ore"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "시간"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jam"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Timer"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uren"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Godziny"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Horas"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Horas"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ore"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Часы"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hodiny"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Timmar"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ชั่วโมง"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saatler"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Години"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Giờ"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小时"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : " 小時"
+ }
+ }
+ }
+ },
+ "hour_plural" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hores"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hod"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timer"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stunden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ώρες"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tunti"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heures"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heures"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "घंटे"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "óra"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ore"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "시간"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timer"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "urr"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "godz."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ore"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ч"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timmar"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ชั่วโมง"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "год"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giờ"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小时"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ }
+ }
+ },
+ "hour_singular" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hora"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hod"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "time"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stunde"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ώρα"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hour"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hour"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hour"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hour"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hora"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hora"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tuntia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heure"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heure"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "घंटा"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "óra"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ora"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "시간"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "time"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "urr"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "godz."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hora"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hora"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "oră"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ч"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timme"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ชั่วโมง"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "година"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giờ"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小时"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ }
+ }
+ },
+ "hour_zero" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hores"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hod"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timer"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stunden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ώρες"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "hours"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tuntia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heure"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heure"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "घंटे"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "óra"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ore"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "시간"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jam"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timer"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "urr"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "godz."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "horas"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ore"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ч"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "h"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timmar"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ชั่วโมง"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "годин"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giờ"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小时"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "小時"
+ }
+ }
+ }
+ },
+ "minute_a11y_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuts"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuty"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutter"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuten"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Λεπτά"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutos"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuutit"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "मिनट"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minute"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Percek"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Menit"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuti"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "분"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minit"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutter"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuten"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuty"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutos"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutos"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minute"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Минуты"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minúty"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuter"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "นาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dakikalar"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Хвилини"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Phút"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分钟"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ }
+ }
+ },
+ "minute_plural" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Min."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "λεπτά"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "मिनट"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "perc"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mnt"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "분"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "мин"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "นาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dk."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "хв"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "phút"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分钟"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ }
+ }
+ },
+ "minute_singular" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Min."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "λεπτό"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "मिनट"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "perc"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mnt"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "분"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "мин"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "นาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dk."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "хв"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "phút"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分钟"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ }
+ }
+ },
+ "minute_zero" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Min."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "λεπτά"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "मिनट"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "perc"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mnt"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "분"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "мин"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "นาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dk."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "хв"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "phút"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分钟"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分鐘"
+ }
+ }
+ }
+ },
+ "second_a11y_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Segons"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekundy"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunder"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δευτερόλεπτα"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seconds"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seconds"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seconds"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seconds"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Segundos"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Segundos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunnit"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Secondes"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Secondes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सेकंड"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunde"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Másodpercek"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Detik"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Secondi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "초"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saat"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunder"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seconden"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekundy"
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutos"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Segundos"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Secunde"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Секунды"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekundy"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sekunder"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "วินาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saniyeler"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Секунди"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Giây"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒钟"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒鐘"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒鐘"
+ }
+ }
+ }
+ },
+ "second_plural" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sek."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "δευτ."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secs"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secs"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सेकंड"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mp"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dtk"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "초"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "seg"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "วินาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sn."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giây"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ }
+ }
+ },
+ "second_singular" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sek."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "δευτ."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सेकंड"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mp"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dtk"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "초"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "seg"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "วินาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sn."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giây"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ }
+ }
+ },
+ "second_zero" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ca" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "cs" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "da" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sek."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "δευτ."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec"
+ }
+ },
+ "en-AU" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secs"
+ }
+ },
+ "en-IN" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secs"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सेकंड"
+ }
+ },
+ "hr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "hu" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "mp"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dtk"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "초"
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "saat"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek"
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sec."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sek."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "seg"
+ }
+ },
+ "pt-PT" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "sk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "s"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "วินาที"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sn."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "с"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "giây"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
\ No newline at end of file
diff --git a/Tests/DurationPickerTests/DurationPickerTests.swift b/Tests/DurationPickerTests/DurationPickerTests.swift
new file mode 100644
index 0000000..f5ff1b2
--- /dev/null
+++ b/Tests/DurationPickerTests/DurationPickerTests.swift
@@ -0,0 +1,507 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+@testable import DurationPicker
+import XCTest
+
+/// Tests which cover the basic functionality of `DurationPicker`.
+///
+/// We also include some tests which compare behavior against `UIDatePicker` with `countDownTimer` mode.
+final class DurationPickerTests: XCTestCase {
+
+ /// The duration picker used for testing.
+ private var durationPicker: DurationPicker!
+
+ /// A date picker instance used for comparison purposes.
+ private var datePicker: UIDatePicker!
+
+ override func setUp() {
+ super.setUp()
+ durationPicker = DurationPicker()
+ datePicker = UIDatePicker()
+ datePicker.datePickerMode = .countDownTimer
+ }
+
+ // MARK: - Initializer Date Picker Comparison
+
+ /// Tests whether the initial frame of a `DurationPicker` initialized with a zero frame matches that of a `UIDatePicker`.
+ func testInitWithZeroFrameComparisonWithDatePicker() {
+ durationPicker.pickerMode = .hourMinute
+ XCTAssertEqual(
+ datePicker.frame.size,
+ durationPicker.frame.size)
+ XCTAssertEqual(
+ durationPicker.frame.size,
+ datePicker.intrinsicContentSize)
+ XCTAssertEqual(
+ datePicker.intrinsicContentSize,
+ durationPicker.intrinsicContentSize)
+ }
+
+ /// Tests whether the frame of a `DurationPicker` initialized with non-zero frame matches that of a `UIDatePicker`.
+ func testInitWithNonZeroFrameComparisonWithDatePicker() {
+ let nonZeroFrame = CGRect(x: 1, y: 1, width: 1, height: 1)
+
+ let durationPicker = DurationPicker(frame: nonZeroFrame)
+ let datePicker = UIDatePicker(frame: nonZeroFrame)
+ datePicker.datePickerMode = .countDownTimer
+
+ XCTAssertEqual(
+ datePicker.frame.size,
+ durationPicker.frame.size)
+ XCTAssertEqual(
+ durationPicker.frame.size,
+ datePicker.intrinsicContentSize)
+ XCTAssertEqual(
+ datePicker.intrinsicContentSize,
+ durationPicker.intrinsicContentSize)
+ }
+
+ // MARK: - Event Handling
+
+ /// Tests that updating the `duration` property on the picker triggers the expected actions.
+ func testEventsOnDurationUpdate() {
+ let valueChangedExpectation = XCTestExpectation(description: "Received update via the valueChanged event")
+ let primaryActionExpectation = XCTestExpectation(description: "Received update via the primaryAction event")
+
+ let updatedDuration = TimeInterval(1)
+
+ durationPicker.addAction(
+ UIAction { [weak self] _ in
+ if self?.durationPicker.duration == updatedDuration {
+ valueChangedExpectation.fulfill()
+ }
+ },
+ for: .valueChanged)
+
+ durationPicker.addAction(
+ UIAction { [weak self] _ in
+ if self?.durationPicker.duration == updatedDuration {
+ primaryActionExpectation.fulfill()
+ }
+ },
+ for: .primaryActionTriggered)
+
+ durationPicker.duration = updatedDuration
+
+ wait(for: [
+ valueChangedExpectation,
+ primaryActionExpectation])
+ }
+
+ // MARK: - Duration Rounding
+
+ /// Tests that the rounding behavior of a duration picker with `hourMinute` mode matches that of a `UIDatePicker`.
+ func testDurationRoundingDatePickerComparison() {
+ durationPicker.pickerMode = .hourMinute
+ datePicker.countDownDuration = twoMinutes - 1
+ durationPicker.duration = twoMinutes - 1
+ XCTAssertEqual(
+ durationPicker.duration,
+ datePicker.countDownDuration)
+ }
+
+ /// Tests rounding to the nearest hour.
+ func testDurationRoundingHourMode() {
+ durationPicker.pickerMode = .hour
+ durationPicker.duration = twoHours - 1
+ XCTAssertEqual(
+ durationPicker.duration,
+ oneHour)
+ }
+
+ /// Tests rounding to the nearest minute.
+ func testDurationRoundingMinuteMode() {
+ durationPicker.pickerMode = .minute
+ durationPicker.duration = twoMinutes - 1
+ XCTAssertEqual(
+ durationPicker.duration,
+ oneMinute)
+ }
+
+ /// Tests rounding to the nearest second.
+ func testDurationRoundingSecondMode() {
+ durationPicker.pickerMode = .second
+ durationPicker.duration = twoSeconds - 0.5
+ XCTAssertEqual(
+ durationPicker.duration,
+ oneSecond)
+ }
+
+ // MARK: - Minimum & Maximum Durations
+
+ /// Tests that setting the minimum above the current duration will set the duration to the minimum.
+ func testMinimumDuration() {
+ durationPicker.duration = oneSecond
+ durationPicker.minimumDuration = twoSeconds
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoSeconds)
+ }
+
+ /// Tests that setting a duration below the minimum will set it to the minimum.
+ func testMinimumDurationAboveCurrentDuration() {
+ durationPicker.minimumDuration = twoSeconds
+ durationPicker.duration = oneSecond
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoSeconds)
+ }
+
+ /// Tests that setting a duration above the maximum will set it to the maximum.
+ func testMaximumDuration() {
+ durationPicker.maximumDuration = oneSecond
+ durationPicker.duration = twoSeconds
+ XCTAssertEqual(
+ durationPicker.duration,
+ oneSecond)
+ }
+
+ /// Tests that setting the maximum below the current duration will set the duration to the maximum.
+ func testMaximumDurationBelowCurrentDuration() {
+ durationPicker.duration = twoSeconds
+ durationPicker.maximumDuration = oneSecond
+ XCTAssertEqual(
+ durationPicker.duration,
+ oneSecond)
+ }
+
+ /// Tests that when setting the minimum above the absolute maximum duration, it will be ignored the current duration will remain unaffected.
+ func testMinimumDurationAboveAbsoluteMaximumDuration() {
+ let absoluteMaximumDuration = TimeUtils.absoluteMaximumDuration(forPickerMode: .hourMinuteSecond)
+ durationPicker.duration = oneSecond
+ durationPicker.minimumDuration = TimeInterval(absoluteMaximumDuration) + 1
+ XCTAssertEqual(durationPicker.duration, oneSecond)
+ }
+
+ /// Tests that when setting the maximum below zero, it will be ignored and the current duration will remain unaffected.
+ func testMaximumDurationBelowAbsoluteMinimumDuration() {
+ durationPicker.duration = oneSecond
+ durationPicker.maximumDuration = -1
+ XCTAssertEqual(durationPicker.duration, oneSecond)
+ }
+
+ /// Tests that both the minimum and maximum durations are ignored when the minimum is greater than the maximum.
+ func testMinimumDurationGreaterThanMaximumDuration() {
+ durationPicker.minimumDuration = twoSeconds
+ durationPicker.maximumDuration = oneSecond
+
+ let timeBelowMinimum = TimeInterval(0)
+ durationPicker.duration = 0
+ XCTAssertEqual(
+ durationPicker.duration,
+ timeBelowMinimum)
+
+ let timeAboveMaximum = TimeInterval(3)
+ durationPicker.duration = timeAboveMaximum
+ XCTAssertEqual(
+ durationPicker.duration,
+ timeAboveMaximum)
+ }
+
+ /// Tests whether the duration property is propertly rounded when the picker has a second interval with a non-divisible minimum duration.
+ func testMinimumDurationSecondIntervalRounding() {
+ durationPicker.pickerMode = .second
+ durationPicker.secondInterval = 2
+ durationPicker.minimumDuration = oneSecond
+ durationPicker.duration = 0
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoSeconds)
+ }
+
+ /// Tests whether the duration property is propertly rounded when the picker has a minute interval with a non-divisible minimum duration.
+ func testMinimumDurationMinuteIntervalRounding() {
+ durationPicker.pickerMode = .minute
+ durationPicker.minuteInterval = 2
+ durationPicker.minimumDuration = oneMinute
+ durationPicker.duration = 0
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoMinutes)
+ }
+
+ /// Tests whether the duration property is propertly rounded when the picker has an hour interval with a non-divisible minimum duration.
+ func testMinimumDurationHourIntervalRounding() {
+ durationPicker.pickerMode = .hour
+ durationPicker.hourInterval = 2
+ durationPicker.minimumDuration = oneHour
+ durationPicker.duration = 0
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoHours)
+ }
+
+ /// Tests whether the duration property is propertly rounded when the picker has a second interval with a non-divisible maximum duration.
+ func testMaximumDurationSecondIntervalRounding() {
+ durationPicker.pickerMode = .second
+ durationPicker.secondInterval = 2
+ durationPicker.maximumDuration = threeSeconds
+ durationPicker.duration = fourSeconds
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoSeconds)
+ }
+
+ /// Tests whether the duration property is properly rounded when the picker has a minute interval with a non-divisible maximum duration.
+ func testMaximumDurationMinuteIntervalRounding() {
+ durationPicker.pickerMode = .minute
+ durationPicker.minuteInterval = 2
+ durationPicker.maximumDuration = threeMinutes
+ durationPicker.duration = fourMinutes
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoMinutes)
+ }
+
+ /// Tests whether the duration property is properly rounded when the picker has an hour interval with a non-divisible maximum duration.
+ func testMaximumDurationHourIntervalRounding() {
+ durationPicker.pickerMode = .hour
+ durationPicker.hourInterval = 2
+ durationPicker.maximumDuration = threeHours
+ durationPicker.duration = fourHours
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoHours)
+ }
+
+ /// Tests whether the duration property is properly rounded (based on the next component's intervals) when the minimum duration does not allow for any values in the current component.
+ func testMinimumDurationRoundingToNextComponentWithInterval() {
+ durationPicker.pickerMode = .minuteSecond
+ durationPicker.minuteInterval = 2
+ durationPicker.minimumDuration = oneMinute
+ XCTAssertEqual(
+ durationPicker.duration,
+ twoMinutes)
+ }
+
+ /// Tests whether the duration property is properly rounded (based on the previous component's intervals) when the maximum duration does not allow for any values in the current component.
+ func testMaximumDurationRoundingToPreviousComponentWithInterval() {
+ durationPicker.pickerMode = .minuteSecond
+ durationPicker.duration = twoMinutes
+ durationPicker.secondInterval = 2
+ durationPicker.maximumDuration = oneMinute - 1
+ XCTAssertEqual(
+ durationPicker.duration,
+ fiftyEightSeconds)
+ }
+
+ // MARK: - Picker Mode
+
+ /// Tests whether setting the picker mode rounds the duration to the nearest allowed value.
+ func testPickerModeUpdate() {
+ durationPicker.duration = oneMinute + thirtySeconds
+ durationPicker.pickerMode = .minute
+ XCTAssertEqual(durationPicker.duration, oneMinute)
+ }
+
+ // MARK: - Hour Interval
+
+ /// Tests whether setting the hour interval to a negative value resets it to 1.
+ func testHourIntervalNegative() {
+ durationPicker.hourInterval = -1
+ XCTAssertEqual(durationPicker.hourInterval, 1)
+ }
+
+ /// Tests whether setting the hour interval to zero resets it to 1.
+ func testHourIntervalZero() {
+ durationPicker.hourInterval = 0
+ XCTAssertEqual(durationPicker.hourInterval, 1)
+ }
+
+ /// Tests whether setting the hour interval to a value larger than 12 resets it to 1.
+ func testHourIntervalLargerThanTwelve() {
+ durationPicker.hourInterval = 24
+ XCTAssertEqual(durationPicker.hourInterval, 1)
+ }
+
+ /// Tests whether setting the hour interval to a non-divisible value resets it to 1.
+ func testHourIntervalNonDivisibility() {
+ durationPicker.hourInterval = 13
+ XCTAssertEqual(durationPicker.hourInterval, 1)
+ }
+
+ /// Tests whether the current duration is rounded down to the nearest allowed hour when setting an hour interval.
+ func testHourInterval() {
+ durationPicker.pickerMode = .hour
+ durationPicker.duration = oneHour
+ durationPicker.hourInterval = 2
+ XCTAssertEqual(durationPicker.duration, 0)
+ }
+
+ /// Tests whether setting a duration which is not a multiple of the hour interval rounds down to the nearest allowed hour.
+ func testHourIntervalNonMultipleDuration() {
+ durationPicker.pickerMode = .hour
+ durationPicker.hourInterval = 12
+ durationPicker.duration = twelveHours + 1
+ XCTAssertEqual(durationPicker.duration, twelveHours)
+ }
+
+ // MARK: Minute Interval Date Picker Comparison
+
+ /// Tests whether the rounding behavior of a duration picker when setting a negative minute interval matches that of a `UIDatePicker`.
+ func testMinuteIntervalDatePickerComparisonNegative() {
+ durationPicker.pickerMode = .hourMinute
+ durationPicker.minuteInterval = -1
+ datePicker.minuteInterval = -1
+ XCTAssertEqual(
+ durationPicker.secondInterval,
+ datePicker.minuteInterval)
+ }
+
+ /// Tests whether the rounding behavior of a duration picker when setting a zero minute interval matches that of a `UIDatePicker`.
+ func testMinuteIntervalDatePickerComparisonZero() {
+ durationPicker.pickerMode = .hourMinute
+ durationPicker.minuteInterval = 0
+ datePicker.minuteInterval = 0
+ XCTAssertEqual(
+ durationPicker.secondInterval,
+ datePicker.minuteInterval)
+ }
+
+ /// Tests whether the rounding behavior of a duration picker when setting a minute interval larger than 30 matches that of a `UIDatePicker`.
+ func testMinuteIntervalDatePickerComparisonLargerThirty() {
+ durationPicker.pickerMode = .hourMinute
+ durationPicker.minuteInterval = 60
+ datePicker.minuteInterval = 60
+ XCTAssertEqual(
+ durationPicker.secondInterval,
+ datePicker.minuteInterval)
+ }
+
+ /// Tests whether the rounding behavior of a duration picker when setting a non-divisible minute interval matches that of a `UIDatePicker`.
+ func testMinuteIntervalDatePickerComparisonNonDivisible() {
+ durationPicker.pickerMode = .hourMinute
+ durationPicker.minuteInterval = 59
+ datePicker.minuteInterval = 59
+ XCTAssertEqual(
+ durationPicker.secondInterval,
+ datePicker.minuteInterval)
+ }
+
+ /// Tests whether the rounding behavior of the duration property is the same as a `UIDatePicker` when setting a minute interval.
+ func testMinuteIntervalDatePickerComparison() {
+ durationPicker.pickerMode = .hourMinute
+ durationPicker.minuteInterval = 15
+ durationPicker.duration = thirtyMinutes
+ datePicker.minuteInterval = 15
+ datePicker.countDownDuration = thirtyMinutes
+
+ XCTAssertEqual(
+ durationPicker.duration,
+ datePicker.countDownDuration)
+ }
+
+ /// Tests whether the behavior of a duration picker matches that of a `UIDatePicker` when setting a duration which is not a multiple of the minute interval.
+ func testMinuteIntervalNonMultipleDurationDatePickerComparison() {
+ durationPicker.pickerMode = .hour
+ durationPicker.hourInterval = 12
+ durationPicker.duration = twelveHours + 1
+ XCTAssertEqual(
+ durationPicker.duration,
+ twelveHours)
+ }
+
+ // MARK: - Minute Interval
+
+ /// Tests whether setting the minute interval to a negative value resets it to 1.
+ func testMinuteIntervalNegative() {
+ durationPicker.minuteInterval = -1
+ XCTAssertEqual(durationPicker.minuteInterval, 1)
+ }
+
+ /// Tests whether setting the minute interval to zero resets it to 1.
+ func testMinuteIntervalZero() {
+ durationPicker.minuteInterval = 0
+ XCTAssertEqual(durationPicker.minuteInterval, 1)
+ }
+
+ /// Tests whether setting the minute interval to a value larger than 30 resets it to 1.
+ func testMinuteIntervalLargerThanThirty() {
+ durationPicker.minuteInterval = 60
+ XCTAssertEqual(durationPicker.minuteInterval, 1)
+ }
+
+ /// Tests whether setting a minute interval properly rounds the current duration down to the nearest allowed minute.
+ func testMinuteIntervalNonDivisibility() {
+ durationPicker.minuteInterval = 59
+ XCTAssertEqual(durationPicker.minuteInterval, 1)
+ }
+
+ /// Tests whether the current duration is rounded down to the nearest allowed minute when setting a minute interval.
+ func testMinuteInterval() {
+ durationPicker.pickerMode = .minute
+ durationPicker.duration = oneMinute
+ durationPicker.minuteInterval = 2
+ XCTAssertEqual(durationPicker.duration, 0)
+ }
+
+ /// Tests whether setting a duration which is not a multiple of the minute interval rounds down to the nearest allowed minute.
+ func testMinuteIntervalNonMultipleDuration() {
+ durationPicker.pickerMode = .minute
+ durationPicker.minuteInterval = 15
+ durationPicker.duration = thirtyMinutes + 1
+ XCTAssertEqual(durationPicker.duration, thirtyMinutes)
+ }
+
+ // MARK: Second Interval
+
+ /// Tests whether setting the second interval to a negative value resets it to 1.
+ func testSecondIntervalNegative() {
+ durationPicker.secondInterval = -1
+ XCTAssertEqual(durationPicker.secondInterval, 1)
+ }
+
+ /// Tests whether setting the second interval to zero resets it to 1.
+ func testSecondIntervalZero() {
+ durationPicker.secondInterval = 0
+ XCTAssertEqual(durationPicker.secondInterval, 1)
+ }
+
+ /// Tests whether setting a second interval to a value larger than 30 resets it to 1.
+ func testSecondIntervalLargerThanThirty() {
+ durationPicker.secondInterval = 60
+ XCTAssertEqual(durationPicker.secondInterval, 1)
+ }
+
+ /// Tests whether setting a second interval properly rounds the current duration down to the nearest allowed second.
+ func testSecondIntervalNonDivisibility() {
+ durationPicker.secondInterval = 59
+ XCTAssertEqual(durationPicker.secondInterval, 1)
+ }
+
+ /// Tests whether the current duration is rounded down to the nearest allowed second when setting a second interval.
+ func testSecondInterval() {
+ durationPicker.pickerMode = .second
+ durationPicker.duration = oneSecond
+ durationPicker.secondInterval = 2
+ XCTAssertEqual(durationPicker.duration, 0)
+ }
+
+ /// Tests whether setting a duration which is not a multiple of the second interval rounds down to the nearest allowed second.
+ func testSecondIntervalNonMultipleDuration() {
+ durationPicker.pickerMode = .second
+ durationPicker.secondInterval = 15
+ durationPicker.duration = thirtySeconds + 1
+ XCTAssertEqual(durationPicker.duration, thirtySeconds)
+ }
+}
diff --git a/Tests/DurationPickerTests/TestConstants.swift b/Tests/DurationPickerTests/TestConstants.swift
new file mode 100644
index 0000000..8163389
--- /dev/null
+++ b/Tests/DurationPickerTests/TestConstants.swift
@@ -0,0 +1,61 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+@testable import DurationPicker
+import Foundation
+
+// MARK: - Integers
+
+let oneHourInt = TimeUtils.seconds(fromHours: 1)
+let twoHoursInt = TimeUtils.seconds(fromHours: 2)
+let thirteenHoursInt = TimeUtils.seconds(fromHours: 13)
+let twentyThreeHoursInt = TimeUtils.seconds(fromHours: 23)
+let twentyFourHoursInt = TimeUtils.seconds(fromHours: 24)
+
+let oneMinuteInt = TimeUtils.seconds(minutes: 1)
+let twoMinutesInt = TimeUtils.seconds(minutes: 2)
+let fiftyNineMinutesInt = TimeUtils.seconds(minutes: 59)
+
+let oneSecondInt = TimeUtils.seconds(seconds: 1)
+let twoSecondsInt = TimeUtils.seconds(seconds: 2)
+let fiftyNineSecondsInt = TimeUtils.seconds(seconds: 59)
+
+// MARK: - Time Intervals
+
+let oneHour = TimeInterval(oneHourInt)
+let twoHours = TimeInterval(twoHoursInt)
+let threeHours = TimeInterval(TimeUtils.seconds(fromHours: 3))
+let fourHours = TimeInterval(TimeUtils.seconds(fromHours: 4))
+let twelveHours = TimeInterval(TimeUtils.seconds(fromHours: 12))
+
+let oneMinute = TimeInterval(oneMinuteInt)
+let twoMinutes = TimeInterval(twoMinutesInt)
+let threeMinutes = TimeInterval(TimeUtils.seconds(minutes: 3))
+let fourMinutes = TimeInterval(TimeUtils.seconds(minutes: 4))
+let thirtyMinutes = TimeInterval(TimeUtils.seconds(minutes: 30))
+
+let oneSecond = TimeInterval(oneSecondInt)
+let twoSeconds = TimeInterval(twoSecondsInt)
+let threeSeconds = TimeInterval(TimeUtils.seconds(seconds: 3))
+let fourSeconds = TimeInterval(TimeUtils.seconds(seconds: 4))
+let thirtySeconds = TimeInterval(TimeUtils.seconds(seconds: 30))
+let fiftyEightSeconds = TimeInterval(TimeUtils.seconds(seconds: 58))
diff --git a/Tests/DurationPickerTests/TimeComponentsTests.swift b/Tests/DurationPickerTests/TimeComponentsTests.swift
new file mode 100644
index 0000000..299c705
--- /dev/null
+++ b/Tests/DurationPickerTests/TimeComponentsTests.swift
@@ -0,0 +1,864 @@
+/// MIT License
+///
+/// Copyright (c) 2023 Mac Gallagher
+///
+/// Permission is hereby granted, free of charge, to any person obtaining a copy
+/// of this software and associated documentation files (the "Software"), to deal
+/// in the Software without restriction, including without limitation the rights
+/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+/// copies of the Software, and to permit persons to whom the Software is
+/// furnished to do so, subject to the following conditions:
+///
+/// The above copyright notice and this permission notice shall be included in all
+/// copies or substantial portions of the Software.
+///
+/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+/// SOFTWARE.
+
+@testable import DurationPicker
+import XCTest
+
+/// Tests which cover the functionality of the `TimeComponents` class.
+///
+/// We test the following functionality:
+/// - Rounding mode
+/// - PIcker mode
+/// - Minimum/maximum
+/// - Intervals
+final class TimeComponentsTests: XCTestCase {
+
+ // MARK: - General
+
+ /// Tests that the components of a negative duration are `(0, 0, 0)`.
+ func testNegativeSeconds() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: -1),
+ expectedComponents)
+ }
+
+ /// Tests that the components of a negative duration when rounding up are `(0, 0, 0)`.
+ func testNegativeSecondsRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: -1,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:00 are `(0, 0, 0)`.
+ func testZeroSeconds() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: 0),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:00 when rounding up are `(0, 0, 0)`.
+ func testZeroSecondsRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: 0,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:01 are `(0, 0, 1)`.
+ func testOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:01 when rounding up are `(0, 0, 1)`.
+ func testOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:01:00 are `(0, 1, 0)`.
+ func testOneMinute() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: oneMinuteInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:01:00 when rounding up are `(0, 1, 0)`.
+ func testOneMinuteRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneMinuteInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:02:01 are `(0, 2, 1)`.
+ func testTwoMinutesAndOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: twoMinutesInt + oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:02:01 when rounding up are `(0, 2, 1)`.
+ func testTwoMinutesAndOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoMinutesInt + oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 are `(1, 0, 0)`.
+ func testOneHour() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: oneHourInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 when rounding up are `(1, 0, 0)`.
+ func testOneHourRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:01 are `(1, 0, 1)`.
+ func testOneHourAndOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: oneHourInt + oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:01 when rounding up are `(1, 0, 1)`.
+ func testOneHourAndOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt + oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:02:01 are `(1, 2, 1)`.
+ func testOneHourTwoMinutesAndOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: oneHourInt + twoMinutesInt + oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:02:01 when rounding up are `(1, 2, 1)`.
+ func testOneHourTwoMinutesAndOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt + twoMinutesInt + oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:00:01 are `(2, 0, 1)`.
+ func testTwoHoursAndOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: twoHoursInt + oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:00:01 when rounding up are `(2, 0, 1)`.
+ func testTwoHoursAndOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoHoursInt + oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:02:01 are `(2, 2, 1)`.
+ func testTwoHoursTwoMinutesAndOneSecond() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: twoHoursInt + twoMinutesInt + oneSecondInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:02:01 when rounding up are `(2, 2, 1)`.
+ func testTwoHoursTwoMinutesAndOneSecondRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoHoursInt + twoMinutesInt + oneSecondInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 2,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 24:00:00 are `(23, 59, 59)`.
+ func testTwentyFourHours() {
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 23,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ TimeComponents.components(fromDuration: twentyFourHoursInt),
+ expectedComponents)
+ }
+
+ /// Tests that the components of 24:00:00 when rounding up are `(23, 59, 59)`.
+ ///
+ /// - Note: This is a special case where we round _down_ to the nearest allowed value even though the rounding mode is `up`.
+ func testTwentyFourHoursRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 23,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Minimum & Maximum
+
+ /// Tests that the components of 1:00:00 with minimum duration 1:00:01 are `(1, 0, 1)`.
+ func testMinimum() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ minimumDuration: oneHourInt + 1,
+ maximumDuration: nil)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 with minimum duration 1:00:01 and rounding mode up are `(1, 0, 1)`.
+ func testMinimumWithRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ minimumDuration: oneHourInt + 1,
+ maximumDuration: nil,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 1)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:01 with minimum duration 0:00:01 and minute mode are `(0, 1, 0)`.
+ func testMinimumOneSecondWithMinuteMode() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: 1,
+ pickerMode: .hourMinute,
+ minimumDuration: 1,
+ maximumDuration: nil)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+ /// Tests that the components of 0:00:01 with minimum duration 0:00:01 and minute mode are `(0, 1, 0)`.
+ func testMinimumOneSecondWithMinuteModeAndRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: 1,
+ pickerMode: .hourMinute,
+ minimumDuration: 1,
+ maximumDuration: nil,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 with maximum duration 0:59:59 are `(0, 59, 59)`.
+ func testMaximum() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ minimumDuration: nil,
+ maximumDuration: oneHourInt - 1)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 with maximum duration 0:59:59 and rounding mode up are `(0, 59, 59)`.
+ func testMaximumWithRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ minimumDuration: nil,
+ maximumDuration: oneHourInt - 1,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:01:01 with maximum duration 0:01:01 and minute mode are `(0, 1, 0)`.
+ func testMaximumOneMinuteAndOneSecondWithMinuteMode() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: 61,
+ pickerMode: .minute,
+ minimumDuration: nil,
+ maximumDuration: 61)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:01:01 with maximum duration 0:01:01 and minute mode are `(0, 1, 0)`.
+ func testMaximumOneMinuteAndOneSecondWithMinuteModeAndRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: 61,
+ pickerMode: .minute,
+ minimumDuration: nil,
+ maximumDuration: 61,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 when the minimum is larger than the maximum are `(1, 0, 0)`, i.e., the minimum and maximum values are ignored.
+ func testMinimumLargerThanMaximum() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ minimumDuration: oneHourInt + 1,
+ maximumDuration: oneHourInt - 1,
+ roundingMode: .up)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Hour Interval
+
+ /// Tests that the components of 1:00:00 with a two-hour interval are `(0, 59, 59)`.
+ func testOneHourWithTwoHourInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 1:00:00 with a two-hour interval when rounding up are `(2, 0, 0)`.
+ func testOneHourWithTwoHourIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneHourInt,
+ roundingMode: .up,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:00:00 with a two-hour interval are `(2, 0, 0)`.
+ func testTwoHoursWithTwoHourInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoHoursInt,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 2:00:00 with a two-hour interval when rounding up are `(2, 0, 0)`.
+ func testTwoHoursWithTwoHourIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoHoursInt,
+ roundingMode: .up,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 2,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 23:00:00 with a two-hour interval when rounding up are `(22, 59, 59)`.
+ ///
+ /// - Note: This is a special case where we round _down_ to the nearest allowed value even though the rounding mode is `up`.
+ func testTwentyThreeHoursWithTwoHourIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyThreeHoursInt,
+ roundingMode: .up,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 22,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 23:00:00 with a two-hour interval and `hour` mode when rounding up are `(22, 0, 0)`
+ ///
+ /// - Note: This is a special case where we round _down_ to the nearest allowed value even though the rounding mode is `up`.
+ func testTwentyThreeHoursWithTwoHourIntervalRoundingModeUpHourMode() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyThreeHoursInt,
+ pickerMode: .hour,
+ roundingMode: .up,
+ hourInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 22,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Minute Interval
+
+ /// Tests that the components of 0:01:00 with a two-minute interval are `(0, 0, 59)`.
+ func testOneMinuteWithTwoMinuteInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneMinuteInt,
+ minuteInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:01:00 with a two-minute interval when rounding up are `(0, 2, 0)`.
+ func testOneMinuteWithTwoMinuteIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneMinuteInt,
+ roundingMode: .up,
+ minuteInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 2,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:02:00 with a two-minute interval are `(0, 2, 0)`.
+ func testTwoMinutesWithTwoMinuteInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoMinutesInt,
+ minuteInterval: 2)
+
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 2,
+ uncheckedSecond: 0)
+
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:02:00 with a two-minute interval when rounding up are `(0, 2, 0)`.
+ func testTwoMinutesWithTwoMinuteIntervalWithRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoMinutesInt,
+ roundingMode: .up,
+ minuteInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 2,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:59:00 with a thirty-minute interval when rounding up are `(1, 0, 0)`.
+ func testFiftyNineMinutesWithTwoMinuteIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: fiftyNineMinutesInt,
+ roundingMode: .up,
+ minuteInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 1,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:59:00 with a two-minute interval and `minute` mode when rounding up are `(0, 58, 0)`
+ ///
+ /// - Note: This is a special case where we round _down_ to the nearest allowed value even though the rounding mode is `up`.
+ func testFiftyNineMinutesWithTwoMinuteIntervalRoundingModeUpMinuteMode() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: fiftyNineMinutesInt,
+ pickerMode: .minute,
+ roundingMode: .up,
+ minuteInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 58,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Second Interval
+
+ /// Tests that the components of 0:00:01 with a two-second interval are `(0, 0, 0)`.
+ func testOneSecondWithTwoSecondInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneSecondInt,
+ secondInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:01 with a two-second interval when rounding up are `(0, 0, 2)`.
+ func testOneSecondWithTwoSecondIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: oneSecondInt,
+ roundingMode: .up,
+ secondInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 2)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:02 with a two-second interval are `(0, 0, 2)`.
+ func testTwoSecondsWithTwoSecondInterval() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoSecondsInt,
+ secondInterval: 2)
+
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 2)
+
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:02 with a two-second interval when rounding up are `(0, 0, 2)`.
+ func testTwoSecondsWithTwoSecondIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twoSecondsInt,
+ roundingMode: .up,
+ secondInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 2)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:59 with a two-second interval when rounding up are `(0, 1, 0)`.
+ func testFiftyNineSecondsWithTwoSecondIntervalRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: fiftyNineSecondsInt,
+ roundingMode: .up,
+ secondInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 1,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 0:00:59 with a two-second interval and `second` mode when rounding up are `(0, 0, 58)`
+ ///
+ /// - Note: This is a special case where we round _down_ to the nearest allowed value even though the rounding mode is `up`.
+ func testFiftyNineSecondsWithTwoSecondIntervalRoundingModeUpSecondMode() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: fiftyNineSecondsInt,
+ pickerMode: .second,
+ roundingMode: .up,
+ secondInterval: 2)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 58)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Mixed Intervals
+
+ /// Tests that the components of 13:00:00 with six-hour, fifteen-minute, and ten-second intervals are `(12, 30, 50)`.
+ func testMixedIntervals() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: thirteenHoursInt,
+ hourInterval: 6,
+ minuteInterval: 15,
+ secondInterval: 10)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 12,
+ uncheckedMinute: 45,
+ uncheckedSecond: 50)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the components of 13:00:00 with six-hour, fifteen-minute, and ten-second intervals are `(18, 0, 0)`.
+ func testMixedIntervalsRoundingModeUp() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: thirteenHoursInt,
+ roundingMode: .up,
+ hourInterval: 6,
+ minuteInterval: 15,
+ secondInterval: 10)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 18,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ // MARK: - Picker Mode
+
+ /// Tests that none of the components are zeroed with `hourMinuteSecond` mode.
+ func testHourMinuteSecondModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .hourMinuteSecond)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 23,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the second component is zeroed with `hourMinute` mode.
+ func testHourMinuteModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .hourMinute)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 23,
+ uncheckedMinute: 59,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the second and minute components are zeroed with `hour` mode.
+ func testHourModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .hour)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 23,
+ uncheckedMinute: 0,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the hour component is zeroed with `minuteSecond` mode.
+ func testMinuteSecondModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .minuteSecond)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 59,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the hour and second components are zeroed with `minute` mode.
+ func testMinuteModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .minute)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 59,
+ uncheckedSecond: 0)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+
+ /// Tests that the hour and minute components are zeroed with `second` mode.
+ func testSecondModeZeroingBehavior() {
+ let actualComponents = TimeComponents.components(
+ fromDuration: twentyFourHoursInt,
+ pickerMode: .second)
+ let expectedComponents = TimeComponents(
+ uncheckedHour: 0,
+ uncheckedMinute: 0,
+ uncheckedSecond: 59)
+ XCTAssertEqual(
+ actualComponents,
+ expectedComponents)
+ }
+}