diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..251778b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,70 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+
+# Keystore files
+*.jks
+*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+lint/reports/
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1fc885a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,309 @@
+# TimeRangePicker
+[![Maven Central](https://maven-badges.herokuapp.com/maven-central/nl.joery.timerangepicker/timerangepicker/badge.svg)](https://maven-badges.herokuapp.com/maven-central/nl.joery.timerangepicker/timerangepicker)
+[![API](https://img.shields.io/badge/API-14%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=14)
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+
+A customizable, easy-to-use, and functional circular time range picker library for Android. Use this library to mimic Apple's iOS or Samsung's bedtime picker.
+
+## Examples
+
+
+## Playground app
+
+
+Download the playground app: [demo.apk](./art/demo.apk). In this app, you can try out all features and even generate XML layouts.
+
+## Contents
+- [Getting started](#getting-started)
+- [Managing picker programmatically](#managing-picker-programmatically)
+- [Configuration](#configuration)
+
+## Getting started
+
+
+This library is available on Maven Central, add the following dependency to your build.gradle:
+```gradle
+implementation 'nl.joery.timerangepicker:timerangepicker:1.0.0'
+```
+Define `TimeRangePicker` in your XML layout with custom attributes. See the [Configuration](#configuration) section for more information.
+```xml
+
+```
+
+Get notified when the time or duration changes:
+```kotlin
+picker.setOnTimeChangeListener(object : TimeRangePicker.OnTimeChangeListener {
+ override fun onStartTimeChange(startTime: TimeRangePicker.Time) {
+ Log.d("TimeRangePicker", "Start time: " + startTime)
+ }
+
+ override fun onEndTimeChange(endTime: TimeRangePicker.Time) {
+ Log.d("TimeRangePicker", "End time: " + endTime.hour)
+ }
+
+ override fun onDurationChange(duration: TimeRangePicker.TimeDuration) {
+ Log.d("TimeRangePicker", "Duration: " + duration.hour)
+ }
+})
+```
+
+## Managing picker programmatically
+### Managing time
+Examples of how to set and retrieve start time programmatically, identical properties are available for the end time.
+
+```kotlin
+// Set new time with 'Time' object to 12:00
+picker.startTime = TimeRangePicker.Time(12, 0)
+// Set new time by minutes
+picker.startTimeMinutes = 320
+```
+
+Time
+When retrieving the start or end time, the library will provide a `TimeRangePicker.Time` object.
+- Use `time.hour`, `time.minute` or `time.totalMinutes` to retrieve literal time.
+- Use `time.calendar` to retrieve a `java.util.Calendar` object.
+- Use `time.localTime` to retrieve a `java.time.LocalTime` object. (Available since API 26)
+
+### Managing duration
+When retrieving the duration between the start and end time, the library will provide a `TimeRangePicker.Duration` object.
+- Use `duration.hour`, `duration.minute` or `duration.durationMinutes` to retrieve literal duration.
+- Use `duration.classicDuration` to retrieve a `javax.xml.datatype.Duration` object. (Available since API 8)
+- Use `duration.duration` to retrieve a `java.time.Duration` object. (Available since API 26)
+
+### Listening for starting and stopping of dragging
+This listener is called whenever a user starts or stops dragging. It will also provide which thumb the user was dragging: start, end, or both thumbs. You can return false in the `ònDragStart` method to prevent the user from dragging a thumb.
+
+```kotlin
+picker.setOnDragChangeListener(object : TimeRangePicker.OnDragChangeListener {
+ override fun onDragStart(thumb: TimeRangePicker.Thumb): Boolean {
+ // Do something on start dragging
+ return true // Return false to disallow the user from dragging a handle.
+ }
+
+ override fun onDragStop(thumb: TimeRangePicker.Thumb) {
+ // Do something on stop dragging
+ }
+})
+```
+
+## Configuration
+The attributes listed below can be used to configure the look and feel of the picker. Note that all of these values can also be set programmatically using the properties.
+### Time
+
+
+ Attribute |
+ Description |
+ Default |
+
+
+ trp_startTime |
+ Set the start time by providing a time with format h:mm. |
+ 0:00 |
+
+
+ trp_startTimeMinutes |
+ Set the start time by providing minutes between 0 and 1440 (24 hours). |
+ 0 |
+
+
+ trp_endTime |
+ Set the end time by providing a time with format h:mm. |
+ 8:00 |
+
+
+ trp_endTimeMinutes |
+ Set the end time by providing minutes between 0 and 1440 (24 hours). |
+ 480 |
+
+
+ trp_minDuration |
+ Set the minimum selectable duration by providing a duration with format h:mm. |
+ |
+
+
+ trp_maxDuration |
+ Set the maximum selectable duration by providing a duration with format h:mm. |
+ |
+
+
+ trp_maxDurationMinutes |
+ Set the maximum selectable duration by providing minutes between 0 and 1440 (24 hours). |
+ 480 |
+
+
+ trp_minDurationMinutes |
+ Set the minimum selectable duration by providing minutes between 0 and 1440 (24 hours). |
+ 0 |
+
+
+ trp_stepTimeMinutes |
+ Determines at what interval the time should be rounded. Setting it to a less accurate number (e.g. 10 minutes) makes it easier for a user to select his desired time. |
+ 10 |
+
+
+
+### Slider
+
+
+ Attribute |
+ Description |
+ Default |
+
+
+ trp_sliderWidth |
+ The width of the slider wheel. |
+ 8dp |
+
+
+ trp_sliderColor |
+ The background color of the slider wheel. |
+ #E1E1E1 |
+
+
+
+ trp_sliderRangeColor |
+ The color of the active part of the slider wheel. |
+ ?android:colorPrimary |
+
+
+
+ trp_sliderRangeGradientStart |
+ Set the starting gradient color of the active part of the slider wheel.
Please note that both trp_sliderRangeGradientStart and trp_sliderRangeGradientEnd need to be configured.
Tip: Set the thumbColor to transparent to mimic the Apple iOS slider. |
+ |
+
+
+
+ trp_sliderRangeGradientStart |
+ Optional for gradient: set the middle gradient color of the active part of the slider wheel. |
+ |
+
+
+
+ trp_sliderRangeGradientEnd |
+ Set the ending gradient color of the active part of the slider wheel.
Please note that both trp_sliderRangeGradientStart and trp_sliderRangeGradientEnd need to be configured. |
+ |
+
+
+
+### Thumb
+
+
+ Attribute |
+ Description |
+ Default |
+
+
+ trp_thumbIconStart |
+ Set the start thumb icon. |
+ |
+
+
+ trp_thumbIconEnd |
+ Set the end thumb icon. |
+ |
+
+
+ trp_thumbSize |
+ The size of both the starting and ending thumb. |
+ 28dp |
+
+
+ trp_thumbSizeActiveGrow |
+ The amount of growth of the size when a thumb is being dragged. |
+ 1.2 |
+
+
+ trp_thumbColor |
+ The background color of the thumbs. |
+ ?android:colorPrimary |
+
+
+ trp_thumbIconColor |
+ The color (tint) of the icons inside the thumbs. |
+ white |
+
+
+ trp_thumbIconSize |
+ The size of the thumb icons. |
+ 24dp |
+
+
+
+### Clock
+
+
+ Attribute |
+ Description |
+ Default |
+
+
+ trp_clockVisible |
+ Whether the clock face in the middle should be visible. |
+ true |
+
+
+ trp_clockFace |
+ There a two different clock faces (appearance of the inner clock) you can use, both mimicking the Clock apps:
+ APPLE
+
+ SAMSUNG
+
+ |
+ APPLE |
+
+
+ trp_clockLabelSize |
+ The text size of the hour labels in the clock (1, 2, 3, etc.). This value is recommended to be set as scale-independent pixels (sp). |
+ 16sp |
+
+
+ trp_clockLabelColor |
+ Set the text color of the hour labels in the clock. |
+ ?android:textColorPrimary |
+
+
+ trp_clockIndicatorColor |
+ Set the color of the small time indicator lines in the clock. |
+ ?android:textColorPrimary |
+
+
+
+## Credits
+- Samsung's and Apple's Clock app have been used for inspiration, as they both implement this picker differently.
+
+## License
+```
+MIT License
+
+Copyright (c) 2021 Joery Droppers (https://github.com/Droppers)
+
+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/art/demo.apk b/art/demo.apk
new file mode 100644
index 0000000..78eda01
Binary files /dev/null and b/art/demo.apk differ
diff --git a/art/example-2.png b/art/example-2.png
new file mode 100644
index 0000000..94b5b27
Binary files /dev/null and b/art/example-2.png differ
diff --git a/art/example-3.png b/art/example-3.png
new file mode 100644
index 0000000..3b9a7e4
Binary files /dev/null and b/art/example-3.png differ
diff --git a/art/example-animated.gif b/art/example-animated.gif
new file mode 100644
index 0000000..d75a820
Binary files /dev/null and b/art/example-animated.gif differ
diff --git a/art/face-apple.png b/art/face-apple.png
new file mode 100644
index 0000000..3549a4a
Binary files /dev/null and b/art/face-apple.png differ
diff --git a/art/face-samsung.png b/art/face-samsung.png
new file mode 100644
index 0000000..a8383e9
Binary files /dev/null and b/art/face-samsung.png differ
diff --git a/art/getting-started.png b/art/getting-started.png
new file mode 100644
index 0000000..e1f24f7
Binary files /dev/null and b/art/getting-started.png differ
diff --git a/art/playground-demo.png b/art/playground-demo.png
new file mode 100644
index 0000000..c4eb47d
Binary files /dev/null and b/art/playground-demo.png differ
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..f66eb3d
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,30 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ ext.kotlin_version = "1.5.10"
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:4.2.1'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath 'com.vanniktech:gradle-maven-publish-plugin:0.15.1'
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ plugins.withId("com.vanniktech.maven.publish") {
+ mavenPublish {
+ sonatypeHost = "S01"
+ }
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/demo/.gitignore b/demo/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/demo/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/demo/build.gradle b/demo/build.gradle
new file mode 100644
index 0000000..e858505
--- /dev/null
+++ b/demo/build.gradle
@@ -0,0 +1,46 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
+
+android {
+ compileSdkVersion 30
+
+ defaultConfig {
+ applicationId "nl.joery.demo.timerangepicker"
+ minSdkVersion 21
+ targetSdkVersion 30
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+ implementation fileTree(dir: "libs", include: ["*.jar"])
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation 'androidx.core:core-ktx:1.5.0'
+ implementation 'androidx.appcompat:appcompat:1.3.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
+
+ implementation project(path: ':timerangepicker')
+
+ testImplementation 'junit:junit:4.12'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
+
+ implementation 'com.google.android.material:material:1.3.0'
+ implementation 'com.jaredrummler:colorpicker:1.1.0'
+ implementation 'nl.joery.animatedbottombar:library:1.1.0'
+}
\ No newline at end of file
diff --git a/demo/proguard-rules.pro b/demo/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/demo/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/demo/src/androidTest/java/nl/joery/demo/timerangepicker/ExampleInstrumentedTest.kt b/demo/src/androidTest/java/nl/joery/demo/timerangepicker/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..93065d1
--- /dev/null
+++ b/demo/src/androidTest/java/nl/joery/demo/timerangepicker/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package nl.joery.demo.timerangepicker
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("nl.joery.demo.timerangepicker", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7c34c17
--- /dev/null
+++ b/demo/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/ic_launcher-playstore.png b/demo/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..0e6e1fb
Binary files /dev/null and b/demo/src/main/ic_launcher-playstore.png differ
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/ExampleActivity.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/ExampleActivity.kt
new file mode 100644
index 0000000..8aff534
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/ExampleActivity.kt
@@ -0,0 +1,149 @@
+package nl.joery.demo.timerangepicker
+
+import android.annotation.SuppressLint
+import android.content.res.Resources
+import android.graphics.Color
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.android.synthetic.main.activity_example.*
+import nl.joery.animatedbottombar.AnimatedBottomBar
+import nl.joery.timerangepicker.TimeRangePicker
+
+class ExampleActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_example)
+
+ setStyle(R.id.tab_one)
+ bottom_bar.setOnTabSelectListener(object: AnimatedBottomBar.OnTabSelectListener {
+ override fun onTabSelected(
+ lastIndex: Int,
+ lastTab: AnimatedBottomBar.Tab?,
+ newIndex: Int,
+ newTab: AnimatedBottomBar.Tab
+ ) {
+ setStyle(newTab.id)
+ }
+ })
+
+ updateTimes()
+ updateDuration()
+
+ picker.setOnTimeChangeListener(object : TimeRangePicker.OnTimeChangeListener {
+ override fun onStartTimeChange(startTime: TimeRangePicker.Time) {
+ updateTimes()
+ }
+
+ override fun onEndTimeChange(endTime: TimeRangePicker.Time) {
+ updateTimes()
+ }
+
+ override fun onDurationChange(duration: TimeRangePicker.TimeDuration) {
+ updateDuration()
+ }
+ })
+
+ picker.setOnDragChangeListener(object : TimeRangePicker.OnDragChangeListener {
+ override fun onDragStart(thumb: TimeRangePicker.Thumb): Boolean {
+ if(thumb != TimeRangePicker.Thumb.BOTH) {
+ animate(thumb, true)
+ }
+ return true
+ }
+
+ override fun onDragStop(thumb: TimeRangePicker.Thumb) {
+ if(thumb != TimeRangePicker.Thumb.BOTH) {
+ animate(thumb, false)
+ }
+
+ Log.d(
+ "TimeRangePicker",
+ "Start time: " + picker.startTime
+ )
+ Log.d(
+ "TimeRangePicker",
+ "End time: " + picker.endTime
+ )
+ Log.d(
+ "TimeRangePicker",
+ "Total duration: " + picker.duration
+ )
+ }
+ })
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun updateTimes() {
+ end_time.text = picker.endTime.toString()
+ start_time.text = picker.startTime.toString()
+ }
+
+ private fun updateDuration() {
+ duration.text = getString(R.string.duration, picker.duration)
+ }
+
+ private fun animate(thumb: TimeRangePicker.Thumb, active: Boolean) {
+ val activeView = if(thumb == TimeRangePicker.Thumb.START) bedtime_layout else wake_layout
+ val inactiveView = if(thumb == TimeRangePicker.Thumb.START) wake_layout else bedtime_layout
+ val direction = if(thumb == TimeRangePicker.Thumb.START) 1 else -1
+
+ activeView
+ .animate()
+ .translationY(if(active) (activeView.measuredHeight / 2f)*direction else 0f)
+ .setDuration(300)
+ .start()
+ inactiveView
+ .animate()
+ .alpha(if(active) 0f else 1f)
+ .setDuration(300)
+ .start()
+ }
+
+ private fun setStyle(id: Int) {
+ when(id) {
+ R.id.tab_one -> {
+ picker.thumbColorAuto = true
+ picker.thumbSize = 28.px
+ picker.sliderWidth = 8.px
+ picker.sliderColor = Color.rgb(238, 238, 236)
+ picker.thumbIconColor = Color.WHITE
+ picker.thumbSizeActiveGrow = 1.2f
+ picker.sliderRangeGradientStart = Color.parseColor("#8287fe")
+ picker.sliderRangeGradientMiddle = Color.parseColor("#b67cc8")
+ picker.sliderRangeGradientEnd = Color.parseColor("#ffa301")
+ picker.clockFace = TimeRangePicker.ClockFace.SAMSUNG
+ picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_24
+ }
+ R.id.tab_two -> {
+ picker.thumbSize = 36.px
+ picker.sliderWidth = 40.px
+ picker.sliderColor = Color.TRANSPARENT
+ picker.thumbColor = Color.WHITE
+ picker.sliderRangeGradientStart = Color.parseColor("#F79104")
+ picker.sliderRangeGradientMiddle = null
+ picker.sliderRangeGradientEnd = Color.parseColor("#F8C207")
+ picker.thumbIconColor = Color.parseColor("#F79104")
+ picker.thumbSizeActiveGrow = 1.0f
+ picker.clockFace = TimeRangePicker.ClockFace.APPLE
+ picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_12
+ }
+ R.id.tab_three -> {
+ picker.thumbSize = 32.px
+ picker.sliderWidth = 32.px
+ picker.sliderColor = Color.rgb(233, 233, 233)
+ picker.thumbColor = Color.TRANSPARENT
+ picker.thumbIconColor = Color.WHITE
+ picker.sliderRangeGradientStart = Color.parseColor("#5663de")
+ picker.sliderRangeGradientMiddle = null
+ picker.sliderRangeGradientEnd = Color.parseColor("#6d7bff")
+ picker.thumbSizeActiveGrow = 1.0f
+ picker.clockFace = TimeRangePicker.ClockFace.SAMSUNG
+ picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_12
+ }
+ }
+ }
+
+ private val Int.px: Int
+ get() = (this * Resources.getSystem().displayMetrics.density).toInt()
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/Extensions.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/Extensions.kt
new file mode 100644
index 0000000..56603e8
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/Extensions.kt
@@ -0,0 +1,13 @@
+package nl.joery.demo.timerangepicker
+
+import android.content.res.Resources
+import kotlin.math.roundToInt
+
+internal val Int.dp: Int
+ get() = (this / Resources.getSystem().displayMetrics.density).roundToInt()
+internal val Int.sp: Int
+ get() = (this / Resources.getSystem().displayMetrics.scaledDensity).roundToInt()
+internal val Int.dpPx: Int
+ get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
+internal val Int.spPx: Int
+ get() = (this * Resources.getSystem().displayMetrics.scaledDensity).roundToInt()
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/ReflectionUtils.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/ReflectionUtils.kt
new file mode 100644
index 0000000..190abfa
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/ReflectionUtils.kt
@@ -0,0 +1,41 @@
+package nl.joery.demo.timerangepicker
+
+import android.annotation.SuppressLint
+import android.graphics.drawable.ColorDrawable
+import java.util.regex.Pattern
+
+internal object ReflectionUtils {
+ @SuppressLint("DefaultLocale")
+ fun getPropertyValue(instance: Any, property: String): Any? {
+ val methodName =
+ if (property == "backgroundColor") "getBackground" else "get" + property.capitalize()
+ val method = instance::class.java.methods.toList().find { it.name == methodName }
+ val result = method?.invoke(instance)
+
+ return if (result != null && result is ColorDrawable && property == "backgroundColor") {
+ result.color
+ } else {
+ result
+ }
+ }
+
+ @SuppressLint("DefaultLocale")
+ fun setPropertyValue(instance: Any, property: String, value: Any) {
+ val methodName = "set" + property.capitalize()
+ val method = instance::class.java.methods.toList().find { it.name == methodName }
+ method?.invoke(instance, value)
+ }
+
+ @SuppressLint("DefaultLocale")
+ fun pascalCaseToSnakeCase(text: String): String {
+ val matcher = Pattern.compile("(?<=[a-z])[A-Z]").matcher(text)
+
+ val sb = StringBuffer()
+ while (matcher.find()) {
+ matcher.appendReplacement(sb, "_" + matcher.group().lowercase())
+ }
+ matcher.appendTail(sb)
+
+ return sb.toString().lowercase()
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/Utils.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/Utils.kt
new file mode 100644
index 0000000..be61704
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/Utils.kt
@@ -0,0 +1,14 @@
+package nl.joery.demo.timerangepicker
+
+import android.graphics.Color
+import androidx.annotation.ColorInt
+
+object Utils {
+ fun colorToString(@ColorInt color: Int): String {
+ return if(Color.alpha(color) == 255) {
+ "#%06X".format(0xFFFFFF and color)
+ } else {
+ "#%08X".format(color)
+ }
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PlaygroundActivity.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PlaygroundActivity.kt
new file mode 100644
index 0000000..ac2c9f6
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PlaygroundActivity.kt
@@ -0,0 +1,226 @@
+package nl.joery.demo.timerangepicker.playground
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.text.Html
+import android.text.Spanned
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.android.synthetic.main.activity_playground.*
+import nl.joery.demo.timerangepicker.ExampleActivity
+import nl.joery.demo.timerangepicker.R
+import nl.joery.demo.timerangepicker.playground.properties.*
+import nl.joery.timerangepicker.TimeRangePicker
+
+
+class PlaygroundActivity : AppCompatActivity() {
+ private lateinit var properties: ArrayList
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_playground)
+
+ initProperties()
+ initRecyclerView()
+
+ view_xml.setOnClickListener {
+ showXmlDialog()
+ }
+
+ open_examples.setOnClickListener {
+ startActivity(Intent(this, ExampleActivity::class.java))
+ }
+ }
+
+ private fun initProperties() {
+ properties = ArrayList()
+ properties.add(
+ CategoryProperty(
+ "Time"
+ )
+ )
+ properties.add(
+ EnumProperty(
+ "hourFormat",
+ TimeRangePicker.HourFormat::class.java
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "stepTimeMinutes"
+ )
+ )
+
+ properties.add(
+ CategoryProperty(
+ "Slider"
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "sliderWidth",
+ false,
+ TypedValue.COMPLEX_UNIT_DIP
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "sliderColor"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "sliderRangeColor"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "sliderRangeGradientStart"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "sliderRangeGradientMiddle"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "sliderRangeGradientEnd"
+ )
+ )
+
+
+ properties.add(
+ CategoryProperty(
+ "Thumb"
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "thumbSize",
+ false,
+ TypedValue.COMPLEX_UNIT_DIP
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "thumbSizeActiveGrow",
+ true
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "thumbColor"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "thumbIconColor"
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "thumbIconSize",
+ false,
+ TypedValue.COMPLEX_UNIT_DIP
+ )
+ )
+
+
+ properties.add(
+ CategoryProperty(
+ "Clock"
+ )
+ )
+ properties.add(
+ BooleanProperty(
+ "clockVisible"
+ )
+ )
+ properties.add(
+ EnumProperty(
+ "clockFace",
+ TimeRangePicker.ClockFace::class.java
+ )
+ )
+ properties.add(
+ IntegerProperty(
+ "clockLabelSize",
+ false,
+ TypedValue.COMPLEX_UNIT_SP
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "clockLabelColor"
+ )
+ )
+ properties.add(
+ ColorProperty(
+ "clockTickColor"
+ )
+ )
+ }
+
+ private fun initRecyclerView() {
+ recycler.layoutManager =
+ LinearLayoutManager(applicationContext, LinearLayoutManager.VERTICAL, false)
+ recycler.adapter = PropertyAdapter(picker, properties)
+ }
+
+ private fun showXmlDialog() {
+ val html = XmlGenerator.generateHtmlXml(
+ "nl.joery.timerangepicker.TimeRangepicker",
+ "trp",
+ picker,
+ properties,
+ arrayOf(TimeRangePicker(this))
+ )
+
+ val layout = LayoutInflater.from(this).inflate(R.layout.view_generated_xml, null)
+ val textView = layout.findViewById(R.id.xml)
+ textView.setHorizontallyScrolling(true)
+ textView.text = htmlToSpanned(html)
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.generate_xml_title)
+ .setView(layout)
+ .setPositiveButton(R.string.copy_to_clipboard) { _, _ ->
+ val clipboard =
+ getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip =
+ ClipData.newPlainText(getString(R.string.generate_xml_title), htmlToText(html))
+ clipboard.setPrimaryClip(clip)
+
+ Snackbar.make(
+ findViewById(android.R.id.content),
+ R.string.copied_xml_clipboard,
+ Snackbar.LENGTH_LONG
+ ).show()
+ }
+ .show()
+ }
+
+ private fun htmlToText(html: String): String {
+ return htmlToSpanned(html).toString().replace("\u00A0", " ")
+ }
+
+ private fun htmlToSpanned(html: String): Spanned {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
+ } else {
+ @Suppress("DEPRECATION")
+ Html.fromHtml(html)
+ }
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PropertyAdapter.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PropertyAdapter.kt
new file mode 100644
index 0000000..4675a28
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/PropertyAdapter.kt
@@ -0,0 +1,329 @@
+@file:Suppress("UNCHECKED_CAST")
+
+package nl.joery.demo.timerangepicker.playground
+
+import android.annotation.SuppressLint
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.text.InputType
+import android.util.Log
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import android.widget.Toast
+import androidx.annotation.LayoutRes
+import androidx.core.graphics.ColorUtils
+import androidx.fragment.app.FragmentActivity
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.switchmaterial.SwitchMaterial
+import com.google.android.material.textfield.TextInputEditText
+import com.jaredrummler.android.colorpicker.ColorPickerDialog
+import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
+import nl.joery.demo.timerangepicker.*
+import nl.joery.demo.timerangepicker.playground.properties.*
+
+
+internal class PropertyAdapter(
+ private val bottomBar: Any,
+ private val properties: List
+) :
+ RecyclerView.Adapter() {
+
+ override fun getItemCount(): Int {
+ return properties.size
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val v: View = LayoutInflater.from(parent.context)
+ .inflate(getLayout(viewType), parent, false) as View
+ return when (viewType) {
+ Property.TYPE_ENUM -> EnumHolder(v, bottomBar)
+ Property.TYPE_COLOR -> ColorHolder(v, bottomBar)
+ Property.TYPE_BOOLEAN -> BooleanHolder(v, bottomBar)
+ Property.TYPE_INTERPOLATOR -> InterpolatorHolder(v, bottomBar)
+ Property.TYPE_CATEGORY -> CategoryHolder(v)
+ else -> IntegerHolder(v, bottomBar)
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ if (holder is BaseHolder<*>) {
+ (holder as BaseHolder).bind(properties[position])
+ } else {
+ (holder as CategoryHolder).bind(properties[position] as CategoryProperty)
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return when (properties[position]) {
+ is EnumProperty -> Property.TYPE_ENUM
+ is ColorProperty -> Property.TYPE_COLOR
+ is IntegerProperty -> Property.TYPE_INTEGER
+ is BooleanProperty -> Property.TYPE_BOOLEAN
+ is InterpolatorProperty -> Property.TYPE_INTERPOLATOR
+ is CategoryProperty -> Property.TYPE_CATEGORY
+ else -> -1
+ }
+ }
+
+ @LayoutRes
+ private fun getLayout(propertyType: Int): Int {
+ return when (propertyType) {
+ Property.TYPE_CATEGORY -> R.layout.list_property_category
+ Property.TYPE_COLOR -> R.layout.list_property_color
+ Property.TYPE_BOOLEAN -> R.layout.list_property_boolean
+ else -> R.layout.list_property
+ }
+ }
+
+ class CategoryHolder(
+ view: View
+ ) :
+ RecyclerView.ViewHolder(view) {
+ internal val name = view.findViewById(R.id.name)
+
+ fun bind(category: CategoryProperty) {
+ name.text = category.name
+ }
+ }
+
+ abstract class BaseHolder(
+ internal val view: View,
+ internal val bottomBar: Any
+ ) :
+ RecyclerView.ViewHolder(view) {
+ internal lateinit var property: T
+
+ internal val name = view.findViewById(R.id.name)
+ private val value = view.findViewById(R.id.value)
+
+ init {
+ view.setOnClickListener {
+ thumbClick()
+ }
+ }
+
+ @SuppressLint("DefaultLocale")
+ protected open fun getValue(): String {
+ return ReflectionUtils.getPropertyValue(bottomBar, property.name).toString()
+ .lowercase()
+ .capitalize()
+ }
+
+ protected abstract fun thumbClick()
+
+ protected open fun updateValue() {
+ if (value == null) {
+ return
+ }
+
+ value.text = getValue()
+ }
+
+ protected fun setValue(value: Any) {
+ ReflectionUtils.setPropertyValue(bottomBar, property.name, value)
+ property.modified = true
+ updateValue()
+ }
+
+ internal open fun bind(property: T) {
+ this.property = property
+ name.text = property.name
+
+ updateValue()
+ }
+ }
+
+ class EnumHolder(v: View, bottomBar: Any) :
+ BaseHolder(v, bottomBar) {
+
+ @SuppressLint("DefaultLocale")
+ override fun thumbClick() {
+ val enumValues = property.enumClass.enumConstants as Array>
+ val items = enumValues.map { it.name.lowercase().capitalize() }.toTypedArray()
+
+ MaterialAlertDialogBuilder(view.context)
+ .setTitle(view.context.getString(R.string.set_property_value, property.name))
+ .setSingleChoiceItems(
+ items, items.indexOf(getValue())
+ ) { dialog, item ->
+ setValue(enumValues.first {
+ it.name == items[item].uppercase()
+ })
+ dialog.dismiss()
+ }
+ .show()
+ }
+ }
+
+ class ColorHolder(v: View, bottomBar: Any) :
+ BaseHolder(v, bottomBar) {
+
+ private val color = view.findViewById(R.id.color)
+
+ override fun getValue(): String {
+ return if (getColor() == null) "no color set" else Utils.colorToString(getColor()!!)
+ }
+
+ override fun thumbClick() {
+ val activity = view.context as FragmentActivity
+ val builder = ColorPickerDialog.newBuilder()
+ .setAllowCustom(true)
+ .setAllowPresets(true)
+ .setShowColorShades(true)
+ .setShowAlphaSlider(true)
+ .setDialogTitle(R.string.pick_color)
+ .setSelectedButtonText(R.string.apply)
+
+ if(getColor() != null) {
+ builder.setColor(ColorUtils.setAlphaComponent(getColor()!!, 255))
+ }
+
+ val dialog = builder.create()
+ dialog.setColorPickerDialogListener(object : ColorPickerDialogListener {
+ override fun onDialogDismissed(dialogId: Int) {
+ }
+
+ override fun onColorSelected(dialogId: Int, color: Int) {
+ setValue(color)
+ updateColor()
+ }
+ })
+ dialog.show(activity.supportFragmentManager, "")
+ }
+
+ private fun updateColor() {
+ if (getColor() != null) {
+ val shape = GradientDrawable()
+ shape.shape = GradientDrawable.RECTANGLE
+ shape.cornerRadii = FloatArray(8) { 3.dpPx.toFloat() }
+ shape.setColor(getColor()!!)
+ shape.setStroke(1.dpPx, Color.rgb(200, 200, 200))
+ color.background = shape
+ color.visibility = View.VISIBLE
+ } else {
+ color.visibility = View.GONE
+ }
+ }
+
+ private fun getColor(): Int? {
+ return ReflectionUtils.getPropertyValue(bottomBar, property.name) as Int?
+ }
+
+ override fun bind(property: ColorProperty) {
+ super.bind(property)
+
+ updateColor()
+ }
+ }
+
+ class IntegerHolder(v: View, bottomBar: Any) :
+ BaseHolder(v, bottomBar) {
+
+ override fun getValue(): String {
+ val value = super.getValue()
+
+ if (value == "Null") {
+ return "unset"
+ }
+
+ return when (property.density) {
+ TypedValue.COMPLEX_UNIT_DIP -> value.toInt().dp.toString() + "dp"
+ TypedValue.COMPLEX_UNIT_SP -> value.toInt().sp.toString() + "sp"
+ else -> value
+ }
+ }
+
+ @SuppressLint("InflateParams")
+ override fun thumbClick() {
+ val view = LayoutInflater.from(view.context).inflate(
+ R.layout.view_text_input,
+ null
+ )
+ val editText = view.findViewById(R.id.edit_text)
+ editText.setText(getValue().replace("[^.^\\dxX]+".toRegex(), ""))
+ if(property.float) {
+ editText.inputType = InputType.TYPE_NUMBER_FLAG_DECIMAL
+ } else {
+ editText.inputType = InputType.TYPE_CLASS_NUMBER
+ }
+
+ MaterialAlertDialogBuilder(view.context)
+ .setTitle(view.context.getString(R.string.set_property_value, property.name))
+ .setPositiveButton(R.string.apply) { dialog, _ ->
+ try {
+ val newValue = if (property.float) {
+ editText.text.toString().toFloat()
+ } else {
+ val tempValue = editText.text.toString().toInt()
+ when (property.density) {
+ TypedValue.COMPLEX_UNIT_DIP -> tempValue.dpPx
+ TypedValue.COMPLEX_UNIT_SP -> tempValue.spPx
+ else -> tempValue
+ }
+ }
+ setValue(newValue)
+ dialog.dismiss()
+ } catch (e: NumberFormatException) {
+ Toast.makeText(
+ view.context,
+ "Invalid value: " + e.message,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ .setView(view)
+ .show()
+ }
+ }
+
+ class BooleanHolder(v: View, bottomBar: Any) :
+ BaseHolder(v, bottomBar) {
+ private val booleanSwitch = view.findViewById(R.id.booleanSwitch)
+
+ override fun updateValue() {
+ booleanSwitch.isChecked =
+ ReflectionUtils.getPropertyValue(bottomBar, property.name) as Boolean
+ }
+
+ override fun thumbClick() {
+ }
+
+ override fun bind(property: BooleanProperty) {
+ super.bind(property)
+
+ booleanSwitch.setOnCheckedChangeListener { _, isChecked ->
+ setValue(isChecked)
+ }
+ }
+ }
+
+ class InterpolatorHolder(v: View, bottomBar: Any) :
+ BaseHolder(v, bottomBar) {
+
+ override fun getValue(): String {
+ val value = ReflectionUtils.getPropertyValue(bottomBar, property.name)
+ return value!!::class.java.simpleName
+ }
+
+ override fun thumbClick() {
+ val interpolatorNames =
+ InterpolatorProperty.interpolators.map { it::class.java.simpleName }.toTypedArray()
+
+ MaterialAlertDialogBuilder(view.context)
+ .setTitle(view.context.getString(R.string.set_property_value, property.name))
+ .setSingleChoiceItems(
+ interpolatorNames, interpolatorNames.indexOf(getValue())
+ ) { dialog, item ->
+ setValue(InterpolatorProperty.interpolators.first {
+ it::class.java.simpleName == interpolatorNames[item]
+ })
+ dialog.dismiss()
+ }
+ .show()
+ }
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/XmlGenerator.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/XmlGenerator.kt
new file mode 100644
index 0000000..2eda248
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/XmlGenerator.kt
@@ -0,0 +1,90 @@
+package nl.joery.demo.timerangepicker.playground
+
+import android.annotation.SuppressLint
+import android.util.TypedValue
+import nl.joery.demo.timerangepicker.ReflectionUtils
+import nl.joery.demo.timerangepicker.Utils
+import nl.joery.demo.timerangepicker.dp
+import nl.joery.demo.timerangepicker.playground.properties.*
+import nl.joery.demo.timerangepicker.sp
+
+object XmlGenerator {
+ fun generateHtmlXml(
+ name: String,
+ prefix: String,
+ instance: Any,
+ properties: List,
+ defaultProviders: Array
+ ): String {
+ val sb = StringBuilder()
+ sb.append("<")
+ .append(coloredText(name, "#22863a"))
+ .append("
")
+
+ sb.append(getXmlProperty("android:layout_width", "300dp"))
+ sb.append(getXmlProperty("android:layout_height", "wrap_content"))
+ sb.append(getXmlProperty("app:trp_thumbStartIcon", "@drawable/ic_thumb_start"))
+ sb.append(getXmlProperty("app:trp_thumbEndIcon", "@drawable/ic_thumb_end"))
+
+ for (property in properties) {
+ if (!property.modified || property is CategoryProperty) {
+ continue
+ }
+
+ val defaultValue = getDefaultValue(defaultProviders, property.name)
+ val actualValue = ReflectionUtils.getPropertyValue(instance, property.name)
+ if ((defaultValue == null && actualValue != null) || defaultValue != actualValue) {
+ sb.append(
+ getXmlProperty(
+ if (property.name == "backgroundColor") "android:background" else "app:${prefix}_${property.name}",
+ getHumanValue(property, actualValue!!)
+ )
+ )
+ }
+ }
+
+ return sb.toString().substring(0, sb.toString().length - 4) + " />"
+ }
+
+ private fun getXmlProperty(name: String, value: String): String {
+ val sb = StringBuilder()
+ return sb.append(" ")
+ .append(coloredText(name, "#6f42c1"))
+ .append("=")
+ .append(coloredText(""", "#032f62"))
+ .append(coloredText(value, "#032f62"))
+ .append(coloredText(""", "#032f62"))
+ .append("
").toString()
+ }
+
+ private fun getDefaultValue(defaultProviders: Array, propertyName: String): Any? {
+ for (provider in defaultProviders) {
+ val value = ReflectionUtils.getPropertyValue(provider, propertyName)
+ if (value != null) {
+ return value
+ }
+ }
+ return null
+ }
+
+ @SuppressLint("DefaultLocale")
+ private fun getHumanValue(property: Property, value: Any): String {
+ return when (property) {
+ is ColorProperty -> Utils.colorToString(value as Int)
+ is IntegerProperty -> when (property.density) {
+ TypedValue.COMPLEX_UNIT_DIP -> (value as Int).dp.toString() + "dp"
+ TypedValue.COMPLEX_UNIT_SP -> (value as Int).sp.toString() + "sp"
+ else -> value.toString()
+ }
+ is EnumProperty -> value.toString().lowercase()
+ is InterpolatorProperty -> "@android:anim/" + ReflectionUtils.pascalCaseToSnakeCase(
+ value::class.java.simpleName
+ )
+ else -> value.toString()
+ }
+ }
+
+ private fun coloredText(text: String, color: String): String {
+ return "$text"
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/BooleanProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/BooleanProperty.kt
new file mode 100644
index 0000000..6cdefb1
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/BooleanProperty.kt
@@ -0,0 +1,4 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+
+class BooleanProperty(name: String) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/CategoryProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/CategoryProperty.kt
new file mode 100644
index 0000000..d51898b
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/CategoryProperty.kt
@@ -0,0 +1,4 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+
+class CategoryProperty(name: String) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/ColorProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/ColorProperty.kt
new file mode 100644
index 0000000..37c4333
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/ColorProperty.kt
@@ -0,0 +1,4 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+
+class ColorProperty(name: String) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/EnumProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/EnumProperty.kt
new file mode 100644
index 0000000..063d8a8
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/EnumProperty.kt
@@ -0,0 +1,4 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+
+class EnumProperty(name: String, val enumClass: Class<*>) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/FloatProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/FloatProperty.kt
new file mode 100644
index 0000000..ef86256
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/FloatProperty.kt
@@ -0,0 +1,4 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+
+class FloatProperty(name: String) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/IntegerProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/IntegerProperty.kt
new file mode 100644
index 0000000..b25311d
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/IntegerProperty.kt
@@ -0,0 +1,6 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+import android.util.TypedValue
+
+
+class IntegerProperty(name: String, val float: Boolean = false, val density: Int = TypedValue.DENSITY_NONE) : Property(name)
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/InterpolatorProperty.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/InterpolatorProperty.kt
new file mode 100644
index 0000000..082d5fd
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/InterpolatorProperty.kt
@@ -0,0 +1,23 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+import android.view.animation.*
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator
+
+
+class InterpolatorProperty(name: String) : Property(name) {
+ companion object {
+ val interpolators: List by lazy {
+ ArrayList().apply {
+ add(FastOutSlowInInterpolator())
+ add(LinearInterpolator())
+ add(AccelerateDecelerateInterpolator())
+ add(AccelerateInterpolator())
+ add(DecelerateInterpolator())
+ add(AnticipateInterpolator())
+ add(AnticipateOvershootInterpolator())
+ add(OvershootInterpolator())
+ add(BounceInterpolator())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/Property.kt b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/Property.kt
new file mode 100644
index 0000000..6feb36d
--- /dev/null
+++ b/demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/Property.kt
@@ -0,0 +1,14 @@
+package nl.joery.demo.timerangepicker.playground.properties
+
+abstract class Property(val name: String) {
+ var modified: Boolean = false
+
+ companion object {
+ const val TYPE_INTEGER = 1
+ const val TYPE_COLOR = 2
+ const val TYPE_ENUM = 3
+ const val TYPE_BOOLEAN = 4
+ const val TYPE_INTERPOLATOR = 5
+ const val TYPE_CATEGORY = 6
+ }
+}
\ No newline at end of file
diff --git a/demo/src/main/res/drawable/ic_alarm.xml b/demo/src/main/res/drawable/ic_alarm.xml
new file mode 100644
index 0000000..958b0f0
--- /dev/null
+++ b/demo/src/main/res/drawable/ic_alarm.xml
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/drawable/ic_launcher_foreground.xml b/demo/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..4836c3b
--- /dev/null
+++ b/demo/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/src/main/res/drawable/ic_moon.xml b/demo/src/main/res/drawable/ic_moon.xml
new file mode 100644
index 0000000..fdcb754
--- /dev/null
+++ b/demo/src/main/res/drawable/ic_moon.xml
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/drawable/numeric_1_box_outline.xml b/demo/src/main/res/drawable/numeric_1_box_outline.xml
new file mode 100644
index 0000000..6a5865b
--- /dev/null
+++ b/demo/src/main/res/drawable/numeric_1_box_outline.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/drawable/numeric_2_box_outline.xml b/demo/src/main/res/drawable/numeric_2_box_outline.xml
new file mode 100644
index 0000000..1e903af
--- /dev/null
+++ b/demo/src/main/res/drawable/numeric_2_box_outline.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/drawable/numeric_3_box_outline.xml b/demo/src/main/res/drawable/numeric_3_box_outline.xml
new file mode 100644
index 0000000..d0ff330
--- /dev/null
+++ b/demo/src/main/res/drawable/numeric_3_box_outline.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/activity_example.xml b/demo/src/main/res/layout/activity_example.xml
new file mode 100644
index 0000000..67d844b
--- /dev/null
+++ b/demo/src/main/res/layout/activity_example.xml
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/activity_playground.xml b/demo/src/main/res/layout/activity_playground.xml
new file mode 100644
index 0000000..506d293
--- /dev/null
+++ b/demo/src/main/res/layout/activity_playground.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/list_property.xml b/demo/src/main/res/layout/list_property.xml
new file mode 100644
index 0000000..badc04b
--- /dev/null
+++ b/demo/src/main/res/layout/list_property.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/list_property_boolean.xml b/demo/src/main/res/layout/list_property_boolean.xml
new file mode 100644
index 0000000..1c51688
--- /dev/null
+++ b/demo/src/main/res/layout/list_property_boolean.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/list_property_category.xml b/demo/src/main/res/layout/list_property_category.xml
new file mode 100644
index 0000000..1f8ca9f
--- /dev/null
+++ b/demo/src/main/res/layout/list_property_category.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/list_property_color.xml b/demo/src/main/res/layout/list_property_color.xml
new file mode 100644
index 0000000..165f241
--- /dev/null
+++ b/demo/src/main/res/layout/list_property_color.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/view_generated_xml.xml b/demo/src/main/res/layout/view_generated_xml.xml
new file mode 100644
index 0000000..d3035c3
--- /dev/null
+++ b/demo/src/main/res/layout/view_generated_xml.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/view_text_input.xml b/demo/src/main/res/layout/view_text_input.xml
new file mode 100644
index 0000000..6d39ba8
--- /dev/null
+++ b/demo/src/main/res/layout/view_text_input.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/menu/tabs.xml b/demo/src/main/res/menu/tabs.xml
new file mode 100644
index 0000000..92d8041
--- /dev/null
+++ b/demo/src/main/res/menu/tabs.xml
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/values/colors.xml b/demo/src/main/res/values/colors.xml
new file mode 100644
index 0000000..fd54635
--- /dev/null
+++ b/demo/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #6200EE
+ #3700B3
+ #E91E63
+
+ #f5f5f5
+ #E4E4E4
+
\ No newline at end of file
diff --git a/demo/src/main/res/values/ic_launcher_background.xml b/demo/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..d20ba70
--- /dev/null
+++ b/demo/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #0287D0
+
\ No newline at end of file
diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4d89bcc
--- /dev/null
+++ b/demo/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+ TimeRangePicker
+ Sleep at
+ Wake at
+ You will sleep for %1$s
+
+ Example 1
+ Example 2
+ Example 3
+
+ Set %1$s value
+ Apply
+ Pick a color
+
+ Copied XML to clipboard
+ Copy to clipboard
+ Generated XML
+
+ Generate XML
+ Show examples
+
\ No newline at end of file
diff --git a/demo/src/main/res/values/styles.xml b/demo/src/main/res/values/styles.xml
new file mode 100644
index 0000000..ec2556f
--- /dev/null
+++ b/demo/src/main/res/values/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/test/java/nl/joery/demo/timerangepicker/ExampleUnitTest.kt b/demo/src/test/java/nl/joery/demo/timerangepicker/ExampleUnitTest.kt
new file mode 100644
index 0000000..363a724
--- /dev/null
+++ b/demo/src/test/java/nl/joery/demo/timerangepicker/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package nl.joery.demo.timerangepicker
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f3090bc
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,43 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+
+GROUP=nl.joery.timerangepicker
+POM_ARTIFACT_ID=timerangepicker
+VERSION_NAME=1.0.0
+
+POM_NAME=TimeRangePicker
+POM_DESCRIPTION=A customizable, easy-to-use, and functional circular time range picker library for \
+ Android. Use this library to mimic Apple's iOS or Samsung's bedtime picker.
+POM_INCEPTION_YEAR=2021
+POM_URL=https://github.com/Droppers/TimeRangePicker
+
+POM_LICENCE_NAME=The MIT License
+POM_LICENCE_URL=https://github.com/Droppers/TimeRangePicker/blob/master/LICENSE
+POM_LICENCE_DIST=repo
+
+POM_SCM_URL=https://github.com/Droppers/TimeRangePicker
+POM_SCM_CONNECTION=scm:git:git://github.com/Droppers/TimeRangePicker.git
+POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/Droppers/TimeRangePicker.git
+
+POM_DEVELOPER_ID=Droppers
+POM_DEVELOPER_NAME=Joery Droppers
+POM_DEVELOPER_URL=https://github.com/Droppers
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..f6b961f
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..da4344b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Mar 03 23:02:02 CET 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..cccdd3d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..f955316
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..dac31a6
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,3 @@
+include ':timerangepicker'
+include ':demo'
+rootProject.name = "TimeRangePicker"
\ No newline at end of file
diff --git a/timerangepicker/.gitignore b/timerangepicker/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/timerangepicker/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/timerangepicker/build.gradle b/timerangepicker/build.gradle
new file mode 100644
index 0000000..1f22b67
--- /dev/null
+++ b/timerangepicker/build.gradle
@@ -0,0 +1,41 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: "com.vanniktech.maven.publish"
+
+android {
+ compileSdkVersion 30
+
+ defaultConfig {
+ minSdkVersion 14
+ targetSdkVersion 30
+ versionCode 1
+ versionName VERSION_NAME
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+
+ kotlinOptions.freeCompilerArgs += ['-module-name', "${GROUP}.${POM_ARTIFACT_ID}"]
+ }
+}
+
+dependencies {
+ api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ api 'androidx.core:core-ktx:1.5.0'
+ api 'androidx.appcompat:appcompat:1.3.0'
+
+ testImplementation 'junit:junit:4.12'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
+}
\ No newline at end of file
diff --git a/timerangepicker/consumer-rules.pro b/timerangepicker/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/timerangepicker/proguard-rules.pro b/timerangepicker/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/timerangepicker/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/timerangepicker/src/androidTest/java/nl/joery/timerangepicker/ExampleInstrumentedTest.kt b/timerangepicker/src/androidTest/java/nl/joery/timerangepicker/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..fe6230d
--- /dev/null
+++ b/timerangepicker/src/androidTest/java/nl/joery/timerangepicker/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package nl.joery.timerangepicker
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("nl.joery.timerangepicker.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/timerangepicker/src/main/AndroidManifest.xml b/timerangepicker/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5061876
--- /dev/null
+++ b/timerangepicker/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/timerangepicker/src/main/java/nl/joery/timerangepicker/ClockRenderer.kt b/timerangepicker/src/main/java/nl/joery/timerangepicker/ClockRenderer.kt
new file mode 100644
index 0000000..92ed00c
--- /dev/null
+++ b/timerangepicker/src/main/java/nl/joery/timerangepicker/ClockRenderer.kt
@@ -0,0 +1,130 @@
+package nl.joery.timerangepicker
+
+import android.graphics.*
+import nl.joery.timerangepicker.utils.dp
+import nl.joery.timerangepicker.utils.px
+import kotlin.math.cos
+import kotlin.math.sin
+
+
+internal class ClockRenderer(private val timeRangePicker: TimeRangePicker) {
+ private val _minuteTickWidth = 1.px
+ private val _hourTickWidth = 2.px
+ private val _middle = PointF(0f, 0f)
+
+ private val _tickLength: Int
+ get() = when (timeRangePicker.clockFace) {
+ TimeRangePicker.ClockFace.APPLE -> 6.px
+ TimeRangePicker.ClockFace.SAMSUNG -> 4.px
+ }
+ private val _tickCount: Int
+ get() = when (timeRangePicker.clockFace) {
+ TimeRangePicker.ClockFace.APPLE -> 48
+ TimeRangePicker.ClockFace.SAMSUNG -> 120
+ }
+
+ private val _tickPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val _labelPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ fun updatePaint() {
+ _tickPaint.apply {
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ color = timeRangePicker.clockTickColor
+ }
+
+ _labelPaint.apply {
+ textSize = timeRangePicker.clockLabelSize.toFloat()
+ color = timeRangePicker.clockLabelColor
+ textAlign = Paint.Align.CENTER
+ }
+ }
+
+ fun render(canvas: Canvas, radius: Float) {
+ _middle.x = canvas.width / 2f
+ _middle.y = canvas.width / 2f
+
+ drawTicks(canvas, radius)
+ drawLabels(canvas, radius)
+ }
+
+ private fun drawTicks(canvas: Canvas, radius: Float) {
+ val hourTickInterval = if(timeRangePicker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) 24 else 12
+
+ for (i in 0 until _tickCount) {
+ val angle = 360f / _tickCount * i
+
+ val start = getPositionByAngle(radius, angle)
+ val stop = getPositionByAngle(radius - _tickLength, angle)
+
+ val offset = if(timeRangePicker.clockLabelSize.dp <= 16) 3 else 6
+ if (timeRangePicker.clockFace == TimeRangePicker.ClockFace.SAMSUNG &&
+ ((angle >= 90-offset && angle <= 90+offset) ||
+ (angle >= 180-offset && angle <= 180+offset) ||
+ (angle >= 270-offset && angle <= 270+offset) ||
+ angle >= 360-offset ||
+ angle <= 0+offset)) {
+ continue
+ }
+
+ // Hour tick
+ if (i % (_tickCount / hourTickInterval) == 0) {
+ _tickPaint.alpha = 180
+ _tickPaint.strokeWidth = _hourTickWidth.toFloat()
+ } else {
+ _tickPaint.alpha = 100
+ _tickPaint.strokeWidth = _minuteTickWidth.toFloat()
+ }
+ canvas.drawLine(start.x, start.y, stop.x, stop.y, _tickPaint)
+ }
+ }
+
+ private fun drawLabels(canvas: Canvas, radius: Float) {
+ val labels = when (timeRangePicker.clockFace) {
+ TimeRangePicker.ClockFace.APPLE -> {
+ if (timeRangePicker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) {
+ arrayOf("0", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22")
+ } else {
+ arrayOf("12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11")
+ }
+ }
+ TimeRangePicker.ClockFace.SAMSUNG -> {
+ if (timeRangePicker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) {
+ arrayOf("0", "6", "12", "18")
+ } else {
+ arrayOf("12", "3", "6", "9")
+ }
+ }
+ }
+
+ for (i in labels.indices) {
+ val label = labels[i]
+ val angle = 360f / labels.size * i - 90f
+
+ val bounds = Rect()
+ _labelPaint.getTextBounds(label, 0, label.length, bounds)
+ val offset = when (timeRangePicker.clockFace) {
+ TimeRangePicker.ClockFace.APPLE -> _tickLength.toFloat() * 2 + bounds.height()
+ TimeRangePicker.ClockFace.SAMSUNG -> (if(angle == 0f || angle == 180f) bounds.width() else bounds.height()).toFloat() / 2
+ }
+ val position =
+ getPositionByAngle(radius - offset, angle)
+ canvas.drawText(
+ label,
+ position.x,
+ position.y + bounds.height() / 2f,
+ _labelPaint
+ )
+ }
+ }
+
+ private fun getPositionByAngle(
+ radius: Float,
+ angle: Float
+ ): PointF {
+ return PointF(
+ (_middle.x + radius * cos(Math.toRadians(angle.toDouble()))).toFloat(),
+ (_middle.y + radius * sin(Math.toRadians(angle.toDouble()))).toFloat()
+ )
+ }
+}
\ No newline at end of file
diff --git a/timerangepicker/src/main/java/nl/joery/timerangepicker/SavedState.kt b/timerangepicker/src/main/java/nl/joery/timerangepicker/SavedState.kt
new file mode 100644
index 0000000..ebe7e9f
--- /dev/null
+++ b/timerangepicker/src/main/java/nl/joery/timerangepicker/SavedState.kt
@@ -0,0 +1,36 @@
+package nl.joery.timerangepicker
+
+import android.os.Parcel
+import android.os.Parcelable
+import android.view.View
+
+internal class SavedState : View.BaseSavedState {
+ var angleStart: Float = 0f
+ var angleEnd: Float = 0f
+
+ constructor(source: Parcel) : super(source) {
+ angleStart = source.readFloat()
+ angleEnd = source.readFloat()
+ }
+
+ constructor(superState: Parcelable?) : super(superState)
+
+ override fun writeToParcel(out: Parcel, flags: Int) {
+ super.writeToParcel(out, flags)
+ out.writeFloat(angleStart)
+ out.writeFloat(angleEnd)
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR = object : Parcelable.Creator {
+ override fun createFromParcel(source: Parcel): SavedState {
+ return SavedState(source)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/timerangepicker/src/main/java/nl/joery/timerangepicker/TimeRangePicker.kt b/timerangepicker/src/main/java/nl/joery/timerangepicker/TimeRangePicker.kt
new file mode 100644
index 0000000..8c19404
--- /dev/null
+++ b/timerangepicker/src/main/java/nl/joery/timerangepicker/TimeRangePicker.kt
@@ -0,0 +1,1093 @@
+package nl.joery.timerangepicker
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.TypedArray
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.os.Parcelable
+import android.text.format.DateFormat
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import androidx.annotation.*
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
+import nl.joery.timerangepicker.utils.*
+import nl.joery.timerangepicker.utils.MathUtils
+import nl.joery.timerangepicker.utils.MathUtils.differenceBetweenAngles
+import nl.joery.timerangepicker.utils.MathUtils.angleTo360
+import nl.joery.timerangepicker.utils.MathUtils.angleTo720
+import nl.joery.timerangepicker.utils.MathUtils.angleToMinutes
+import nl.joery.timerangepicker.utils.MathUtils.angleToPreciseMinutes
+import nl.joery.timerangepicker.utils.MathUtils.durationBetweenMinutes
+import nl.joery.timerangepicker.utils.MathUtils.minutesToAngle
+import nl.joery.timerangepicker.utils.MathUtils.simpleMinutesToAngle
+import nl.joery.timerangepicker.utils.MathUtils.snapMinutes
+import nl.joery.timerangepicker.utils.getColorResCompat
+import nl.joery.timerangepicker.utils.px
+import nl.joery.timerangepicker.utils.sp
+import java.time.Duration
+import java.time.LocalTime
+import java.util.*
+import javax.xml.datatype.DatatypeFactory
+import kotlin.math.*
+import kotlin.properties.Delegates
+
+
+class TimeRangePicker @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+ private val _clockRenderer = ClockRenderer(this)
+
+ private val _thumbStartPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val _thumbEndPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ private val _sliderRangePaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val _sliderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val _gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ private val _sliderRect: RectF = RectF()
+ private val _sliderCapRect: RectF = RectF()
+
+ private var _sliderWidth: Int = 8.px
+ private var _sliderColor by Delegates.notNull()
+ private var _sliderRangeColor by Delegates.notNull()
+ private var _sliderRangeGradientStart: Int? = null
+ private var _sliderRangeGradientMiddle: Int? = null
+ private var _sliderRangeGradientEnd: Int? = null
+
+ private var _thumbSize: Int = 28.px
+ private var _thumbSizeActiveGrow: Float = 1.2f
+ private var _thumbIconStart: Drawable? = null
+ private var _thumbIconEnd: Drawable? = null
+ private var _thumbColor by Delegates.notNull()
+ private var _thumbColorAuto: Boolean = true
+ private var _thumbIconColor: Int? = null
+ private var _thumbIconSize: Int? = null
+
+ private var _clockVisible: Boolean = true
+ private var _clockFace: ClockFace = ClockFace.APPLE
+ private var _clockLabelSize = 15.sp
+ private var _clockLabelColor by Delegates.notNull()
+ private var _clockTickColor by Delegates.notNull()
+
+ private var _minDurationMinutes: Int = 0
+ private var _maxDurationMinutes: Int = 24 * 60
+ private var _stepTimeMinutes = 10
+
+ private var onTimeChangeListener: OnTimeChangeListener? = null
+ private var onDragChangeListener: OnDragChangeListener? = null
+
+ private val _radius: Float
+ get() = (min(width, height) / 2f - max(
+ max(
+ _thumbSize,
+ (_thumbSize * _thumbSizeActiveGrow).toInt()
+ ), _sliderWidth
+ ) / 2f) - max(
+ max(paddingTop, paddingLeft),
+ max(paddingBottom, paddingRight)
+ )
+
+ private var _middlePoint = PointF(0f, 0f)
+
+ private var _hourFormat = HourFormat.FORMAT_12
+ private var _angleStart: Float = 0f
+ private var _angleEnd: Float = 0f
+
+ private var _activeThumb: Thumb? = null
+ private var _touchOffsetAngle: Float = 0.0f
+
+ private val _isGradientSlider: Boolean
+ get() = _sliderRangeGradientStart != null && _sliderRangeGradientEnd != null
+
+ init {
+ initColors()
+ initAttributes(attrs)
+
+ updateMiddlePoint()
+ _clockRenderer.updatePaint()
+ updatePaint()
+ }
+
+ private fun initColors() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ _sliderRangeColor = context.getColorResCompat(android.R.attr.colorPrimary)
+ _thumbColor = context.getColorResCompat(android.R.attr.colorPrimary)
+ } else {
+ _sliderRangeColor = Color.BLUE
+ _thumbColor = Color.BLUE
+ }
+ _sliderColor = Color.parseColor("#E1E1E1")
+ _clockTickColor = context.getTextColor(android.R.attr.textColorPrimary)
+ _clockLabelColor = context.getTextColor(android.R.attr.textColorPrimary)
+ }
+
+ private fun initAttributes(
+ attributeSet: AttributeSet?
+ ) {
+ val attr: TypedArray =
+ context.obtainStyledAttributes(attributeSet, R.styleable.TimeRangePicker, 0, 0)
+ try {
+ // Time
+ hourFormat = HourFormat.fromId(
+ attr.getInt(
+ R.styleable.TimeRangePicker_trp_hourFormat,
+ _hourFormat.id
+ )
+ ) ?: _hourFormat // Sets public property to determine 24 / 12 format automatically
+ _angleStart = minutesToAngle(
+ attr.getInt(
+ R.styleable.TimeRangePicker_trp_endTimeMinutes,
+ angleToMinutes(minutesToAngle(Time(0, 0).totalMinutes, _hourFormat), _hourFormat)
+ ),
+ _hourFormat
+ )
+ _angleEnd = minutesToAngle(
+ attr.getInt(
+ R.styleable.TimeRangePicker_trp_startTimeMinutes,
+ angleToMinutes(minutesToAngle(Time(8, 0).totalMinutes, _hourFormat), _hourFormat)
+ ),
+ _hourFormat
+ )
+ val startTime = attr.getString(R.styleable.TimeRangePicker_trp_startTime)
+ if (startTime != null) {
+ _angleStart = minutesToAngle(parseTimeString(startTime).totalMinutes, _hourFormat)
+ }
+ val endTime = attr.getString(R.styleable.TimeRangePicker_trp_endTime)
+ if (endTime != null) {
+ _angleEnd = minutesToAngle(parseTimeString(endTime).totalMinutes, _hourFormat)
+ }
+
+ // Duration
+ minDurationMinutes = attr.getInt(R.styleable.TimeRangePicker_trp_minDurationMinutes, _minDurationMinutes)
+ maxDurationMinutes = attr.getInt(R.styleable.TimeRangePicker_trp_maxDurationMinutes, _maxDurationMinutes)
+
+ val minDuration = attr.getString(R.styleable.TimeRangePicker_trp_minDuration)
+ if (minDuration != null) {
+ minDurationMinutes = parseTimeString(minDuration).totalMinutes
+ }
+ val maxDuration = attr.getString(R.styleable.TimeRangePicker_trp_maxDuration)
+ if (maxDuration != null) {
+ maxDurationMinutes = parseTimeString(maxDuration).totalMinutes
+ }
+
+ _stepTimeMinutes = attr.getInt(
+ R.styleable.TimeRangePicker_trp_stepTimeMinutes,
+ _stepTimeMinutes
+ )
+
+ // Slider
+ _sliderWidth = attr.getDimension(
+ R.styleable.TimeRangePicker_trp_sliderWidth,
+ _sliderWidth.toFloat()
+ ).toInt()
+ _sliderColor = attr.getColor(R.styleable.TimeRangePicker_trp_sliderColor, _sliderColor)
+ _sliderRangeColor =
+ attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeColor, _sliderRangeColor)
+
+ // Slider gradient
+ val gradientStart =
+ attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientStart, -1)
+ val gradientMiddle =
+ attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientMiddle, -1)
+ val gradientEnd =
+ attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientEnd, -1)
+ if (gradientStart != -1 && gradientEnd != -1) {
+ _sliderRangeGradientStart = gradientStart
+ _sliderRangeGradientMiddle = gradientMiddle
+ _sliderRangeGradientEnd = gradientEnd
+ }
+
+ // Thumb
+ _thumbSize = attr.getDimension(
+ R.styleable.TimeRangePicker_trp_thumbSize,
+ _thumbSize.toFloat()
+ ).toInt()
+ _thumbSizeActiveGrow = attr.getFloat(R.styleable.TimeRangePicker_trp_thumbSizeActiveGrow, _thumbSizeActiveGrow)
+
+ val thumbColor = attr.getColor(R.styleable.TimeRangePicker_trp_thumbColor, 0)
+ _thumbColor = if(thumbColor == 0) _thumbColor else thumbColor
+ _thumbColorAuto = thumbColor == 0
+ val iconColor = attr.getColor(R.styleable.TimeRangePicker_trp_thumbIconColor, 0)
+ _thumbIconColor = if(iconColor == 0) null else iconColor
+ val iconSize = attr.getDimension(R.styleable.TimeRangePicker_trp_thumbIconSize, -1f)
+ _thumbIconSize = if(iconSize == -1f) null else iconSize.toInt()
+ _thumbIconStart = attr.getDrawable(R.styleable.TimeRangePicker_trp_thumbIconStart)?.mutate()
+ _thumbIconEnd = attr.getDrawable(R.styleable.TimeRangePicker_trp_thumbIconEnd)?.mutate()
+
+ // Clock
+ _clockVisible =
+ attr.getBoolean(R.styleable.TimeRangePicker_trp_clockVisible, _clockVisible)
+ _clockFace = ClockFace.fromId(
+ attr.getInt(
+ R.styleable.TimeRangePicker_trp_clockFace,
+ _clockFace.id
+ )
+ ) ?: _clockFace
+
+ _clockLabelSize = attr.getDimensionPixelSize(
+ R.styleable.TimeRangePicker_trp_clockLabelSize,
+ _clockLabelSize
+ )
+ _clockLabelColor =
+ attr.getColor(R.styleable.TimeRangePicker_trp_clockLabelColor, _clockLabelColor)
+ _clockTickColor = attr.getColor(
+ R.styleable.TimeRangePicker_trp_clockTickColor,
+ _clockTickColor
+ )
+ } finally {
+ attr.recycle()
+ }
+ }
+
+ private fun updateMiddlePoint() {
+ _middlePoint.set(width / 2f, height / 2f)
+ }
+
+ private fun updatePaint() {
+ _thumbStartPaint.apply {
+ style = Paint.Style.FILL
+ color = if (_thumbColorAuto && _isGradientSlider) _sliderRangeGradientStart!! else _thumbColor
+ }
+ _thumbEndPaint.apply {
+ style = Paint.Style.FILL
+ color = if (_thumbColorAuto && _isGradientSlider) _sliderRangeGradientEnd!! else _thumbColor
+ }
+
+ _sliderPaint.apply {
+ style = Paint.Style.STROKE
+ strokeWidth = _sliderWidth.toFloat()
+ color = _sliderColor
+ }
+ _sliderRangePaint.apply {
+ style = Paint.Style.STROKE
+ strokeWidth = _sliderWidth.toFloat()
+ color = _sliderRangeColor
+ }
+
+ if (_isGradientSlider) {
+ updateGradient()
+ } else {
+ _sliderRangePaint.shader = null
+ }
+
+ postInvalidate()
+ }
+
+ private fun updateGradient() {
+ if (!_isGradientSlider) {
+ return
+ }
+
+ val sweepAngle =
+ angleTo360(_angleStart - _angleEnd)
+
+ val positions = if (_sliderRangeGradientMiddle == null) {
+ floatArrayOf(0f, sweepAngle / 360f)
+ } else {
+ floatArrayOf(0f, (sweepAngle / 360f) / 2, sweepAngle / 360f)
+ }
+ val colors = if (_sliderRangeGradientMiddle == null) {
+ intArrayOf(_sliderRangeGradientStart!!, _sliderRangeGradientEnd!!)
+ } else {
+ intArrayOf(_sliderRangeGradientStart!!, _sliderRangeGradientMiddle!!, _sliderRangeGradientEnd!!)
+ }
+
+ val gradient: Shader =
+ SweepGradient(_middlePoint.x, _middlePoint.y, colors, positions)
+ val gradientMatrix = Matrix()
+ gradientMatrix.preRotate(-_angleStart, _middlePoint.x, _middlePoint.y)
+ gradient.setLocalMatrix(gradientMatrix)
+ _sliderRangePaint.shader = gradient
+ }
+
+ private fun updateThumbIconColors() {
+ if (_thumbIconColor != null) {
+ if(_thumbIconStart != null) {
+ DrawableCompat.setTint(_thumbIconStart!!, _thumbIconColor!!)
+ }
+ if(_thumbIconEnd != null) {
+ DrawableCompat.setTint(_thumbIconEnd!!, _thumbIconColor!!)
+ }
+ }
+
+ postInvalidate()
+ }
+
+ public override fun onSaveInstanceState(): Parcelable {
+ return SavedState(super.onSaveInstanceState()).apply {
+ angleStart = _angleStart
+ angleEnd = _angleEnd
+ }
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable) {
+ if (state is SavedState) {
+ super.onRestoreInstanceState(state.superState)
+ _angleStart = state.angleStart
+ _angleEnd = state.angleEnd
+ } else {
+ super.onRestoreInstanceState(state)
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldWidth: Int, oldHeight: Int) {
+ super.onSizeChanged(w, h, oldWidth, oldHeight)
+
+ updateMiddlePoint()
+ updateGradient()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ val squareSize = min(measuredWidth, measuredHeight)
+ super.onMeasure(
+ MeasureSpec.makeMeasureSpec(squareSize, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(squareSize, MeasureSpec.EXACTLY)
+ )
+ }
+
+ @SuppressLint("DrawAllocation")
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ if (_clockVisible) {
+ _clockRenderer.render(canvas, _radius - max(_thumbSize, _sliderWidth) / 2f - 8.px)
+ }
+
+ _sliderRect.set(
+ _middlePoint.x - _radius,
+ _middlePoint.y - _radius,
+ _middlePoint.x + _radius,
+ _middlePoint.y + _radius
+ )
+
+ val sweepAngle =
+ angleTo360(_angleStart - _angleEnd)
+
+ canvas.drawCircle(
+ _middlePoint.x,
+ _middlePoint.y,
+ _radius,
+ _sliderPaint
+ )
+
+ val startThumb = getThumbPosition(
+ angleTo360(_angleStart)
+ )
+ val endThumb = getThumbPosition(_angleEnd)
+
+ // Draw start thumb
+ canvas.drawArc(
+ _sliderRect,
+ -_angleStart - 0.25f,
+ sweepAngle / 2f + 0.5f,
+ false,
+ _sliderRangePaint
+ )
+ drawRangeCap(
+ canvas,
+ startThumb,
+ 0f,
+ if (_isGradientSlider) _sliderRangeGradientStart!! else _sliderRangeColor
+ )
+ drawThumb(canvas, _thumbStartPaint, _thumbIconStart, _activeThumb == Thumb.START, startThumb.x, startThumb.y)
+
+ // Draw end thumb
+ canvas.drawArc(
+ _sliderRect,
+ -_angleStart + sweepAngle / 2f - 0.25f,
+ sweepAngle / 2f + 0.5f,
+ false,
+ _sliderRangePaint
+ )
+ drawRangeCap(
+ canvas,
+ endThumb,
+ 180f,
+ if (_isGradientSlider) _sliderRangeGradientEnd!! else _sliderRangeColor
+ )
+ drawThumb(canvas, _thumbEndPaint, _thumbIconEnd, _activeThumb == Thumb.END, endThumb.x, endThumb.y)
+ }
+
+ private fun drawRangeCap(
+ canvas: Canvas,
+ position: PointF,
+ rotation: Float, @ColorInt color: Int
+ ) {
+ val capAngle = Math.toDegrees(
+ atan2(
+ _middlePoint.x - position.x,
+ position.y - _middlePoint.y
+ ).toDouble()
+ ).toFloat()
+ _gradientPaint.color = color
+
+ _sliderCapRect.set(
+ position.x - _sliderWidth / 2f,
+ position.y - _sliderWidth / 2f,
+ position.x + _sliderWidth / 2f,
+ position.y + _sliderWidth / 2f
+ )
+ canvas.drawArc(
+ _sliderCapRect,
+ capAngle - 90 + rotation,
+ 180f,
+ true,
+ _gradientPaint
+ )
+ }
+
+ private fun drawThumb(canvas: Canvas, paint: Paint, icon: Drawable?, active: Boolean, x: Float, y: Float) {
+ val grow = if(active) _thumbSizeActiveGrow else 1f
+ val thumbRadius = (_thumbSize.toFloat() * grow) / 2f
+ canvas.drawCircle(
+ x,
+ y,
+ thumbRadius,
+ paint
+ )
+
+ if (icon != null) {
+ val iconSize =
+ _thumbIconSize?.toFloat() ?: min(24.px.toFloat(), _thumbSize * 0.625f)
+ icon.setBounds(
+ (x - iconSize / 2).toInt(),
+ (y - iconSize / 2).toInt(),
+ (x + iconSize / 2).toInt(),
+ (y + iconSize / 2f).toInt()
+ )
+ icon.draw(canvas)
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ val touchAngle = Math.toDegrees(
+ atan2(
+ _middlePoint.y - event.y,
+ event.x - _middlePoint.x
+ ).toDouble()
+ ).toFloat()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ _activeThumb = getClosestThumb(event.x, event.y)
+ return if (_activeThumb != Thumb.NONE) {
+ val targetAngleRad = if (_activeThumb == Thumb.END) _angleEnd else _angleStart
+ _touchOffsetAngle = differenceBetweenAngles(
+ targetAngleRad,
+ touchAngle
+ )
+
+ postInvalidate()
+
+ return onDragChangeListener?.onDragStart(_activeThumb!!) ?: true
+ } else {
+ false
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (_activeThumb == Thumb.START || _activeThumb == Thumb.BOTH) {
+ val difference =
+ differenceBetweenAngles(_angleStart, touchAngle) - _touchOffsetAngle
+ val newStartAngle = angleTo720(_angleStart + difference)
+ val newDurationMinutes = durationBetweenMinutes(
+ angleToPreciseMinutes(newStartAngle, _hourFormat),
+ angleToPreciseMinutes(_angleEnd, _hourFormat)
+ )
+
+ if (_activeThumb == Thumb.BOTH) {
+ _angleStart = newStartAngle
+ _angleEnd = angleTo720(_angleEnd + difference)
+ } else {
+ _angleStart = when {
+ newDurationMinutes < _minDurationMinutes -> _angleEnd + simpleMinutesToAngle(
+ _minDurationMinutes,
+ _hourFormat
+ )
+ newDurationMinutes > _maxDurationMinutes -> _angleEnd + simpleMinutesToAngle(
+ _maxDurationMinutes,
+ _hourFormat
+ )
+ else -> newStartAngle
+ }
+ }
+ } else if (_activeThumb == Thumb.END) {
+ val difference =
+ differenceBetweenAngles(_angleEnd, touchAngle) - _touchOffsetAngle
+ val newEndAngle = angleTo720(_angleEnd + difference)
+ val newDurationMinutes = durationBetweenMinutes(
+ angleToPreciseMinutes(_angleStart, _hourFormat),
+ angleToPreciseMinutes(newEndAngle, _hourFormat)
+ )
+
+ _angleEnd = when {
+ newDurationMinutes < _minDurationMinutes -> _angleStart - simpleMinutesToAngle(
+ _minDurationMinutes,
+ _hourFormat
+ )
+ newDurationMinutes > _maxDurationMinutes -> _angleStart - simpleMinutesToAngle(
+ _maxDurationMinutes,
+ _hourFormat
+ )
+ else -> newEndAngle
+ }
+ }
+
+ anglesChanged(_activeThumb!!)
+ postInvalidate()
+ return true
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ _angleStart = minutesToAngle(
+ startTimeMinutes,
+ _hourFormat
+ )
+ _angleEnd = minutesToAngle(
+ endTimeMinutes,
+ _hourFormat
+ )
+
+ updateGradient()
+ postInvalidate()
+
+ onDragChangeListener?.onDragStop(_activeThumb!!)
+ _activeThumb = Thumb.NONE
+ return true
+ }
+ }
+
+ return false
+ }
+
+ private fun anglesChanged(thumb: Thumb) {
+ updateGradient()
+
+ if (onTimeChangeListener != null) {
+ if (thumb == Thumb.START || thumb == Thumb.BOTH) {
+ onTimeChangeListener?.onStartTimeChange(startTime)
+ }
+ if (thumb == Thumb.END || thumb == Thumb.BOTH) {
+ onTimeChangeListener?.onEndTimeChange(endTime)
+ }
+ if (thumb == Thumb.START || thumb == Thumb.END) {
+ onTimeChangeListener?.onDurationChange(duration)
+ }
+ }
+ }
+
+ private fun getClosestThumb(touchX: Float, touchY: Float): Thumb {
+ val startThumb = getThumbPosition(
+ angleTo360(_angleStart)
+ )
+ val endThumb = getThumbPosition(_angleEnd)
+ val distanceFromMiddle =
+ MathUtils.distanceBetweenPoints(_middlePoint.x, _middlePoint.y, touchX, touchY)
+ if (MathUtils.isPointInCircle(
+ touchX,
+ touchY,
+ endThumb.x,
+ endThumb.y,
+ _thumbSize * 2f
+ )
+ ) {
+ return Thumb.END
+ } else if (MathUtils.isPointInCircle(
+ touchX,
+ touchY,
+ startThumb.x,
+ startThumb.y,
+ _thumbSize * 2f
+ )
+ ) {
+ return Thumb.START
+ } else if (distanceFromMiddle > _radius - _sliderWidth * 2 && distanceFromMiddle < _radius + _sliderWidth * 2) {
+ return Thumb.BOTH
+ }
+
+ return Thumb.NONE
+ }
+
+ private fun getThumbPosition(
+ angle: Float
+ ): PointF {
+ return PointF(
+ (_middlePoint.x + _radius * cos(Math.toRadians(-angle.toDouble()))).toFloat(),
+ (_middlePoint.y + _radius * sin(Math.toRadians(-angle.toDouble()))).toFloat()
+ )
+ }
+
+ private fun parseTimeString(time: String): Time {
+ if (!time.matches("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]\$".toRegex())) {
+ throw IllegalArgumentException("Format of time value '$time' is invalid, expected format hh:mm.")
+ }
+
+ val split = time.split(":")
+ return Time(split[0].toInt(), split[1].toInt())
+ }
+
+ fun setOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener) {
+ this.onTimeChangeListener = onTimeChangeListener
+ }
+
+ fun setOnDragChangeListener(onDragChangeListener: OnDragChangeListener) {
+ this.onDragChangeListener = onDragChangeListener
+ }
+
+ // Time
+ var hourFormat
+ get() = _hourFormat
+ set(value) {
+ val prevFormat = _hourFormat
+
+ _hourFormat = if (value == HourFormat.FORMAT_SYSTEM) {
+ if (DateFormat.is24HourFormat(context)) HourFormat.FORMAT_24 else HourFormat.FORMAT_12
+ } else {
+ value
+ }
+
+ _angleStart = minutesToAngle(angleToMinutes(_angleStart, prevFormat), _hourFormat)
+ _angleEnd = minutesToAngle(angleToMinutes(_angleEnd, prevFormat), _hourFormat)
+
+ updateGradient()
+ postInvalidate()
+ }
+
+ var startTime: Time
+ get() = Time(
+ startTimeMinutes
+ )
+ set(value) {
+ _angleStart = minutesToAngle(value.totalMinutes, _hourFormat)
+ postInvalidate()
+ }
+
+ var startTimeMinutes: Int
+ get() = snapMinutes(
+ angleToMinutes(_angleStart, _hourFormat), _stepTimeMinutes
+ )
+ set(value) {
+ _angleStart = minutesToAngle(value, _hourFormat)
+ postInvalidate()
+ }
+
+ var endTime: Time
+ get() = Time(
+ endTimeMinutes
+ )
+ set(value) {
+ _angleStart = minutesToAngle(value.totalMinutes, _hourFormat)
+ postInvalidate()
+ }
+
+ var endTimeMinutes: Int
+ get() = snapMinutes(
+ angleToMinutes(_angleEnd, _hourFormat), _stepTimeMinutes
+ )
+ set(value) {
+ _angleEnd = minutesToAngle(value, _hourFormat)
+ postInvalidate()
+ }
+
+ val duration: TimeDuration
+ get() = TimeDuration(startTime, endTime)
+
+ val durationMinutes: Int
+ get() = duration.durationMinutes
+
+ var minDuration: Time
+ get() = Time(_minDurationMinutes)
+ set(value) {
+ minDurationMinutes = value.totalMinutes
+ }
+
+ var minDurationMinutes: Int
+ get() = _minDurationMinutes
+ set(value) {
+ if (value < 0 || value > 24 * 60) {
+ throw java.lang.IllegalArgumentException("Minimum duration has to be between 00:00 and 24:00");
+ }
+
+ if (value > _maxDurationMinutes) {
+ throw IllegalArgumentException("Minimum duration cannot be greater than the maximum duration.");
+ }
+
+ _minDurationMinutes = value
+
+ if (durationMinutes < _minDurationMinutes) {
+ _angleEnd = minutesToAngle(
+ endTimeMinutes + abs(durationMinutes - _maxDurationMinutes),
+ _hourFormat
+ )
+ postInvalidate()
+ }
+ }
+
+ var maxDuration: Time
+ get() = Time(_maxDurationMinutes)
+ set(value) {
+ maxDurationMinutes = value.totalMinutes
+ }
+
+ var maxDurationMinutes: Int
+ get() = _maxDurationMinutes
+ set(value) {
+ if (value < 0 || value > 24 * 60) {
+ throw java.lang.IllegalArgumentException("Maximum duration has to be between 00:00 and 24:00");
+ }
+
+ if (value < _minDurationMinutes) {
+ throw IllegalArgumentException("Maximum duration cannot be less than the minimum duration.");
+ }
+
+ _maxDurationMinutes = value
+
+ if (durationMinutes > _maxDurationMinutes) {
+ _angleEnd = minutesToAngle(
+ endTimeMinutes - abs(durationMinutes - _maxDurationMinutes),
+ _hourFormat
+ )
+ postInvalidate()
+ }
+ }
+
+ var stepTimeMinutes
+ get() = _stepTimeMinutes
+ set(value) {
+ if (value > 24 * 60) {
+ throw IllegalArgumentException("Minutes per step cannot be above 24 hours (24 * 60).")
+ }
+
+ _stepTimeMinutes = value
+ postInvalidate()
+ }
+
+ // Slider
+ var sliderWidth
+ get() = _sliderWidth
+ set(@ColorInt value) {
+ _sliderWidth = value
+ updatePaint()
+ }
+
+ var sliderColor
+ @ColorInt
+ get() = _sliderColor
+ set(@ColorInt value) {
+ _sliderColor = value
+ updatePaint()
+ }
+
+ var sliderColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ sliderColor = ContextCompat.getColor(context, value)
+ }
+
+ var sliderRangeColor
+ @ColorInt
+ get() = _sliderRangeColor
+ set(@ColorInt value) {
+ _sliderRangeGradientStart = null
+ _sliderRangeGradientEnd = null
+ _sliderRangeColor = value
+ updatePaint()
+ }
+
+ var sliderRangeColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ sliderRangeColor = ContextCompat.getColor(context, value)
+ }
+
+ var sliderRangeGradientStart
+ @ColorInt
+ get() = _sliderRangeGradientStart
+ set(@ColorInt value) {
+ _sliderRangeGradientStart = value
+ updatePaint()
+ }
+
+ var sliderRangeGradientStartRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ sliderRangeGradientStart = ContextCompat.getColor(context, value)
+ }
+
+ var sliderRangeGradientMiddle
+ @ColorInt
+ get() = _sliderRangeGradientMiddle
+ set(@ColorInt value) {
+ _sliderRangeGradientMiddle = value
+ updatePaint()
+ }
+
+ var sliderRangeGradientMiddleRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ sliderRangeGradientMiddle = ContextCompat.getColor(context, value)
+ }
+
+ var sliderRangeGradientEnd
+ @ColorInt
+ get() = _sliderRangeGradientEnd
+ set(@ColorInt value) {
+ _sliderRangeGradientEnd = value
+ updatePaint()
+ }
+
+ var sliderRangeGradientEndRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ sliderRangeGradientEnd = ContextCompat.getColor(context, value)
+ }
+
+ // Thumb
+ var thumbSize
+ get() = _thumbSize
+ set(@ColorInt value) {
+ _thumbSize = value
+ updatePaint()
+ }
+
+ var thumbSizeActiveGrow
+ get() = _thumbSizeActiveGrow
+ set(value) {
+ _thumbSizeActiveGrow = value
+ postInvalidate()
+ }
+
+ var thumbColor
+ @ColorInt
+ get() = _thumbColor
+ set(@ColorInt value) {
+ _thumbColor = value
+ _thumbColorAuto = false
+ updatePaint()
+ }
+
+ var thumbColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ thumbColor = ContextCompat.getColor(context, value)
+ }
+
+ var thumbColorAuto
+ get() = _thumbColorAuto
+ set(value) {
+ _thumbColorAuto = value
+ updatePaint()
+ }
+
+ var thumbIconStart
+ get() = _thumbIconStart
+ set(value) {
+ _thumbIconStart = value?.mutate()
+ updateThumbIconColors()
+ }
+
+ var thumbIconStartRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@DrawableRes value) {
+ thumbIconStart = ContextCompat.getDrawable(context, value)
+ }
+
+ var thumbIconEnd
+ get() = _thumbIconEnd
+ set(value) {
+ _thumbIconEnd = value?.mutate()
+ updateThumbIconColors()
+ }
+
+ var thumbIconEndRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@DrawableRes value) {
+ thumbIconEnd = ContextCompat.getDrawable(context, value)
+ }
+
+ var thumbIconSize
+ get() = _thumbIconSize
+ set(@ColorInt value) {
+ _thumbIconSize = value
+ postInvalidate()
+ }
+
+ var thumbIconColor
+ @ColorInt
+ get() = _thumbIconColor
+ set(@ColorInt value) {
+ _thumbIconColor = value
+ updateThumbIconColors()
+ }
+
+ var thumbIconColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ thumbIconColor = ContextCompat.getColor(context, value)
+ }
+
+ // Clock
+ var clockVisible
+ get() = _clockVisible
+ set(value) {
+ _clockVisible = value
+ postInvalidate()
+ }
+
+ var clockFace
+ get() = _clockFace
+ set(value) {
+ _clockFace = value
+ postInvalidate()
+ }
+
+ var clockTickColor
+ @ColorInt
+ get() = _clockTickColor
+ set(@ColorInt value) {
+ _clockTickColor = value
+ _clockRenderer.updatePaint()
+ postInvalidate()
+ }
+
+ var clockTickColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ clockTickColor = ContextCompat.getColor(context, value)
+ }
+
+ var clockLabelColor
+ @ColorInt
+ get() = _clockLabelColor
+ set(@ColorInt value) {
+ _clockLabelColor = value
+ _clockRenderer.updatePaint()
+ postInvalidate()
+ }
+
+ var clockLabelColorRes
+ @Deprecated("", level = DeprecationLevel.HIDDEN)
+ get() = 0
+ set(@ColorRes value) {
+ clockLabelColor = ContextCompat.getColor(context, value)
+ }
+
+ var clockLabelSize
+ @Dimension
+ get() = _clockLabelSize
+ set(@Dimension value) {
+ _clockLabelSize = value
+ _clockRenderer.updatePaint()
+ postInvalidate()
+ }
+
+ open class Time(val totalMinutes: Int) {
+ constructor(hr: Int, min: Int) : this(hr * 60 + min)
+
+ val hour: Int
+ get() = totalMinutes / 60 % 24
+ val minute: Int
+ get() = totalMinutes % 60
+
+ val localTime: LocalTime
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() = LocalTime.of(hour, minute)
+
+ val calendar: Calendar
+ get() = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, hour)
+ set(Calendar.MINUTE, minute)
+ }
+
+ override fun toString(): String {
+ return "$hour:${minute.toString().padStart(2, '0')}"
+ }
+ }
+
+ open class TimeDuration(val start: Time, val end: Time) {
+ val durationMinutes: Int
+ get() {
+ return if (start.totalMinutes > end.totalMinutes) {
+ 24 * 60 - (start.totalMinutes - end.totalMinutes)
+ } else {
+ end.totalMinutes - start.totalMinutes
+ }
+ }
+
+ val hour: Int
+ get() = durationMinutes / 60 % 24
+ val minute: Int
+ get() = durationMinutes % 60
+
+ val duration: Duration
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() = Duration.ofMinutes(durationMinutes.toLong())
+
+ val classicDuration: javax.xml.datatype.Duration
+ @RequiresApi(Build.VERSION_CODES.FROYO)
+ get() =
+ DatatypeFactory.newInstance().newDuration(true, 0, 0, 0, hour, minute, 0)
+
+ override fun toString(): String {
+ return "$hour:${minute.toString().padStart(2, '0')}"
+ }
+ }
+
+ enum class Thumb {
+ NONE, START, END, BOTH
+ }
+
+ enum class HourFormat(val id: Int) {
+ FORMAT_SYSTEM(0),
+ FORMAT_12(1),
+ FORMAT_24(2);
+
+ companion object {
+ fun fromId(id: Int): HourFormat? {
+ for (f in values()) {
+ if (f.id == id) return f
+ }
+ throw IllegalArgumentException()
+ }
+ }
+ }
+
+ enum class ClockFace(val id: Int) {
+ APPLE(0),
+ SAMSUNG(1);
+
+ companion object {
+ fun fromId(id: Int): ClockFace? {
+ for (f in values()) {
+ if (f.id == id) return f
+ }
+ throw IllegalArgumentException()
+ }
+ }
+ }
+
+ interface OnTimeChangeListener {
+ fun onStartTimeChange(startTime: Time)
+ fun onEndTimeChange(endTime: Time)
+ fun onDurationChange(duration: TimeDuration)
+ }
+
+ interface OnDragChangeListener {
+ fun onDragStart(thumb: Thumb): Boolean
+ fun onDragStop(thumb: Thumb)
+ }
+}
\ No newline at end of file
diff --git a/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/Extensions.kt b/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/Extensions.kt
new file mode 100644
index 0000000..3cf5ecb
--- /dev/null
+++ b/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/Extensions.kt
@@ -0,0 +1,46 @@
+package nl.joery.timerangepicker.utils
+
+import android.content.Context
+import android.content.res.Resources
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+
+@ColorInt
+internal fun Context.getColorResCompat(@AttrRes id: Int): Int {
+ return ContextCompat.getColor(this, getResourceId(id))
+}
+
+@ColorInt
+internal fun Context.getTextColor(@AttrRes id: Int): Int {
+ val typedValue = TypedValue()
+ theme.resolveAttribute(id, typedValue, true)
+ val arr = obtainStyledAttributes(
+ typedValue.data, intArrayOf(
+ id
+ )
+ )
+ val color = arr.getColor(0, -1)
+ arr.recycle()
+ return color
+}
+
+internal fun Context.getResourceId(id: Int): Int {
+ val resolvedAttr = TypedValue()
+ theme.resolveAttribute(id, resolvedAttr, true)
+ return resolvedAttr.run { if (resourceId != 0) resourceId else data }
+}
+
+internal val Int.px: Int
+ get() = (this * Resources.getSystem().displayMetrics.density).toInt()
+
+internal val Int.dp: Int
+ get() = (this / Resources.getSystem().displayMetrics.density).toInt()
+
+internal val Int.sp: Int
+ get() = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_SP,
+ this.toFloat(),
+ Resources.getSystem().displayMetrics
+ ).toInt()
\ No newline at end of file
diff --git a/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/MathUtils.kt b/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/MathUtils.kt
new file mode 100644
index 0000000..a193b8f
--- /dev/null
+++ b/timerangepicker/src/main/java/nl/joery/timerangepicker/utils/MathUtils.kt
@@ -0,0 +1,95 @@
+package nl.joery.timerangepicker.utils
+
+import nl.joery.timerangepicker.TimeRangePicker
+import kotlin.math.*
+
+internal object MathUtils {
+ fun differenceBetweenAngles(a1: Float, a2: Float): Float {
+ val angle1 = Math.toRadians(a1.toDouble())
+ val angle2 = Math.toRadians(a2.toDouble())
+
+ return Math.toDegrees(
+ atan2(
+ cos(angle1) * sin(angle2) - sin(angle1) * cos(angle2),
+ cos(angle1) * cos(angle2) + sin(angle1) * sin(angle2)
+ )
+ ).toFloat()
+ }
+
+ fun angleTo360(angle: Float): Float {
+ var result = angle % 360
+ if (result < 0) {
+ result += 360.0f
+ }
+ return result
+ }
+
+ fun angleTo720(angle: Float): Float {
+ var result = angle % 720
+ if (result < 0) {
+ result += 720.0f
+ }
+ return result
+ }
+
+ fun simpleMinutesToAngle(minutes: Int, hourFormat: TimeRangePicker.HourFormat): Float {
+ return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) {
+ minutes / (12 * 60.0f) * 360.0f
+ } else {
+ minutes / (24 * 60.0f) * 360.0f
+ }
+ }
+
+ fun minutesToAngle(minutes: Int, hourFormat: TimeRangePicker.HourFormat): Float {
+ return angleTo720(90 - simpleMinutesToAngle(minutes, hourFormat))
+ }
+
+ fun angleToPreciseMinutes(angle: Float, hourFormat: TimeRangePicker.HourFormat): Float {
+ return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) {
+ (angleTo720(90 - angle) / 360 * 12 * 60) % (24 * 60)
+ } else {
+ (angleTo720(90 - angle) / 360 * 24 * 60) % (24 * 60)
+ }
+ }
+
+ fun angleToMinutes(angle: Float, hourFormat: TimeRangePicker.HourFormat): Int {
+ return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) {
+ (angleTo720(90 - angle) / 360 * 12 * 60).roundToInt() % (24 * 60)
+ } else {
+ (angleTo720(90 - angle) / 360 * 24 * 60).roundToInt() % (24 * 60)
+ }
+ }
+
+ fun snapMinutes(minutes: Int, step: Int): Int {
+ return minutes / step * step + 2 * (minutes % step) / step * step
+ }
+
+ fun isPointInCircle(
+ x: Float,
+ y: Float,
+ cx: Float,
+ cy: Float,
+ radius: Float
+ ): Boolean {
+ return sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) < radius
+ }
+
+ fun distanceBetweenPoints(
+ x1: Float,
+ y1: Float,
+ x2: Float,
+ y2: Float
+ ): Float {
+ val deltaX = x1 - x2
+ val deltaY = y1 - y2
+ return sqrt(deltaX * deltaX + deltaY * deltaY)
+ }
+
+ fun durationBetweenMinutes(startMinutes: Float, endMinutes: Float): Float {
+ return if (startMinutes > endMinutes) {
+ 24f * 60f - (startMinutes - endMinutes)
+ } else {
+ endMinutes - startMinutes
+ }
+ }
+}
\ No newline at end of file
diff --git a/timerangepicker/src/main/res/values/attrs.xml b/timerangepicker/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..bc046eb
--- /dev/null
+++ b/timerangepicker/src/main/res/values/attrs.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/timerangepicker/src/test/java/nl/joery/timerangepicker/ExampleUnitTest.kt b/timerangepicker/src/test/java/nl/joery/timerangepicker/ExampleUnitTest.kt
new file mode 100644
index 0000000..5a42e3f
--- /dev/null
+++ b/timerangepicker/src/test/java/nl/joery/timerangepicker/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package nl.joery.timerangepicker
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file