├── .gitignore ├── LICENSE ├── README.md ├── art ├── demo.apk ├── example-2.png ├── example-3.png ├── example-animated.gif ├── face-apple.png ├── face-samsung.png ├── getting-started.png └── playground-demo.png ├── build.gradle ├── demo ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── nl │ │ └── joery │ │ └── demo │ │ └── timerangepicker │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── nl │ │ │ └── joery │ │ │ └── demo │ │ │ └── timerangepicker │ │ │ ├── ExampleActivity.kt │ │ │ ├── Extensions.kt │ │ │ ├── ReflectionUtils.kt │ │ │ ├── Utils.kt │ │ │ └── playground │ │ │ ├── PlaygroundActivity.kt │ │ │ ├── PropertyAdapter.kt │ │ │ ├── XmlGenerator.kt │ │ │ └── properties │ │ │ ├── BooleanProperty.kt │ │ │ ├── CategoryProperty.kt │ │ │ ├── ColorProperty.kt │ │ │ ├── EnumProperty.kt │ │ │ ├── FloatProperty.kt │ │ │ ├── IntegerProperty.kt │ │ │ ├── InterpolatorProperty.kt │ │ │ └── Property.kt │ └── res │ │ ├── drawable │ │ ├── ic_alarm.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_moon.xml │ │ ├── numeric_1_box_outline.xml │ │ ├── numeric_2_box_outline.xml │ │ └── numeric_3_box_outline.xml │ │ ├── layout │ │ ├── activity_example.xml │ │ ├── activity_playground.xml │ │ ├── list_property.xml │ │ ├── list_property_boolean.xml │ │ ├── list_property_category.xml │ │ ├── list_property_color.xml │ │ ├── view_generated_xml.xml │ │ └── view_text_input.xml │ │ ├── menu │ │ └── tabs.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── nl │ └── joery │ └── demo │ └── timerangepicker │ └── ExampleUnitTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── timerangepicker ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src ├── androidTest └── java │ └── nl │ └── joery │ └── timerangepicker │ └── ExampleInstrumentedTest.kt ├── main ├── AndroidManifest.xml ├── java │ └── nl │ │ └── joery │ │ └── timerangepicker │ │ ├── BitmapCachedClockRenderer.kt │ │ ├── ClockRenderer.kt │ │ ├── DefaultClockRenderer.kt │ │ ├── SavedState.kt │ │ ├── TimeRangePicker.kt │ │ └── utils │ │ ├── Extensions.kt │ │ ├── MathUtils.kt │ │ ├── ReflectionUtils.kt │ │ └── StringUtils.kt └── res │ └── values │ └── attrs.xml └── test └── java └── nl └── joery └── timerangepicker ├── ExampleUnitTest.kt ├── ReflectionUtilsTests.kt └── TimeRangePickerTests.kt /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | release/ 18 | 19 | # Gradle files 20 | .gradle/ 21 | build/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | 26 | # Proguard folder generated by Eclipse 27 | proguard/ 28 | 29 | # Log Files 30 | *.log 31 | 32 | # Android Studio Navigation editor temp files 33 | .navigation/ 34 | 35 | # Android Studio captures folder 36 | captures/ 37 | 38 | # IntelliJ 39 | *.iml 40 | .idea/ 41 | 42 | # Keystore files 43 | *.jks 44 | *.keystore 45 | 46 | # External native build folder generated in Android Studio 2.2 and later 47 | .externalNativeBuild 48 | .cxx/ 49 | 50 | # Freeline 51 | freeline.py 52 | freeline/ 53 | freeline_project_description.json 54 | 55 | # fastlane 56 | fastlane/report.xml 57 | fastlane/Preview.html 58 | fastlane/screenshots 59 | fastlane/test_output 60 | fastlane/readme.md 61 | 62 | # Version control 63 | vcs.xml 64 | 65 | # lint 66 | lint/intermediates/ 67 | lint/generated/ 68 | lint/outputs/ 69 | lint/tmp/ 70 | lint/reports/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joery 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

⏰ TimeRangePicker


2 |

3 | 4 |

5 | 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. 6 |

7 | 8 |
9 |    10 |    11 |    12 |   By Joery Droppers 13 |
14 | 15 | ## Screenshots 16 | 17 | 18 | ## Playground app 19 | 20 | 21 |      22 | 23 |

24 | Download the playground app from Google Play, with this app you can try out all features and even generate XML with your selected configuration. 25 | 26 | ## Getting started 27 | This library is available on Maven Central, add the following dependency to your build.gradle: 28 | ```gradle 29 | implementation 'nl.joery.timerangepicker:timerangepicker:1.0.0' 30 | ``` 31 | Define `TimeRangePicker` in your XML layout with custom attributes. See the [Configuration](#configuration) section for more information. 32 | ```xml 33 | 41 | ``` 42 | 43 | Get notified when the time or duration changes: 44 | ```kotlin 45 | picker.setOnTimeChangeListener(object : TimeRangePicker.OnTimeChangeListener { 46 | override fun onStartTimeChange(startTime: TimeRangePicker.Time) { 47 | Log.d("TimeRangePicker", "Start time: " + startTime) 48 | } 49 | 50 | override fun onEndTimeChange(endTime: TimeRangePicker.Time) { 51 | Log.d("TimeRangePicker", "End time: " + endTime.hour) 52 | } 53 | 54 | override fun onDurationChange(duration: TimeRangePicker.TimeDuration) { 55 | Log.d("TimeRangePicker", "Duration: " + duration.hour) 56 | } 57 | }) 58 | ``` 59 | 60 | ## Managing picker programmatically 61 | ### Managing time 62 | Examples of how to set and retrieve start time programmatically, identical properties are available for the end time. 63 | 64 | ```kotlin 65 | // Set new time with 'Time' object to 12:00 66 | picker.startTime = TimeRangePicker.Time(12, 0) 67 | // Set new time by minutes 68 | picker.startTimeMinutes = 320 69 | ``` 70 | 71 | Time 72 | When retrieving the start or end time, the library will provide a `TimeRangePicker.Time` object. 73 | - Use `time.hour`, `time.minute` or `time.totalMinutes` to retrieve literal time. 74 | - Use `time.calendar` to retrieve a `java.util.Calendar` object. 75 | - Use `time.localTime` to retrieve a `java.time.LocalTime` object. (Available since API 26) 76 | 77 | ### Managing duration 78 | When retrieving the duration between the start and end time, the library will provide a `TimeRangePicker.Duration` object. 79 | - Use `duration.hour`, `duration.minute` or `duration.durationMinutes` to retrieve literal duration. 80 | - Use `duration.classicDuration` to retrieve a `javax.xml.datatype.Duration` object. (Available since API 8) 81 | - Use `duration.duration` to retrieve a `java.time.Duration` object. (Available since API 26) 82 | 83 | ### Listening for starting and stopping of dragging 84 | 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. 85 | 86 | ```kotlin 87 | picker.setOnDragChangeListener(object : TimeRangePicker.OnDragChangeListener { 88 | override fun onDragStart(thumb: TimeRangePicker.Thumb): Boolean { 89 | // Do something on start dragging 90 | return true // Return false to disallow the user from dragging a handle. 91 | } 92 | 93 | override fun onDragStop(thumb: TimeRangePicker.Thumb) { 94 | // Do something on stop dragging 95 | } 96 | }) 97 | ``` 98 | 99 | ## Configuration 100 | 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. 101 | ### Time 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
AttributeDescriptionDefault
trp_startTimeSet the start time by providing a time with format h:mm.0:00
trp_startTimeMinutesSet the start time by providing minutes between 0 and 1440 (24 hours).0
trp_endTimeSet the end time by providing a time with format h:mm.8:00
trp_endTimeMinutesSet the end time by providing minutes between 0 and 1440 (24 hours).480
trp_minDurationSet the minimum selectable duration by providing a duration with format h:mm.
trp_maxDurationSet the maximum selectable duration by providing a duration with format h:mm.
trp_maxDurationMinutesSet the maximum selectable duration by providing minutes between 0 and 1440 (24 hours).480
trp_minDurationMinutesSet the minimum selectable duration by providing minutes between 0 and 1440 (24 hours).0
trp_stepTimeMinutesDetermines 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
154 | 155 | ### Slider 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 |
AttributeDescriptionDefault
trp_sliderWidthThe width of the slider wheel.8dp
trp_sliderColorThe background color of the slider wheel.#E1E1E1
trp_sliderRangeColorThe color of the active part of the slider wheel.?android:colorPrimary
trp_sliderRangeGradientStartSet 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_sliderRangeGradientStartOptional for gradient: set the middle gradient color of the active part of the slider wheel.
trp_sliderRangeGradientEndSet 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.
197 | 198 | ### Thumb 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 |
AttributeDescriptionDefault
trp_thumbIconStartSet the start thumb icon.
trp_thumbIconEndSet the end thumb icon.
trp_thumbSizeThe size of both the starting and ending thumb.28dp
trp_thumbSizeActiveGrowThe amount of growth of the size when a thumb is being dragged.1.2
trp_thumbColorThe background color of the thumbs.?android:colorPrimary
trp_thumbIconColorThe color (tint) of the icons inside the thumbs.white
trp_thumbIconSizeThe size of the thumb icons.24dp
241 | 242 | ### Clock 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 290 | 291 | 292 |
AttributeDescriptionDefault
trp_clockVisibleWhether the clock face in the middle should be visible.true
trp_clockFaceThere a two different clock faces (appearance of the inner clock) you can use, both mimicking the Clock apps:
257 | APPLE
258 |
259 | SAMSUNG
260 | 261 |
APPLE
trp_clockLabelSizeThe 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_clockLabelColorSet the text color of the hour labels in the clock.?android:textColorPrimary
trp_clockIndicatorColorSet the color of the small time indicator lines in the clock.?android:textColorPrimary
trp_clockRenderer 282 | Set the clock renderer through passing the full class name (with packages). 283 |
284 | Note that, you should add @Keep annotation to your custom renderer class. 285 |
286 | The renderer class should have a public constructor with one parameter: TimeRangePicker. 287 |
288 | Then renderer will be created through calling that constructor. 289 |
nl.joery.timerangepicker.DefaultClockRenderer
293 | 294 | ## Credits 295 | - Samsung's and Apple's Clock app have been used for inspiration, as they both implement this picker differently. 296 | 297 | ## License 298 | ``` 299 | MIT License 300 | 301 | Copyright (c) 2021 Joery Droppers (https://github.com/Droppers) 302 | 303 | Permission is hereby granted, free of charge, to any person obtaining a copy 304 | of this Software and associated documentation files (the "Software"), to deal 305 | in the Software without restriction, including without limitation the rights 306 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 307 | copies of the Software, and to permit persons to whom the Software is 308 | furnished to do so, subject to the following conditions: 309 | 310 | The above copyright notice and this permission notice shall be included in all 311 | copies or substantial portions of the Software. 312 | 313 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 314 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 315 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 316 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 317 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 318 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 319 | SOFTWARE. 320 | ``` 321 | -------------------------------------------------------------------------------- /art/demo.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/demo.apk -------------------------------------------------------------------------------- /art/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/example-2.png -------------------------------------------------------------------------------- /art/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/example-3.png -------------------------------------------------------------------------------- /art/example-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/example-animated.gif -------------------------------------------------------------------------------- /art/face-apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/face-apple.png -------------------------------------------------------------------------------- /art/face-samsung.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/face-samsung.png -------------------------------------------------------------------------------- /art/getting-started.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/getting-started.png -------------------------------------------------------------------------------- /art/playground-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/art/playground-demo.png -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | ext.kotlin_version = "1.5.21" 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:4.2.2' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | classpath 'com.vanniktech:gradle-maven-publish-plugin:0.15.1' 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | google() 18 | mavenCentral() 19 | } 20 | 21 | plugins.withId("com.vanniktech.maven.publish") { 22 | mavenPublish { 23 | sonatypeHost = "S01" 24 | } 25 | } 26 | } 27 | 28 | task clean(type: Delete) { 29 | delete rootProject.buildDir 30 | } -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /demo/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 30 7 | 8 | defaultConfig { 9 | applicationId "nl.joery.demo.timerangepicker" 10 | minSdkVersion 21 11 | targetSdkVersion 30 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | 25 | buildFeatures { 26 | viewBinding true 27 | } 28 | } 29 | 30 | dependencies { 31 | implementation fileTree(dir: "libs", include: ["*.jar"]) 32 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 33 | implementation 'androidx.core:core-ktx:1.6.0' 34 | implementation 'androidx.appcompat:appcompat:1.3.0' 35 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 36 | 37 | implementation project(path: ':timerangepicker') 38 | 39 | testImplementation 'junit:junit:4.13.2' 40 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 41 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 42 | 43 | implementation 'com.google.android.material:material:1.4.0' 44 | implementation 'com.jaredrummler:colorpicker:1.1.0' 45 | implementation 'nl.joery.animatedbottombar:library:1.1.0' 46 | } -------------------------------------------------------------------------------- /demo/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /demo/src/androidTest/java/nl/joery/demo/timerangepicker/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("nl.joery.demo.timerangepicker", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /demo/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /demo/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/demo/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/ExampleActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.res.Resources 5 | import android.graphics.Color 6 | import android.os.Bundle 7 | import android.util.Log 8 | import androidx.appcompat.app.AppCompatActivity 9 | import kotlinx.android.synthetic.main.activity_example.* 10 | import nl.joery.animatedbottombar.AnimatedBottomBar 11 | import nl.joery.timerangepicker.TimeRangePicker 12 | 13 | class ExampleActivity : AppCompatActivity() { 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContentView(R.layout.activity_example) 17 | 18 | setStyle(R.id.tab_one) 19 | bottom_bar.setOnTabSelectListener(object: AnimatedBottomBar.OnTabSelectListener { 20 | override fun onTabSelected( 21 | lastIndex: Int, 22 | lastTab: AnimatedBottomBar.Tab?, 23 | newIndex: Int, 24 | newTab: AnimatedBottomBar.Tab 25 | ) { 26 | setStyle(newTab.id) 27 | } 28 | }) 29 | 30 | updateTimes() 31 | updateDuration() 32 | 33 | picker.setOnTimeChangeListener(object : TimeRangePicker.OnTimeChangeListener { 34 | override fun onStartTimeChange(startTime: TimeRangePicker.Time) { 35 | updateTimes() 36 | } 37 | 38 | override fun onEndTimeChange(endTime: TimeRangePicker.Time) { 39 | updateTimes() 40 | } 41 | 42 | override fun onDurationChange(duration: TimeRangePicker.TimeDuration) { 43 | updateDuration() 44 | } 45 | }) 46 | 47 | picker.setOnDragChangeListener(object : TimeRangePicker.OnDragChangeListener { 48 | override fun onDragStart(thumb: TimeRangePicker.Thumb): Boolean { 49 | if(thumb != TimeRangePicker.Thumb.BOTH) { 50 | animate(thumb, true) 51 | } 52 | return true 53 | } 54 | 55 | override fun onDragStop(thumb: TimeRangePicker.Thumb) { 56 | if(thumb != TimeRangePicker.Thumb.BOTH) { 57 | animate(thumb, false) 58 | } 59 | 60 | Log.d( 61 | "TimeRangePicker", 62 | "Start time: " + picker.startTime 63 | ) 64 | Log.d( 65 | "TimeRangePicker", 66 | "End time: " + picker.endTime 67 | ) 68 | Log.d( 69 | "TimeRangePicker", 70 | "Total duration: " + picker.duration 71 | ) 72 | } 73 | }) 74 | } 75 | 76 | @SuppressLint("SetTextI18n") 77 | private fun updateTimes() { 78 | end_time.text = picker.endTime.toString() 79 | start_time.text = picker.startTime.toString() 80 | } 81 | 82 | private fun updateDuration() { 83 | duration.text = getString(R.string.duration, picker.duration) 84 | } 85 | 86 | private fun animate(thumb: TimeRangePicker.Thumb, active: Boolean) { 87 | val activeView = if(thumb == TimeRangePicker.Thumb.START) bedtime_layout else wake_layout 88 | val inactiveView = if(thumb == TimeRangePicker.Thumb.START) wake_layout else bedtime_layout 89 | val direction = if(thumb == TimeRangePicker.Thumb.START) 1 else -1 90 | 91 | activeView 92 | .animate() 93 | .translationY(if(active) (activeView.measuredHeight / 2f)*direction else 0f) 94 | .setDuration(300) 95 | .start() 96 | inactiveView 97 | .animate() 98 | .alpha(if(active) 0f else 1f) 99 | .setDuration(300) 100 | .start() 101 | } 102 | 103 | private fun setStyle(id: Int) { 104 | when(id) { 105 | R.id.tab_one -> { 106 | picker.thumbColorAuto = true 107 | picker.thumbSize = 28.px 108 | picker.sliderWidth = 8.px 109 | picker.sliderColor = Color.rgb(238, 238, 236) 110 | picker.thumbIconColor = Color.WHITE 111 | picker.thumbSizeActiveGrow = 1.2f 112 | picker.sliderRangeGradientStart = Color.parseColor("#8287fe") 113 | picker.sliderRangeGradientMiddle = Color.parseColor("#b67cc8") 114 | picker.sliderRangeGradientEnd = Color.parseColor("#ffa301") 115 | picker.clockFace = TimeRangePicker.ClockFace.SAMSUNG 116 | picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_24 117 | } 118 | R.id.tab_two -> { 119 | picker.thumbSize = 36.px 120 | picker.sliderWidth = 40.px 121 | picker.sliderColor = Color.TRANSPARENT 122 | picker.thumbColor = Color.WHITE 123 | picker.sliderRangeGradientStart = Color.parseColor("#F79104") 124 | picker.sliderRangeGradientMiddle = null 125 | picker.sliderRangeGradientEnd = Color.parseColor("#F8C207") 126 | picker.thumbIconColor = Color.parseColor("#F79104") 127 | picker.thumbSizeActiveGrow = 1.0f 128 | picker.clockFace = TimeRangePicker.ClockFace.APPLE 129 | picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_12 130 | } 131 | R.id.tab_three -> { 132 | picker.thumbSize = 32.px 133 | picker.sliderWidth = 32.px 134 | picker.sliderColor = Color.rgb(233, 233, 233) 135 | picker.thumbColor = Color.TRANSPARENT 136 | picker.thumbIconColor = Color.WHITE 137 | picker.sliderRangeGradientStart = Color.parseColor("#5663de") 138 | picker.sliderRangeGradientMiddle = null 139 | picker.sliderRangeGradientEnd = Color.parseColor("#6d7bff") 140 | picker.thumbSizeActiveGrow = 1.0f 141 | picker.clockFace = TimeRangePicker.ClockFace.SAMSUNG 142 | picker.hourFormat = TimeRangePicker.HourFormat.FORMAT_12 143 | } 144 | } 145 | } 146 | 147 | private val Int.px: Int 148 | get() = (this * Resources.getSystem().displayMetrics.density).toInt() 149 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/Extensions.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import android.content.res.Resources 4 | import kotlin.math.roundToInt 5 | 6 | internal val Int.dp: Int 7 | get() = (this / Resources.getSystem().displayMetrics.density).roundToInt() 8 | internal val Int.sp: Int 9 | get() = (this / Resources.getSystem().displayMetrics.scaledDensity).roundToInt() 10 | internal val Int.dpPx: Int 11 | get() = (this * Resources.getSystem().displayMetrics.density).roundToInt() 12 | internal val Int.spPx: Int 13 | get() = (this * Resources.getSystem().displayMetrics.scaledDensity).roundToInt() -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/ReflectionUtils.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import android.annotation.SuppressLint 4 | import android.graphics.drawable.ColorDrawable 5 | import java.util.regex.Pattern 6 | 7 | internal object ReflectionUtils { 8 | @SuppressLint("DefaultLocale") 9 | fun getPropertyValue(instance: Any, property: String): Any? { 10 | val methodName = 11 | if (property == "backgroundColor") "getBackground" else "get" + property.capitalize() 12 | val method = instance::class.java.methods.toList().find { it.name == methodName } 13 | val result = method?.invoke(instance) 14 | 15 | return if (result != null && result is ColorDrawable && property == "backgroundColor") { 16 | result.color 17 | } else { 18 | result 19 | } 20 | } 21 | 22 | @SuppressLint("DefaultLocale") 23 | fun setPropertyValue(instance: Any, property: String, value: Any) { 24 | val methodName = "set" + property.capitalize() 25 | val method = instance::class.java.methods.toList().find { it.name == methodName } 26 | method?.invoke(instance, value) 27 | } 28 | 29 | @SuppressLint("DefaultLocale") 30 | fun pascalCaseToSnakeCase(text: String): String { 31 | val matcher = Pattern.compile("(?<=[a-z])[A-Z]").matcher(text) 32 | 33 | val sb = StringBuffer() 34 | while (matcher.find()) { 35 | matcher.appendReplacement(sb, "_" + matcher.group().lowercase()) 36 | } 37 | matcher.appendTail(sb) 38 | 39 | return sb.toString().lowercase() 40 | } 41 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/Utils.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import android.graphics.Color 4 | import androidx.annotation.ColorInt 5 | 6 | object Utils { 7 | fun colorToString(@ColorInt color: Int): String { 8 | return if(Color.alpha(color) == 255) { 9 | "#%06X".format(0xFFFFFF and color) 10 | } else { 11 | "#%08X".format(color) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/PlaygroundActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.os.Build 8 | import android.os.Bundle 9 | import android.text.Html 10 | import android.text.Spanned 11 | import android.util.TypedValue 12 | import android.view.LayoutInflater 13 | import android.view.View 14 | import android.widget.TextView 15 | import androidx.appcompat.app.AppCompatActivity 16 | import androidx.recyclerview.widget.LinearLayoutManager 17 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 18 | import com.google.android.material.snackbar.Snackbar 19 | import kotlinx.android.synthetic.main.activity_playground.* 20 | import nl.joery.demo.timerangepicker.ExampleActivity 21 | import nl.joery.demo.timerangepicker.R 22 | import nl.joery.demo.timerangepicker.playground.properties.* 23 | import nl.joery.timerangepicker.TimeRangePicker 24 | 25 | 26 | class PlaygroundActivity : AppCompatActivity() { 27 | private lateinit var properties: ArrayList 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | setContentView(R.layout.activity_playground) 32 | 33 | initProperties() 34 | initRecyclerView() 35 | 36 | view_xml.setOnClickListener { 37 | showXmlDialog() 38 | } 39 | 40 | open_examples.setOnClickListener { 41 | startActivity(Intent(this, ExampleActivity::class.java)) 42 | } 43 | } 44 | 45 | private fun initProperties() { 46 | properties = ArrayList() 47 | properties.add( 48 | CategoryProperty( 49 | "Time" 50 | ) 51 | ) 52 | properties.add( 53 | EnumProperty( 54 | "hourFormat", 55 | TimeRangePicker.HourFormat::class.java 56 | ) 57 | ) 58 | properties.add( 59 | IntegerProperty( 60 | "stepTimeMinutes" 61 | ) 62 | ) 63 | 64 | properties.add( 65 | CategoryProperty( 66 | "Slider" 67 | ) 68 | ) 69 | properties.add( 70 | IntegerProperty( 71 | "sliderWidth", 72 | false, 73 | TypedValue.COMPLEX_UNIT_DIP 74 | ) 75 | ) 76 | properties.add( 77 | ColorProperty( 78 | "sliderColor" 79 | ) 80 | ) 81 | properties.add( 82 | ColorProperty( 83 | "sliderRangeColor" 84 | ) 85 | ) 86 | properties.add( 87 | ColorProperty( 88 | "sliderRangeGradientStart" 89 | ) 90 | ) 91 | properties.add( 92 | ColorProperty( 93 | "sliderRangeGradientMiddle" 94 | ) 95 | ) 96 | properties.add( 97 | ColorProperty( 98 | "sliderRangeGradientEnd" 99 | ) 100 | ) 101 | 102 | 103 | properties.add( 104 | CategoryProperty( 105 | "Thumb" 106 | ) 107 | ) 108 | properties.add( 109 | IntegerProperty( 110 | "thumbSize", 111 | false, 112 | TypedValue.COMPLEX_UNIT_DIP 113 | ) 114 | ) 115 | properties.add( 116 | IntegerProperty( 117 | "thumbSizeActiveGrow", 118 | true 119 | ) 120 | ) 121 | properties.add( 122 | ColorProperty( 123 | "thumbColor" 124 | ) 125 | ) 126 | properties.add( 127 | ColorProperty( 128 | "thumbIconColor" 129 | ) 130 | ) 131 | properties.add( 132 | IntegerProperty( 133 | "thumbIconSize", 134 | false, 135 | TypedValue.COMPLEX_UNIT_DIP 136 | ) 137 | ) 138 | 139 | 140 | properties.add( 141 | CategoryProperty( 142 | "Clock" 143 | ) 144 | ) 145 | properties.add( 146 | BooleanProperty( 147 | "clockVisible" 148 | ) 149 | ) 150 | properties.add( 151 | EnumProperty( 152 | "clockFace", 153 | TimeRangePicker.ClockFace::class.java 154 | ) 155 | ) 156 | properties.add( 157 | IntegerProperty( 158 | "clockLabelSize", 159 | false, 160 | TypedValue.COMPLEX_UNIT_SP 161 | ) 162 | ) 163 | properties.add( 164 | ColorProperty( 165 | "clockLabelColor" 166 | ) 167 | ) 168 | properties.add( 169 | ColorProperty( 170 | "clockTickColor" 171 | ) 172 | ) 173 | } 174 | 175 | private fun initRecyclerView() { 176 | recycler.layoutManager = 177 | LinearLayoutManager(applicationContext, LinearLayoutManager.VERTICAL, false) 178 | recycler.adapter = PropertyAdapter(picker, properties) 179 | } 180 | 181 | private fun showXmlDialog() { 182 | val html = XmlGenerator.generateHtmlXml( 183 | "nl.joery.timerangepicker.TimeRangepicker", 184 | "trp", 185 | picker, 186 | properties, 187 | arrayOf(TimeRangePicker(this)) 188 | ) 189 | 190 | val layout = LayoutInflater.from(this).inflate(R.layout.view_generated_xml, null) 191 | val textView = layout.findViewById(R.id.xml) 192 | textView.setHorizontallyScrolling(true) 193 | textView.text = htmlToSpanned(html) 194 | 195 | MaterialAlertDialogBuilder(this) 196 | .setTitle(R.string.generate_xml_title) 197 | .setView(layout) 198 | .setPositiveButton(R.string.copy_to_clipboard) { _, _ -> 199 | val clipboard = 200 | getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 201 | val clip = 202 | ClipData.newPlainText(getString(R.string.generate_xml_title), htmlToText(html)) 203 | clipboard.setPrimaryClip(clip) 204 | 205 | Snackbar.make( 206 | findViewById(android.R.id.content), 207 | R.string.copied_xml_clipboard, 208 | Snackbar.LENGTH_LONG 209 | ).show() 210 | } 211 | .show() 212 | } 213 | 214 | private fun htmlToText(html: String): String { 215 | return htmlToSpanned(html).toString().replace("\u00A0", " ") 216 | } 217 | 218 | private fun htmlToSpanned(html: String): Spanned { 219 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 220 | Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY) 221 | } else { 222 | @Suppress("DEPRECATION") 223 | Html.fromHtml(html) 224 | } 225 | } 226 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/PropertyAdapter.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UNCHECKED_CAST") 2 | 3 | package nl.joery.demo.timerangepicker.playground 4 | 5 | import android.annotation.SuppressLint 6 | import android.graphics.Color 7 | import android.graphics.drawable.GradientDrawable 8 | import android.text.InputType 9 | import android.util.Log 10 | import android.util.TypedValue 11 | import android.view.LayoutInflater 12 | import android.view.View 13 | import android.view.ViewGroup 14 | import android.widget.TextView 15 | import android.widget.Toast 16 | import androidx.annotation.LayoutRes 17 | import androidx.core.graphics.ColorUtils 18 | import androidx.fragment.app.FragmentActivity 19 | import androidx.recyclerview.widget.RecyclerView 20 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 21 | import com.google.android.material.switchmaterial.SwitchMaterial 22 | import com.google.android.material.textfield.TextInputEditText 23 | import com.jaredrummler.android.colorpicker.ColorPickerDialog 24 | import com.jaredrummler.android.colorpicker.ColorPickerDialogListener 25 | import nl.joery.demo.timerangepicker.* 26 | import nl.joery.demo.timerangepicker.playground.properties.* 27 | 28 | 29 | internal class PropertyAdapter( 30 | private val bottomBar: Any, 31 | private val properties: List 32 | ) : 33 | RecyclerView.Adapter() { 34 | 35 | override fun getItemCount(): Int { 36 | return properties.size 37 | } 38 | 39 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 40 | val v: View = LayoutInflater.from(parent.context) 41 | .inflate(getLayout(viewType), parent, false) as View 42 | return when (viewType) { 43 | Property.TYPE_ENUM -> EnumHolder(v, bottomBar) 44 | Property.TYPE_COLOR -> ColorHolder(v, bottomBar) 45 | Property.TYPE_BOOLEAN -> BooleanHolder(v, bottomBar) 46 | Property.TYPE_INTERPOLATOR -> InterpolatorHolder(v, bottomBar) 47 | Property.TYPE_CATEGORY -> CategoryHolder(v) 48 | else -> IntegerHolder(v, bottomBar) 49 | } 50 | } 51 | 52 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 53 | if (holder is BaseHolder<*>) { 54 | (holder as BaseHolder).bind(properties[position]) 55 | } else { 56 | (holder as CategoryHolder).bind(properties[position] as CategoryProperty) 57 | } 58 | } 59 | 60 | override fun getItemViewType(position: Int): Int { 61 | return when (properties[position]) { 62 | is EnumProperty -> Property.TYPE_ENUM 63 | is ColorProperty -> Property.TYPE_COLOR 64 | is IntegerProperty -> Property.TYPE_INTEGER 65 | is BooleanProperty -> Property.TYPE_BOOLEAN 66 | is InterpolatorProperty -> Property.TYPE_INTERPOLATOR 67 | is CategoryProperty -> Property.TYPE_CATEGORY 68 | else -> -1 69 | } 70 | } 71 | 72 | @LayoutRes 73 | private fun getLayout(propertyType: Int): Int { 74 | return when (propertyType) { 75 | Property.TYPE_CATEGORY -> R.layout.list_property_category 76 | Property.TYPE_COLOR -> R.layout.list_property_color 77 | Property.TYPE_BOOLEAN -> R.layout.list_property_boolean 78 | else -> R.layout.list_property 79 | } 80 | } 81 | 82 | class CategoryHolder( 83 | view: View 84 | ) : 85 | RecyclerView.ViewHolder(view) { 86 | internal val name = view.findViewById(R.id.name) 87 | 88 | fun bind(category: CategoryProperty) { 89 | name.text = category.name 90 | } 91 | } 92 | 93 | abstract class BaseHolder( 94 | internal val view: View, 95 | internal val bottomBar: Any 96 | ) : 97 | RecyclerView.ViewHolder(view) { 98 | internal lateinit var property: T 99 | 100 | internal val name = view.findViewById(R.id.name) 101 | private val value = view.findViewById(R.id.value) 102 | 103 | init { 104 | view.setOnClickListener { 105 | thumbClick() 106 | } 107 | } 108 | 109 | @SuppressLint("DefaultLocale") 110 | protected open fun getValue(): String { 111 | return ReflectionUtils.getPropertyValue(bottomBar, property.name).toString() 112 | .lowercase() 113 | .capitalize() 114 | } 115 | 116 | protected abstract fun thumbClick() 117 | 118 | protected open fun updateValue() { 119 | if (value == null) { 120 | return 121 | } 122 | 123 | value.text = getValue() 124 | } 125 | 126 | protected fun setValue(value: Any) { 127 | ReflectionUtils.setPropertyValue(bottomBar, property.name, value) 128 | property.modified = true 129 | updateValue() 130 | } 131 | 132 | internal open fun bind(property: T) { 133 | this.property = property 134 | name.text = property.name 135 | 136 | updateValue() 137 | } 138 | } 139 | 140 | class EnumHolder(v: View, bottomBar: Any) : 141 | BaseHolder(v, bottomBar) { 142 | 143 | @SuppressLint("DefaultLocale") 144 | override fun thumbClick() { 145 | val enumValues = property.enumClass.enumConstants as Array> 146 | val items = enumValues.map { it.name.lowercase().capitalize() }.toTypedArray() 147 | 148 | MaterialAlertDialogBuilder(view.context) 149 | .setTitle(view.context.getString(R.string.set_property_value, property.name)) 150 | .setSingleChoiceItems( 151 | items, items.indexOf(getValue()) 152 | ) { dialog, item -> 153 | setValue(enumValues.first { 154 | it.name == items[item].uppercase() 155 | }) 156 | dialog.dismiss() 157 | } 158 | .show() 159 | } 160 | } 161 | 162 | class ColorHolder(v: View, bottomBar: Any) : 163 | BaseHolder(v, bottomBar) { 164 | 165 | private val color = view.findViewById(R.id.color) 166 | 167 | override fun getValue(): String { 168 | return if (getColor() == null) "no color set" else Utils.colorToString(getColor()!!) 169 | } 170 | 171 | override fun thumbClick() { 172 | val activity = view.context as FragmentActivity 173 | val builder = ColorPickerDialog.newBuilder() 174 | .setAllowCustom(true) 175 | .setAllowPresets(true) 176 | .setShowColorShades(true) 177 | .setShowAlphaSlider(true) 178 | .setDialogTitle(R.string.pick_color) 179 | .setSelectedButtonText(R.string.apply) 180 | 181 | if(getColor() != null) { 182 | builder.setColor(ColorUtils.setAlphaComponent(getColor()!!, 255)) 183 | } 184 | 185 | val dialog = builder.create() 186 | dialog.setColorPickerDialogListener(object : ColorPickerDialogListener { 187 | override fun onDialogDismissed(dialogId: Int) { 188 | } 189 | 190 | override fun onColorSelected(dialogId: Int, color: Int) { 191 | setValue(color) 192 | updateColor() 193 | } 194 | }) 195 | dialog.show(activity.supportFragmentManager, "") 196 | } 197 | 198 | private fun updateColor() { 199 | if (getColor() != null) { 200 | val shape = GradientDrawable() 201 | shape.shape = GradientDrawable.RECTANGLE 202 | shape.cornerRadii = FloatArray(8) { 3.dpPx.toFloat() } 203 | shape.setColor(getColor()!!) 204 | shape.setStroke(1.dpPx, Color.rgb(200, 200, 200)) 205 | color.background = shape 206 | color.visibility = View.VISIBLE 207 | } else { 208 | color.visibility = View.GONE 209 | } 210 | } 211 | 212 | private fun getColor(): Int? { 213 | return ReflectionUtils.getPropertyValue(bottomBar, property.name) as Int? 214 | } 215 | 216 | override fun bind(property: ColorProperty) { 217 | super.bind(property) 218 | 219 | updateColor() 220 | } 221 | } 222 | 223 | class IntegerHolder(v: View, bottomBar: Any) : 224 | BaseHolder(v, bottomBar) { 225 | 226 | override fun getValue(): String { 227 | val value = super.getValue() 228 | 229 | if (value == "Null") { 230 | return "unset" 231 | } 232 | 233 | return when (property.density) { 234 | TypedValue.COMPLEX_UNIT_DIP -> value.toInt().dp.toString() + "dp" 235 | TypedValue.COMPLEX_UNIT_SP -> value.toInt().sp.toString() + "sp" 236 | else -> value 237 | } 238 | } 239 | 240 | @SuppressLint("InflateParams") 241 | override fun thumbClick() { 242 | val view = LayoutInflater.from(view.context).inflate( 243 | R.layout.view_text_input, 244 | null 245 | ) 246 | val editText = view.findViewById(R.id.edit_text) 247 | editText.setText(getValue().replace("[^.^\\dxX]+".toRegex(), "")) 248 | if(property.float) { 249 | editText.inputType = InputType.TYPE_NUMBER_FLAG_DECIMAL 250 | } else { 251 | editText.inputType = InputType.TYPE_CLASS_NUMBER 252 | } 253 | 254 | MaterialAlertDialogBuilder(view.context) 255 | .setTitle(view.context.getString(R.string.set_property_value, property.name)) 256 | .setPositiveButton(R.string.apply) { dialog, _ -> 257 | try { 258 | val newValue = if (property.float) { 259 | editText.text.toString().toFloat() 260 | } else { 261 | val tempValue = editText.text.toString().toInt() 262 | when (property.density) { 263 | TypedValue.COMPLEX_UNIT_DIP -> tempValue.dpPx 264 | TypedValue.COMPLEX_UNIT_SP -> tempValue.spPx 265 | else -> tempValue 266 | } 267 | } 268 | setValue(newValue) 269 | dialog.dismiss() 270 | } catch (e: NumberFormatException) { 271 | Toast.makeText( 272 | view.context, 273 | "Invalid value: " + e.message, 274 | Toast.LENGTH_LONG 275 | ).show() 276 | } 277 | } 278 | .setView(view) 279 | .show() 280 | } 281 | } 282 | 283 | class BooleanHolder(v: View, bottomBar: Any) : 284 | BaseHolder(v, bottomBar) { 285 | private val booleanSwitch = view.findViewById(R.id.booleanSwitch) 286 | 287 | override fun updateValue() { 288 | booleanSwitch.isChecked = 289 | ReflectionUtils.getPropertyValue(bottomBar, property.name) as Boolean 290 | } 291 | 292 | override fun thumbClick() { 293 | } 294 | 295 | override fun bind(property: BooleanProperty) { 296 | super.bind(property) 297 | 298 | booleanSwitch.setOnCheckedChangeListener { _, isChecked -> 299 | setValue(isChecked) 300 | } 301 | } 302 | } 303 | 304 | class InterpolatorHolder(v: View, bottomBar: Any) : 305 | BaseHolder(v, bottomBar) { 306 | 307 | override fun getValue(): String { 308 | val value = ReflectionUtils.getPropertyValue(bottomBar, property.name) 309 | return value!!::class.java.simpleName 310 | } 311 | 312 | override fun thumbClick() { 313 | val interpolatorNames = 314 | InterpolatorProperty.interpolators.map { it::class.java.simpleName }.toTypedArray() 315 | 316 | MaterialAlertDialogBuilder(view.context) 317 | .setTitle(view.context.getString(R.string.set_property_value, property.name)) 318 | .setSingleChoiceItems( 319 | interpolatorNames, interpolatorNames.indexOf(getValue()) 320 | ) { dialog, item -> 321 | setValue(InterpolatorProperty.interpolators.first { 322 | it::class.java.simpleName == interpolatorNames[item] 323 | }) 324 | dialog.dismiss() 325 | } 326 | .show() 327 | } 328 | } 329 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/XmlGenerator.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.TypedValue 5 | import nl.joery.demo.timerangepicker.ReflectionUtils 6 | import nl.joery.demo.timerangepicker.Utils 7 | import nl.joery.demo.timerangepicker.dp 8 | import nl.joery.demo.timerangepicker.playground.properties.* 9 | import nl.joery.demo.timerangepicker.sp 10 | 11 | object XmlGenerator { 12 | fun generateHtmlXml( 13 | name: String, 14 | prefix: String, 15 | instance: Any, 16 | properties: List, 17 | defaultProviders: Array 18 | ): String { 19 | val sb = StringBuilder() 20 | sb.append("<") 21 | .append(coloredText(name, "#22863a")) 22 | .append("
") 23 | 24 | sb.append(getXmlProperty("android:layout_width", "300dp")) 25 | sb.append(getXmlProperty("android:layout_height", "wrap_content")) 26 | sb.append(getXmlProperty("app:trp_thumbStartIcon", "@drawable/ic_thumb_start")) 27 | sb.append(getXmlProperty("app:trp_thumbEndIcon", "@drawable/ic_thumb_end")) 28 | 29 | for (property in properties) { 30 | if (!property.modified || property is CategoryProperty) { 31 | continue 32 | } 33 | 34 | val defaultValue = getDefaultValue(defaultProviders, property.name) 35 | val actualValue = ReflectionUtils.getPropertyValue(instance, property.name) 36 | if ((defaultValue == null && actualValue != null) || defaultValue != actualValue) { 37 | sb.append( 38 | getXmlProperty( 39 | if (property.name == "backgroundColor") "android:background" else "app:${prefix}_${property.name}", 40 | getHumanValue(property, actualValue!!) 41 | ) 42 | ) 43 | } 44 | } 45 | 46 | return sb.toString().substring(0, sb.toString().length - 4) + " />" 47 | } 48 | 49 | private fun getXmlProperty(name: String, value: String): String { 50 | val sb = StringBuilder() 51 | return sb.append("    ") 52 | .append(coloredText(name, "#6f42c1")) 53 | .append("=") 54 | .append(coloredText(""", "#032f62")) 55 | .append(coloredText(value, "#032f62")) 56 | .append(coloredText(""", "#032f62")) 57 | .append("
").toString() 58 | } 59 | 60 | private fun getDefaultValue(defaultProviders: Array, propertyName: String): Any? { 61 | for (provider in defaultProviders) { 62 | val value = ReflectionUtils.getPropertyValue(provider, propertyName) 63 | if (value != null) { 64 | return value 65 | } 66 | } 67 | return null 68 | } 69 | 70 | @SuppressLint("DefaultLocale") 71 | private fun getHumanValue(property: Property, value: Any): String { 72 | return when (property) { 73 | is ColorProperty -> Utils.colorToString(value as Int) 74 | is IntegerProperty -> when (property.density) { 75 | TypedValue.COMPLEX_UNIT_DIP -> (value as Int).dp.toString() + "dp" 76 | TypedValue.COMPLEX_UNIT_SP -> (value as Int).sp.toString() + "sp" 77 | else -> value.toString() 78 | } 79 | is EnumProperty -> value.toString().lowercase() 80 | is InterpolatorProperty -> "@android:anim/" + ReflectionUtils.pascalCaseToSnakeCase( 81 | value::class.java.simpleName 82 | ) 83 | else -> value.toString() 84 | } 85 | } 86 | 87 | private fun coloredText(text: String, color: String): String { 88 | return "$text" 89 | } 90 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/BooleanProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | 4 | class BooleanProperty(name: String) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/CategoryProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | 4 | class CategoryProperty(name: String) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/ColorProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | 4 | class ColorProperty(name: String) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/EnumProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | 4 | class EnumProperty(name: String, val enumClass: Class<*>) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/FloatProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | 4 | class FloatProperty(name: String) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/IntegerProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | import android.util.TypedValue 4 | 5 | 6 | class IntegerProperty(name: String, val float: Boolean = false, val density: Int = TypedValue.DENSITY_NONE) : Property(name) -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/InterpolatorProperty.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | import android.view.animation.* 4 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator 5 | 6 | 7 | class InterpolatorProperty(name: String) : Property(name) { 8 | companion object { 9 | val interpolators: List by lazy { 10 | ArrayList().apply { 11 | add(FastOutSlowInInterpolator()) 12 | add(LinearInterpolator()) 13 | add(AccelerateDecelerateInterpolator()) 14 | add(AccelerateInterpolator()) 15 | add(DecelerateInterpolator()) 16 | add(AnticipateInterpolator()) 17 | add(AnticipateOvershootInterpolator()) 18 | add(OvershootInterpolator()) 19 | add(BounceInterpolator()) 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /demo/src/main/java/nl/joery/demo/timerangepicker/playground/properties/Property.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker.playground.properties 2 | 3 | abstract class Property(val name: String) { 4 | var modified: Boolean = false 5 | 6 | companion object { 7 | const val TYPE_INTEGER = 1 8 | const val TYPE_COLOR = 2 9 | const val TYPE_ENUM = 3 10 | const val TYPE_BOOLEAN = 4 11 | const val TYPE_INTERPOLATOR = 5 12 | const val TYPE_CATEGORY = 6 13 | } 14 | } -------------------------------------------------------------------------------- /demo/src/main/res/drawable/ic_alarm.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/ic_moon.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/numeric_1_box_outline.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/numeric_2_box_outline.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/numeric_3_box_outline.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/activity_example.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 21 | 22 | 28 | 29 | 35 | 42 | 43 | 50 | 51 | 56 | 57 | 64 | 65 | 73 | 74 | 75 | 76 | 83 | 84 | 91 | 92 | 97 | 98 | 105 | 106 | 114 | 115 | 116 | 117 | 122 | 123 | 130 | 131 | 141 | 142 | 149 | 150 | 151 | 152 | 162 | 163 | 178 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/activity_playground.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | 19 | 31 | 32 | 45 | 46 | 59 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/list_property.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/list_property_boolean.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 20 | 21 | 31 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/list_property_category.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 21 | 22 | 29 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/list_property_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/view_generated_xml.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/view_text_input.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 17 | 18 | -------------------------------------------------------------------------------- /demo/src/main/res/menu/tabs.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 14 | -------------------------------------------------------------------------------- /demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6200EE 4 | #3700B3 5 | #E91E63 6 | 7 | #f5f5f5 8 | #E4E4E4 9 | -------------------------------------------------------------------------------- /demo/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #0287D0 4 | -------------------------------------------------------------------------------- /demo/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | TimeRangePicker 3 | Sleep at 4 | Wake at 5 | You will sleep for %1$s 6 | 7 | Example 1 8 | Example 2 9 | Example 3 10 | 11 | Set %1$s value 12 | Apply 13 | Pick a color 14 | 15 | Copied XML to clipboard 16 | Copy to clipboard 17 | Generated XML 18 | 19 | Generate XML 20 | Show examples 21 | -------------------------------------------------------------------------------- /demo/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /demo/src/test/java/nl/joery/demo/timerangepicker/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.demo.timerangepicker 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | 23 | GROUP=nl.joery.timerangepicker 24 | POM_ARTIFACT_ID=timerangepicker 25 | VERSION_NAME=1.0.0 26 | 27 | POM_NAME=TimeRangePicker 28 | POM_DESCRIPTION=A customizable, easy-to-use, and functional circular time range picker library for \ 29 | Android. Use this library to mimic Apple's iOS or Samsung's bedtime picker. 30 | POM_INCEPTION_YEAR=2021 31 | POM_URL=https://github.com/Droppers/TimeRangePicker 32 | 33 | POM_LICENCE_NAME=The MIT License 34 | POM_LICENCE_URL=https://github.com/Droppers/TimeRangePicker/blob/master/LICENSE 35 | POM_LICENCE_DIST=repo 36 | 37 | POM_SCM_URL=https://github.com/Droppers/TimeRangePicker 38 | POM_SCM_CONNECTION=scm:git:git://github.com/Droppers/TimeRangePicker.git 39 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/Droppers/TimeRangePicker.git 40 | 41 | POM_DEVELOPER_ID=Droppers 42 | POM_DEVELOPER_NAME=Joery Droppers 43 | POM_DEVELOPER_URL=https://github.com/Droppers -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Mar 03 23:02:02 CET 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':timerangepicker' 2 | include ':demo' 3 | rootProject.name = "TimeRangePicker" -------------------------------------------------------------------------------- /timerangepicker/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /timerangepicker/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: "com.vanniktech.maven.publish" 4 | 5 | android { 6 | compileSdkVersion 30 7 | 8 | defaultConfig { 9 | minSdkVersion 14 10 | targetSdkVersion 30 11 | versionCode 1 12 | versionName VERSION_NAME 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles "consumer-rules.pro" 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_1_8 27 | targetCompatibility JavaVersion.VERSION_1_8 28 | 29 | kotlinOptions.freeCompilerArgs += ['-module-name', "${GROUP}.${POM_ARTIFACT_ID}"] 30 | } 31 | } 32 | 33 | dependencies { 34 | api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 35 | api 'androidx.core:core-ktx:1.6.0' 36 | api 'androidx.appcompat:appcompat:1.3.0' 37 | 38 | testImplementation 'junit:junit:4.13.2' 39 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 40 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 41 | } -------------------------------------------------------------------------------- /timerangepicker/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droppers/TimeRangePicker/cb3418ae8bb04b1edbfed563773b1a53b1bcf072/timerangepicker/consumer-rules.pro -------------------------------------------------------------------------------- /timerangepicker/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /timerangepicker/src/androidTest/java/nl/joery/timerangepicker/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("nl.joery.timerangepicker.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/BitmapCachedClockRenderer.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | interface BitmapCachedClockRenderer: ClockRenderer { 4 | var isBitmapCacheEnabled: Boolean 5 | 6 | fun invalidateBitmapCache() 7 | fun recycleBitmapCache() 8 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/ClockRenderer.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import android.graphics.Canvas 4 | 5 | interface ClockRenderer { 6 | fun render(canvas: Canvas) 7 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/DefaultClockRenderer.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import android.graphics.* 4 | import android.os.Build 5 | import androidx.annotation.Keep 6 | import androidx.annotation.RequiresApi 7 | import nl.joery.timerangepicker.utils.dpToPx 8 | import nl.joery.timerangepicker.utils.pxToDp 9 | import kotlin.math.cos 10 | import kotlin.math.sin 11 | 12 | @Keep 13 | class DefaultClockRenderer(private val picker: TimeRangePicker): BitmapCachedClockRenderer { 14 | private val _minuteTickWidth = dpToPx(1f) 15 | private val _hourTickWidth = dpToPx(2f) 16 | private var _middle: Float = 0f 17 | 18 | private var _bitmapCache: Bitmap? = null 19 | private var _bitmapCacheCanvas: Canvas? = null 20 | 21 | private var _isBitmapCacheEnabled = true 22 | override var isBitmapCacheEnabled: Boolean 23 | get() = _isBitmapCacheEnabled 24 | set(value) { 25 | val oldValue = _isBitmapCacheEnabled 26 | _isBitmapCacheEnabled = value 27 | 28 | if(oldValue != value) { 29 | invalidateBitmapCache() 30 | } 31 | } 32 | 33 | private val _tickLength: Float 34 | get() { 35 | val dp = when(picker.clockFace) { 36 | TimeRangePicker.ClockFace.APPLE -> 6f 37 | TimeRangePicker.ClockFace.SAMSUNG -> 4f 38 | } 39 | return dpToPx(dp) 40 | } 41 | 42 | private val _tickCount: Int 43 | get() = when (picker.clockFace) { 44 | TimeRangePicker.ClockFace.APPLE -> 48 45 | TimeRangePicker.ClockFace.SAMSUNG -> 120 46 | } 47 | 48 | private val _tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 49 | style = Paint.Style.STROKE 50 | strokeCap = Paint.Cap.ROUND 51 | } 52 | private val _labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 53 | textAlign = Paint.Align.CENTER 54 | } 55 | 56 | override fun render(canvas: Canvas) { 57 | if(isBitmapCacheEnabled) { 58 | val radius = picker.clockRadius 59 | val bitmap = _bitmapCache ?: throw RuntimeException("_bitmapCache == null") 60 | 61 | val center = canvas.width / 2 62 | val bitmapLeft = center - radius 63 | val bitmapTop = center - radius 64 | 65 | canvas.drawBitmap(bitmap, bitmapLeft, bitmapTop, null) 66 | } else { 67 | renderInternal(canvas) 68 | } 69 | } 70 | 71 | override fun invalidateBitmapCache() { 72 | if (isBitmapCacheEnabled) { 73 | val size = picker.clockRadius.toInt() * 2 74 | 75 | if(size > 0) { 76 | val bitmap = _bitmapCache 77 | 78 | if (bitmap == null) { 79 | resizeBitmapCacheThroughRecreating(size) 80 | } else { 81 | val oldSize = bitmap.width 82 | // width & height are always the same, so there is no need to do extra check with height 83 | if (oldSize != size) { 84 | // reconfiguring works only when new size is smaller or equal to old 85 | if (Build.VERSION.SDK_INT >= 19 && size < oldSize) { 86 | resizeBitmapCacheThroughReconfiguring(size) 87 | } else { 88 | resizeBitmapCacheThroughRecreating(size) 89 | } 90 | } 91 | } 92 | 93 | val canvas = 94 | _bitmapCacheCanvas ?: throw RuntimeException("_bitmapCacheCanvas == null") 95 | canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) 96 | 97 | renderInternal(canvas) 98 | } 99 | } else { 100 | recycleBitmapCache() 101 | } 102 | } 103 | 104 | override fun recycleBitmapCache() { 105 | _bitmapCache?.recycle() 106 | _bitmapCache = null 107 | _bitmapCacheCanvas = null 108 | } 109 | 110 | private fun resizeBitmapCacheThroughRecreating(newSize: Int) { 111 | // recycle old cache 112 | _bitmapCache?.recycle() 113 | 114 | val b = Bitmap.createBitmap(newSize, newSize, Bitmap.Config.ARGB_8888) 115 | _bitmapCache = b 116 | _bitmapCacheCanvas = Canvas(b) 117 | } 118 | 119 | @RequiresApi(19) 120 | private fun resizeBitmapCacheThroughReconfiguring(newSize: Int) { 121 | val bitmap = _bitmapCache ?: throw RuntimeException("_bitmapCache == null") 122 | bitmap.reconfigure(newSize, newSize, Bitmap.Config.ARGB_8888) 123 | _bitmapCacheCanvas = Canvas(bitmap) 124 | } 125 | 126 | private fun renderInternal(canvas: Canvas) { 127 | _middle = (canvas.width / 2).toFloat() 128 | 129 | _tickPaint.color = picker.clockTickColor 130 | _labelPaint.apply { 131 | textSize = picker.clockLabelSize.toFloat() 132 | color = picker.clockLabelColor 133 | } 134 | 135 | drawTicks(canvas) 136 | drawLabels(canvas) 137 | } 138 | 139 | private fun drawTicks(canvas: Canvas) { 140 | val radius = picker.clockRadius 141 | val hourTickInterval = if(picker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) 24 else 12 142 | val tickLength = _tickLength 143 | val tickCount = _tickCount 144 | val hourTick = tickCount / hourTickInterval 145 | val offset = if(pxToDp(picker.clockLabelSize.toFloat()) <= 16) 3 else 6 146 | val anglePerTick = 360f / tickCount 147 | 148 | for (i in 0 until tickCount) { 149 | val angle = anglePerTick * i 150 | val angleRadians = Math.toRadians(angle.toDouble()) 151 | val stopRadius = radius - tickLength 152 | 153 | val sinAngle = sin(angleRadians).toFloat() 154 | val cosAngle = cos(angleRadians).toFloat() 155 | 156 | val startX = _middle + radius * cosAngle 157 | val startY = _middle + radius * sinAngle 158 | 159 | val stopX = _middle + stopRadius * cosAngle 160 | val stopY = _middle + stopRadius * sinAngle 161 | 162 | if (picker.clockFace == TimeRangePicker.ClockFace.SAMSUNG && 163 | ((angle >= 90-offset && angle <= 90+offset) || 164 | (angle >= 180-offset && angle <= 180+offset) || 165 | (angle >= 270-offset && angle <= 270+offset) || 166 | angle >= 360-offset || 167 | angle <= 0+offset)) { 168 | continue 169 | } 170 | 171 | // Hour tick 172 | if (i % hourTick == 0) { 173 | _tickPaint.alpha = 180 174 | _tickPaint.strokeWidth = _hourTickWidth 175 | } else { 176 | _tickPaint.alpha = 100 177 | _tickPaint.strokeWidth = _minuteTickWidth 178 | } 179 | canvas.drawLine(startX, startY, stopX, stopY, _tickPaint) 180 | } 181 | } 182 | 183 | private val _drawLabelsBounds = Rect() 184 | private val _drawLabelsPosition = PointF() 185 | 186 | private fun drawLabels(canvas: Canvas) { 187 | val labels = when (picker.clockFace) { 188 | TimeRangePicker.ClockFace.APPLE -> { 189 | if (picker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) { 190 | LABELS_APPLE_24 191 | } else { 192 | LABELS_APPLE_12 193 | } 194 | } 195 | TimeRangePicker.ClockFace.SAMSUNG -> { 196 | if (picker.hourFormat == TimeRangePicker.HourFormat.FORMAT_24) { 197 | LABELS_SAMSUNG_24 198 | } else { 199 | LABELS_SAMSUNG_12 200 | } 201 | } 202 | } 203 | 204 | val bounds = _drawLabelsBounds 205 | val position = _drawLabelsPosition 206 | val tickLength = _tickLength 207 | val radius = picker.clockRadius 208 | 209 | for (i in labels.indices) { 210 | val label = labels[i] 211 | val angle = 360f / labels.size * i - 90f 212 | 213 | _labelPaint.getTextBounds(label, 0, label.length, bounds) 214 | val offset = when (picker.clockFace) { 215 | TimeRangePicker.ClockFace.APPLE -> tickLength * 2 + bounds.height() 216 | TimeRangePicker.ClockFace.SAMSUNG -> (if(angle == 0f || angle == 180f) bounds.width() else bounds.height()).toFloat() / 2 217 | } 218 | 219 | getPositionByAngle(radius - offset, angle, position) 220 | canvas.drawText( 221 | label, 222 | position.x, 223 | position.y + bounds.height() / 2f, 224 | _labelPaint 225 | ) 226 | } 227 | } 228 | 229 | private fun getPositionByAngle(radius: Float, angle: Float, outPoint: PointF) { 230 | val angleRadians = Math.toRadians(angle.toDouble()) 231 | outPoint.x = _middle + radius * cos(angleRadians).toFloat() 232 | outPoint.y = _middle + radius * sin(angleRadians).toFloat() 233 | } 234 | 235 | companion object { 236 | private val LABELS_APPLE_24 = arrayOf("0", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22") 237 | private val LABELS_APPLE_12 = arrayOf("12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11") 238 | 239 | private val LABELS_SAMSUNG_24 = arrayOf("0", "6", "12", "18") 240 | private val LABELS_SAMSUNG_12 = arrayOf("12", "3", "6", "9") 241 | } 242 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/SavedState.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import android.view.View 6 | 7 | internal class SavedState : View.BaseSavedState { 8 | var angleStart: Float = 0f 9 | var angleEnd: Float = 0f 10 | 11 | constructor(source: Parcel) : super(source) { 12 | angleStart = source.readFloat() 13 | angleEnd = source.readFloat() 14 | } 15 | 16 | constructor(superState: Parcelable?) : super(superState) 17 | 18 | override fun writeToParcel(out: Parcel, flags: Int) { 19 | super.writeToParcel(out, flags) 20 | out.writeFloat(angleStart) 21 | out.writeFloat(angleEnd) 22 | } 23 | 24 | companion object { 25 | @JvmField 26 | val CREATOR = object : Parcelable.Creator { 27 | override fun createFromParcel(source: Parcel): SavedState { 28 | return SavedState(source) 29 | } 30 | 31 | override fun newArray(size: Int): Array { 32 | return arrayOfNulls(size) 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/TimeRangePicker.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.res.TypedArray 6 | import android.graphics.* 7 | import android.graphics.drawable.Drawable 8 | import android.os.Build 9 | import android.os.Parcelable 10 | import android.text.format.DateFormat 11 | import android.util.AttributeSet 12 | import android.view.MotionEvent 13 | import android.view.View 14 | import androidx.annotation.* 15 | import androidx.core.content.ContextCompat 16 | import androidx.core.graphics.drawable.DrawableCompat 17 | import nl.joery.timerangepicker.utils.* 18 | import nl.joery.timerangepicker.utils.MathUtils.angleTo360 19 | import nl.joery.timerangepicker.utils.MathUtils.angleTo720 20 | import nl.joery.timerangepicker.utils.MathUtils.angleToMinutes 21 | import nl.joery.timerangepicker.utils.MathUtils.angleToPreciseMinutes 22 | import nl.joery.timerangepicker.utils.MathUtils.differenceBetweenAngles 23 | import nl.joery.timerangepicker.utils.MathUtils.durationBetweenMinutes 24 | import nl.joery.timerangepicker.utils.MathUtils.minutesToAngle 25 | import nl.joery.timerangepicker.utils.MathUtils.simpleMinutesToAngle 26 | import nl.joery.timerangepicker.utils.MathUtils.snapMinutes 27 | import java.time.Duration 28 | import java.time.LocalTime 29 | import java.util.* 30 | import javax.xml.datatype.DatatypeFactory 31 | import kotlin.math.* 32 | import kotlin.properties.Delegates 33 | 34 | class TimeRangePicker @JvmOverloads constructor( 35 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 36 | ) : View(context, attrs, defStyleAttr) { 37 | private var _clockRenderer: ClockRenderer = DefaultClockRenderer(this) 38 | private val _thumbStartPaint = Paint(Paint.ANTI_ALIAS_FLAG) 39 | private val _thumbEndPaint = Paint(Paint.ANTI_ALIAS_FLAG) 40 | 41 | private val _sliderRangePaint = Paint(Paint.ANTI_ALIAS_FLAG) 42 | private val _sliderPaint = Paint(Paint.ANTI_ALIAS_FLAG) 43 | private val _gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG) 44 | 45 | private val _sliderRect: RectF = RectF() 46 | private val _sliderCapRect: RectF = RectF() 47 | 48 | private var _sliderWidth: Int = dpToPx(8f).toInt() 49 | private var _sliderColor by Delegates.notNull() 50 | private var _sliderRangeColor by Delegates.notNull() 51 | private var _sliderRangeGradientStart: Int? = null 52 | private var _sliderRangeGradientMiddle: Int? = null 53 | private var _sliderRangeGradientEnd: Int? = null 54 | 55 | private var _thumbSize: Int = dpToPx(28f).toInt() 56 | private var _thumbSizeActiveGrow: Float = 1.2f 57 | private var _thumbIconStart: Drawable? = null 58 | private var _thumbIconEnd: Drawable? = null 59 | private var _thumbColor by Delegates.notNull() 60 | private var _thumbColorAuto: Boolean = true 61 | private var _thumbIconColor: Int? = null 62 | private var _thumbIconSize: Int? = null 63 | 64 | private var _clockVisible: Boolean = true 65 | private var _clockFace: ClockFace = ClockFace.APPLE 66 | private var _clockLabelSize = spToPx(15f).toInt() 67 | private var _clockLabelColor by Delegates.notNull() 68 | private var _clockTickColor by Delegates.notNull() 69 | 70 | private var _minDurationMinutes: Int = 0 71 | private var _maxDurationMinutes: Int = 24 * 60 72 | private var _stepTimeMinutes = 10 73 | 74 | private var onTimeChangeListener: OnTimeChangeListener? = null 75 | private var onDragChangeListener: OnDragChangeListener? = null 76 | 77 | private val _radius: Float 78 | get() = (min(width, height) / 2f - max( 79 | max( 80 | _thumbSize, 81 | (_thumbSize * _thumbSizeActiveGrow).toInt() 82 | ), _sliderWidth 83 | ) / 2f) - max( 84 | max(paddingTop, paddingLeft), 85 | max(paddingBottom, paddingRight) 86 | ) 87 | 88 | private var _middlePoint = PointF(0f, 0f) 89 | 90 | private var _hourFormat = HourFormat.FORMAT_12 91 | private var _angleStart: Float = 0f 92 | private var _angleEnd: Float = 0f 93 | 94 | private var _activeThumb: Thumb? = null 95 | private var _touchOffsetAngle: Float = 0.0f 96 | 97 | private val _isGradientSlider: Boolean 98 | get() = _sliderRangeGradientStart != null && _sliderRangeGradientEnd != null 99 | 100 | private val _thumbPositionCache = PointF() 101 | 102 | private var _gradientPositionsCache = FloatArray(2) 103 | private var _gradientColorsCache = IntArray(2) 104 | private val _gradientMatrixCache = Matrix() 105 | 106 | private var _clockRadius: Float = 0f 107 | 108 | init { 109 | initColors() 110 | initAttributes(attrs) 111 | 112 | updateMiddlePoint() 113 | updatePaint() 114 | 115 | computeClockRadius(invalidate = false) 116 | } 117 | 118 | private fun initColors() { 119 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 120 | val colorPrimary = context.getColorResCompat(android.R.attr.colorPrimary) 121 | 122 | _sliderRangeColor = colorPrimary 123 | _thumbColor = colorPrimary 124 | } else { 125 | _sliderRangeColor = Color.BLUE 126 | _thumbColor = Color.BLUE 127 | } 128 | 129 | _sliderColor = 0xFFE1E1E1.toInt() 130 | 131 | val textColorPrimary = context.getTextColor(android.R.attr.textColorPrimary) 132 | 133 | _clockTickColor = textColorPrimary 134 | _clockLabelColor = textColorPrimary 135 | } 136 | 137 | private fun initAttributes( 138 | attributeSet: AttributeSet? 139 | ) { 140 | val attr: TypedArray = 141 | context.obtainStyledAttributes(attributeSet, R.styleable.TimeRangePicker, 0, 0) 142 | try { 143 | // Time 144 | // Sets public property to determine 24 / 12 format automatically 145 | hourFormat = HourFormat.fromId( 146 | attr.getInt( 147 | R.styleable.TimeRangePicker_trp_hourFormat, 148 | _hourFormat.id 149 | ) 150 | ) 151 | _angleStart = minutesToAngle( 152 | attr.getInt( 153 | R.styleable.TimeRangePicker_trp_endTimeMinutes, 154 | angleToMinutes(minutesToAngle(0, _hourFormat), _hourFormat) 155 | ), 156 | _hourFormat 157 | ) 158 | _angleEnd = minutesToAngle( 159 | attr.getInt( 160 | R.styleable.TimeRangePicker_trp_startTimeMinutes, 161 | angleToMinutes(minutesToAngle(480 /* 8:00 */, _hourFormat), _hourFormat) 162 | ), 163 | _hourFormat 164 | ) 165 | val startTime = attr.getString(R.styleable.TimeRangePicker_trp_startTime) 166 | if (startTime != null) { 167 | _angleStart = minutesToAngle(Time.parseToTotalMinutes(startTime), _hourFormat) 168 | } 169 | val endTime = attr.getString(R.styleable.TimeRangePicker_trp_endTime) 170 | if (endTime != null) { 171 | _angleEnd = minutesToAngle(Time.parseToTotalMinutes(endTime), _hourFormat) 172 | } 173 | 174 | // Duration 175 | minDurationMinutes = 176 | attr.getInt(R.styleable.TimeRangePicker_trp_minDurationMinutes, _minDurationMinutes) 177 | maxDurationMinutes = 178 | attr.getInt(R.styleable.TimeRangePicker_trp_maxDurationMinutes, _maxDurationMinutes) 179 | 180 | val minDuration = attr.getString(R.styleable.TimeRangePicker_trp_minDuration) 181 | if (minDuration != null) { 182 | minDurationMinutes = Time.parseToTotalMinutes(minDuration) 183 | } 184 | val maxDuration = attr.getString(R.styleable.TimeRangePicker_trp_maxDuration) 185 | if (maxDuration != null) { 186 | maxDurationMinutes = Time.parseToTotalMinutes(maxDuration) 187 | } 188 | 189 | _stepTimeMinutes = attr.getInt( 190 | R.styleable.TimeRangePicker_trp_stepTimeMinutes, 191 | _stepTimeMinutes 192 | ) 193 | 194 | // Slider 195 | _sliderWidth = attr.getDimension( 196 | R.styleable.TimeRangePicker_trp_sliderWidth, 197 | _sliderWidth.toFloat() 198 | ).toInt() 199 | _sliderColor = attr.getColor(R.styleable.TimeRangePicker_trp_sliderColor, _sliderColor) 200 | _sliderRangeColor = 201 | attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeColor, _sliderRangeColor) 202 | 203 | // Slider gradient 204 | val gradientStart = 205 | attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientStart, -1) 206 | val gradientMiddle = 207 | attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientMiddle, -1) 208 | val gradientEnd = 209 | attr.getColor(R.styleable.TimeRangePicker_trp_sliderRangeGradientEnd, -1) 210 | if (gradientStart != -1 && gradientEnd != -1) { 211 | _sliderRangeGradientStart = gradientStart 212 | _sliderRangeGradientMiddle = gradientMiddle 213 | _sliderRangeGradientEnd = gradientEnd 214 | } 215 | 216 | // Thumb 217 | _thumbSize = attr.getDimension( 218 | R.styleable.TimeRangePicker_trp_thumbSize, 219 | _thumbSize.toFloat() 220 | ).toInt() 221 | _thumbSizeActiveGrow = attr.getFloat( 222 | R.styleable.TimeRangePicker_trp_thumbSizeActiveGrow, 223 | _thumbSizeActiveGrow 224 | ) 225 | 226 | val thumbColor = attr.getColor(R.styleable.TimeRangePicker_trp_thumbColor, 0) 227 | _thumbColor = if (thumbColor == 0) _thumbColor else thumbColor 228 | _thumbColorAuto = thumbColor == 0 229 | val iconColor = attr.getColor(R.styleable.TimeRangePicker_trp_thumbIconColor, 0) 230 | _thumbIconColor = if (iconColor == 0) null else iconColor 231 | val iconSize = attr.getDimension(R.styleable.TimeRangePicker_trp_thumbIconSize, -1f) 232 | _thumbIconSize = if (iconSize == -1f) null else iconSize.toInt() 233 | _thumbIconStart = 234 | attr.getDrawable(R.styleable.TimeRangePicker_trp_thumbIconStart)?.mutate() 235 | _thumbIconEnd = attr.getDrawable(R.styleable.TimeRangePicker_trp_thumbIconEnd)?.mutate() 236 | 237 | // Clock 238 | _clockVisible = 239 | attr.getBoolean(R.styleable.TimeRangePicker_trp_clockVisible, _clockVisible) 240 | _clockFace = ClockFace.fromId( 241 | attr.getInt( 242 | R.styleable.TimeRangePicker_trp_clockFace, 243 | _clockFace.id 244 | ) 245 | ) 246 | 247 | _clockLabelSize = attr.getDimensionPixelSize( 248 | R.styleable.TimeRangePicker_trp_clockLabelSize, 249 | _clockLabelSize 250 | ) 251 | _clockLabelColor = 252 | attr.getColor(R.styleable.TimeRangePicker_trp_clockLabelColor, _clockLabelColor) 253 | _clockTickColor = attr.getColor( 254 | R.styleable.TimeRangePicker_trp_clockTickColor, 255 | _clockTickColor 256 | ) 257 | 258 | val clockRendererClassName = 259 | attr.getString(R.styleable.TimeRangePicker_trp_clockRenderer) 260 | if (clockRendererClassName != null) { 261 | _clockRenderer = createClockRenderer(clockRendererClassName, this) 262 | } 263 | } finally { 264 | attr.recycle() 265 | } 266 | } 267 | 268 | private fun updateMiddlePoint() { 269 | _middlePoint.set(width / 2f, height / 2f) 270 | } 271 | 272 | private fun updatePaint(invalidate: Boolean = true) { 273 | _thumbStartPaint.apply { 274 | style = Paint.Style.FILL 275 | color = 276 | if (_thumbColorAuto && _isGradientSlider) _sliderRangeGradientStart!! else _thumbColor 277 | } 278 | _thumbEndPaint.apply { 279 | style = Paint.Style.FILL 280 | color = 281 | if (_thumbColorAuto && _isGradientSlider) _sliderRangeGradientEnd!! else _thumbColor 282 | } 283 | 284 | _sliderPaint.apply { 285 | style = Paint.Style.STROKE 286 | strokeWidth = _sliderWidth.toFloat() 287 | color = _sliderColor 288 | } 289 | _sliderRangePaint.apply { 290 | style = Paint.Style.STROKE 291 | strokeWidth = _sliderWidth.toFloat() 292 | color = _sliderRangeColor 293 | } 294 | 295 | if (_isGradientSlider) { 296 | updateGradient() 297 | } else { 298 | _sliderRangePaint.shader = null 299 | } 300 | 301 | if (invalidate) { 302 | invalidate() 303 | } 304 | } 305 | 306 | private fun updateGradient() { 307 | fun resizeCacheIfNeeded(desiredSize: Int) { 308 | if (_gradientPositionsCache.size != desiredSize) { 309 | _gradientPositionsCache = FloatArray(desiredSize) 310 | } 311 | 312 | if (_gradientColorsCache.size != desiredSize) { 313 | _gradientColorsCache = IntArray(desiredSize) 314 | } 315 | } 316 | 317 | if (!_isGradientSlider) { 318 | return 319 | } 320 | 321 | val sweepAngle = angleTo360(_angleStart - _angleEnd) 322 | 323 | val positions: FloatArray 324 | val colors: IntArray 325 | 326 | val gradientStart = _sliderRangeGradientStart!! 327 | val gradientEnd = _sliderRangeGradientEnd!! 328 | val gradientMiddle = _sliderRangeGradientMiddle 329 | 330 | if (gradientMiddle == null) { 331 | resizeCacheIfNeeded(2) 332 | 333 | positions = _gradientPositionsCache 334 | // first element is always 0 335 | positions[1] = sweepAngle / 360f 336 | 337 | colors = _gradientColorsCache 338 | colors[0] = gradientStart 339 | colors[1] = gradientEnd 340 | } else { 341 | resizeCacheIfNeeded(3) 342 | 343 | positions = _gradientPositionsCache 344 | // first element is always 0 345 | positions[1] = (sweepAngle / 360f) / 2 346 | positions[2] = sweepAngle / 360f 347 | 348 | colors = _gradientColorsCache 349 | colors[0] = gradientStart 350 | colors[1] = gradientMiddle 351 | colors[2] = gradientEnd 352 | } 353 | 354 | val gradient: Shader = SweepGradient(_middlePoint.x, _middlePoint.y, colors, positions) 355 | val gradientMatrix = _gradientMatrixCache 356 | 357 | gradientMatrix.reset() 358 | gradientMatrix.preRotate(-_angleStart, _middlePoint.x, _middlePoint.y) 359 | gradient.setLocalMatrix(gradientMatrix) 360 | _sliderRangePaint.shader = gradient 361 | } 362 | 363 | private fun updateThumbIconColors() { 364 | if (_thumbIconColor != null) { 365 | if (_thumbIconStart != null) { 366 | DrawableCompat.setTint(_thumbIconStart!!, _thumbIconColor!!) 367 | } 368 | if (_thumbIconEnd != null) { 369 | DrawableCompat.setTint(_thumbIconEnd!!, _thumbIconColor!!) 370 | } 371 | } 372 | 373 | invalidate() 374 | } 375 | 376 | public override fun onSaveInstanceState(): Parcelable { 377 | return SavedState(super.onSaveInstanceState()).apply { 378 | angleStart = _angleStart 379 | angleEnd = _angleEnd 380 | } 381 | } 382 | 383 | override fun onRestoreInstanceState(state: Parcelable) { 384 | if (state is SavedState) { 385 | super.onRestoreInstanceState(state.superState) 386 | _angleStart = state.angleStart 387 | _angleEnd = state.angleEnd 388 | } else { 389 | super.onRestoreInstanceState(state) 390 | } 391 | } 392 | 393 | override fun onSizeChanged(w: Int, h: Int, oldWidth: Int, oldHeight: Int) { 394 | super.onSizeChanged(w, h, oldWidth, oldHeight) 395 | 396 | updateMiddlePoint() 397 | updateGradient() 398 | computeClockRadius() 399 | } 400 | 401 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 402 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 403 | val squareSize = min(measuredWidth, measuredHeight) 404 | super.onMeasure( 405 | MeasureSpec.makeMeasureSpec(squareSize, MeasureSpec.EXACTLY), 406 | MeasureSpec.makeMeasureSpec(squareSize, MeasureSpec.EXACTLY) 407 | ) 408 | } 409 | 410 | @SuppressLint("DrawAllocation") 411 | override fun onDraw(canvas: Canvas) { 412 | super.onDraw(canvas) 413 | 414 | if (_clockVisible) { 415 | _clockRenderer.render(canvas) 416 | } 417 | 418 | _sliderRect.set( 419 | _middlePoint.x - _radius, 420 | _middlePoint.y - _radius, 421 | _middlePoint.x + _radius, 422 | _middlePoint.y + _radius 423 | ) 424 | 425 | val sweepAngle = 426 | angleTo360(_angleStart - _angleEnd) 427 | 428 | canvas.drawCircle( 429 | _middlePoint.x, 430 | _middlePoint.y, 431 | _radius, 432 | _sliderPaint 433 | ) 434 | 435 | getThumbPosition( 436 | angleTo360(_angleStart), 437 | _thumbPositionCache 438 | ) 439 | val startThumbX = _thumbPositionCache.x 440 | val startThumbY = _thumbPositionCache.y 441 | 442 | getThumbPosition( 443 | _angleEnd, 444 | _thumbPositionCache 445 | ) 446 | 447 | val endThumbX = _thumbPositionCache.x 448 | val endThumbY = _thumbPositionCache.y 449 | 450 | // Draw start thumb 451 | canvas.drawArc( 452 | _sliderRect, 453 | -_angleStart - 0.25f, 454 | sweepAngle / 2f + 0.5f, 455 | false, 456 | _sliderRangePaint 457 | ) 458 | drawRangeCap( 459 | canvas, 460 | startThumbX, startThumbY, 461 | 0f, 462 | if (_isGradientSlider) _sliderRangeGradientStart!! else _sliderRangeColor 463 | ) 464 | drawThumb( 465 | canvas, 466 | _thumbStartPaint, 467 | _thumbIconStart, 468 | _activeThumb == Thumb.START, 469 | startThumbX, startThumbY 470 | ) 471 | 472 | // Draw end thumb 473 | canvas.drawArc( 474 | _sliderRect, 475 | -_angleStart + sweepAngle / 2f - 0.25f, 476 | sweepAngle / 2f + 0.5f, 477 | false, 478 | _sliderRangePaint 479 | ) 480 | drawRangeCap( 481 | canvas, 482 | endThumbX, endThumbY, 483 | 180f, 484 | if (_isGradientSlider) _sliderRangeGradientEnd!! else _sliderRangeColor 485 | ) 486 | drawThumb( 487 | canvas, 488 | _thumbEndPaint, 489 | _thumbIconEnd, 490 | _activeThumb == Thumb.END, 491 | endThumbX, endThumbY 492 | ) 493 | } 494 | 495 | private fun drawRangeCap( 496 | canvas: Canvas, 497 | posX: Float, 498 | posY: Float, 499 | rotation: Float, @ColorInt color: Int 500 | ) { 501 | val capAngle = Math.toDegrees( 502 | atan2( 503 | _middlePoint.x - posX, 504 | posY - _middlePoint.y 505 | ).toDouble() 506 | ).toFloat() 507 | _gradientPaint.color = color 508 | 509 | _sliderCapRect.set( 510 | posX - _sliderWidth / 2f, 511 | posY - _sliderWidth / 2f, 512 | posX + _sliderWidth / 2f, 513 | posY + _sliderWidth / 2f 514 | ) 515 | canvas.drawArc( 516 | _sliderCapRect, 517 | capAngle - 90 + rotation, 518 | 180f, 519 | true, 520 | _gradientPaint 521 | ) 522 | } 523 | 524 | private fun drawThumb( 525 | canvas: Canvas, 526 | paint: Paint, 527 | icon: Drawable?, 528 | active: Boolean, 529 | x: Float, 530 | y: Float 531 | ) { 532 | val grow = if (active) _thumbSizeActiveGrow else 1f 533 | val thumbRadius = (_thumbSize.toFloat() * grow) / 2f 534 | canvas.drawCircle( 535 | x, 536 | y, 537 | thumbRadius, 538 | paint 539 | ) 540 | 541 | if (icon != null) { 542 | val iconSize = 543 | _thumbIconSize?.toFloat() ?: min(dpToPx(24f), _thumbSize * 0.625f) 544 | icon.setBounds( 545 | (x - iconSize / 2).toInt(), 546 | (y - iconSize / 2).toInt(), 547 | (x + iconSize / 2).toInt(), 548 | (y + iconSize / 2f).toInt() 549 | ) 550 | icon.draw(canvas) 551 | } 552 | } 553 | 554 | @SuppressLint("ClickableViewAccessibility") 555 | override fun onTouchEvent(event: MotionEvent): Boolean { 556 | val touchAngle = Math.toDegrees( 557 | atan2( 558 | _middlePoint.y - event.y, 559 | event.x - _middlePoint.x 560 | ).toDouble() 561 | ).toFloat() 562 | 563 | when (event.action) { 564 | MotionEvent.ACTION_DOWN -> { 565 | _activeThumb = getClosestThumb(event.x, event.y) 566 | return if (_activeThumb != Thumb.NONE) { 567 | val targetAngleRad = if (_activeThumb == Thumb.END) _angleEnd else _angleStart 568 | _touchOffsetAngle = differenceBetweenAngles( 569 | targetAngleRad, 570 | touchAngle 571 | ) 572 | 573 | invalidate() 574 | 575 | return onDragChangeListener?.onDragStart(_activeThumb!!) ?: true 576 | } else { 577 | false 578 | } 579 | } 580 | MotionEvent.ACTION_MOVE -> { 581 | if (_activeThumb == Thumb.START || _activeThumb == Thumb.BOTH) { 582 | val difference = 583 | differenceBetweenAngles(_angleStart, touchAngle) - _touchOffsetAngle 584 | val newStartAngle = angleTo720(_angleStart + difference) 585 | val newDurationMinutes = durationBetweenMinutes( 586 | angleToPreciseMinutes(newStartAngle, _hourFormat), 587 | angleToPreciseMinutes(_angleEnd, _hourFormat) 588 | ) 589 | 590 | if (_activeThumb == Thumb.BOTH) { 591 | _angleStart = newStartAngle 592 | _angleEnd = angleTo720(_angleEnd + difference) 593 | } else { 594 | _angleStart = when { 595 | newDurationMinutes < _minDurationMinutes -> _angleEnd + simpleMinutesToAngle( 596 | _minDurationMinutes, 597 | _hourFormat 598 | ) 599 | newDurationMinutes > _maxDurationMinutes -> _angleEnd + simpleMinutesToAngle( 600 | _maxDurationMinutes, 601 | _hourFormat 602 | ) 603 | else -> newStartAngle 604 | } 605 | } 606 | } else if (_activeThumb == Thumb.END) { 607 | val difference = 608 | differenceBetweenAngles(_angleEnd, touchAngle) - _touchOffsetAngle 609 | val newEndAngle = angleTo720(_angleEnd + difference) 610 | val newDurationMinutes = durationBetweenMinutes( 611 | angleToPreciseMinutes(_angleStart, _hourFormat), 612 | angleToPreciseMinutes(newEndAngle, _hourFormat) 613 | ) 614 | 615 | _angleEnd = when { 616 | newDurationMinutes < _minDurationMinutes -> _angleStart - simpleMinutesToAngle( 617 | _minDurationMinutes, 618 | _hourFormat 619 | ) 620 | newDurationMinutes > _maxDurationMinutes -> _angleStart - simpleMinutesToAngle( 621 | _maxDurationMinutes, 622 | _hourFormat 623 | ) 624 | else -> newEndAngle 625 | } 626 | } 627 | 628 | anglesChanged(_activeThumb!!) 629 | invalidate() 630 | return true 631 | } 632 | MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { 633 | _angleStart = minutesToAngle( 634 | startTimeMinutes, 635 | _hourFormat 636 | ) 637 | _angleEnd = minutesToAngle( 638 | endTimeMinutes, 639 | _hourFormat 640 | ) 641 | 642 | updateGradient() 643 | invalidate() 644 | 645 | onDragChangeListener?.onDragStop(_activeThumb!!) 646 | _activeThumb = Thumb.NONE 647 | return true 648 | } 649 | } 650 | 651 | return false 652 | } 653 | 654 | private fun anglesChanged(thumb: Thumb) { 655 | updateGradient() 656 | 657 | if (onTimeChangeListener != null) { 658 | if (thumb == Thumb.START || thumb == Thumb.BOTH) { 659 | onTimeChangeListener?.onStartTimeChange(startTime) 660 | } 661 | if (thumb == Thumb.END || thumb == Thumb.BOTH) { 662 | onTimeChangeListener?.onEndTimeChange(endTime) 663 | } 664 | if (thumb == Thumb.START || thumb == Thumb.END) { 665 | onTimeChangeListener?.onDurationChange(duration) 666 | } 667 | } 668 | } 669 | 670 | private fun getClosestThumb(touchX: Float, touchY: Float): Thumb { 671 | getThumbPosition(angleTo360(_angleStart), _thumbPositionCache) 672 | val startThumbX = _thumbPositionCache.x 673 | val startThumbY = _thumbPositionCache.y 674 | 675 | getThumbPosition(_angleEnd, _thumbPositionCache) 676 | 677 | val endThumbX = _thumbPositionCache.x 678 | val endThumbY = _thumbPositionCache.y 679 | 680 | val distanceFromMiddle = 681 | MathUtils.distanceBetweenPoints(_middlePoint.x, _middlePoint.y, touchX, touchY) 682 | if (MathUtils.isPointInCircle( 683 | touchX, 684 | touchY, 685 | endThumbX, 686 | endThumbY, 687 | _thumbSize * 2f 688 | ) 689 | ) { 690 | return Thumb.END 691 | } else if (MathUtils.isPointInCircle( 692 | touchX, 693 | touchY, 694 | startThumbX, 695 | startThumbY, 696 | _thumbSize * 2f 697 | ) 698 | ) { 699 | return Thumb.START 700 | } else if (distanceFromMiddle > _radius - _sliderWidth * 2 && distanceFromMiddle < _radius + _sliderWidth * 2) { 701 | return Thumb.BOTH 702 | } 703 | 704 | return Thumb.NONE 705 | } 706 | 707 | private fun getThumbPosition( 708 | angle: Float, 709 | outPoint: PointF 710 | ) { 711 | val radians = Math.toRadians(-angle.toDouble()) 712 | 713 | outPoint.x = _middlePoint.x + _radius * cos(radians).toFloat() 714 | outPoint.y = _middlePoint.y + _radius * sin(radians).toFloat() 715 | } 716 | 717 | fun setOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener) { 718 | this.onTimeChangeListener = onTimeChangeListener 719 | } 720 | 721 | fun setOnDragChangeListener(onDragChangeListener: OnDragChangeListener) { 722 | this.onDragChangeListener = onDragChangeListener 723 | } 724 | 725 | private fun computeClockRadius(invalidate: Boolean = true) { 726 | _clockRadius = _radius - max(_thumbSize, _sliderWidth) / 2f - dpToPx(8f) 727 | invalidateBitmapCache() 728 | 729 | if(invalidate) { 730 | invalidate() 731 | } 732 | } 733 | 734 | private fun invalidateBitmapCache() { 735 | val renderer = _clockRenderer 736 | if(renderer is BitmapCachedClockRenderer && renderer.isBitmapCacheEnabled) { 737 | renderer.invalidateBitmapCache() 738 | } 739 | } 740 | 741 | val clockRadius: Float 742 | get() = _clockRadius 743 | 744 | var clockRenderer: ClockRenderer 745 | get() = _clockRenderer 746 | set(value) { 747 | val oldRenderer = _clockRenderer 748 | if(oldRenderer is BitmapCachedClockRenderer) { 749 | oldRenderer.recycleBitmapCache() 750 | } 751 | 752 | _clockRenderer = value 753 | if(value is BitmapCachedClockRenderer) { 754 | value.invalidateBitmapCache() 755 | } 756 | invalidate() 757 | } 758 | 759 | // Time 760 | var hourFormat 761 | get() = _hourFormat 762 | set(value) { 763 | val prevFormat = _hourFormat 764 | 765 | _hourFormat = if (value == HourFormat.FORMAT_SYSTEM) { 766 | if (DateFormat.is24HourFormat(context)) HourFormat.FORMAT_24 else HourFormat.FORMAT_12 767 | } else { 768 | value 769 | } 770 | 771 | _angleStart = minutesToAngle(angleToMinutes(_angleStart, prevFormat), _hourFormat) 772 | _angleEnd = minutesToAngle(angleToMinutes(_angleEnd, prevFormat), _hourFormat) 773 | 774 | updateGradient() 775 | invalidateBitmapCache() 776 | invalidate() 777 | } 778 | 779 | var startTime: Time 780 | get() = Time( 781 | startTimeMinutes 782 | ) 783 | set(value) { 784 | _angleStart = minutesToAngle(value.totalMinutes, _hourFormat) 785 | invalidate() 786 | } 787 | 788 | var startTimeMinutes: Int 789 | get() = snapMinutes( 790 | angleToMinutes(_angleStart, _hourFormat), _stepTimeMinutes 791 | ) 792 | set(value) { 793 | _angleStart = minutesToAngle(value, _hourFormat) 794 | invalidate() 795 | } 796 | 797 | var endTime: Time 798 | get() = Time( 799 | endTimeMinutes 800 | ) 801 | set(value) { 802 | _angleEnd = minutesToAngle(value.totalMinutes, _hourFormat) 803 | invalidate() 804 | } 805 | 806 | var endTimeMinutes: Int 807 | get() = snapMinutes( 808 | angleToMinutes(_angleEnd, _hourFormat), _stepTimeMinutes 809 | ) 810 | set(value) { 811 | _angleEnd = minutesToAngle(value, _hourFormat) 812 | invalidate() 813 | } 814 | 815 | val duration: TimeDuration 816 | get() = TimeDuration(startTime, endTime) 817 | 818 | val durationMinutes: Int 819 | get() = duration.durationMinutes 820 | 821 | var minDuration: Time 822 | get() = Time(_minDurationMinutes) 823 | set(value) { 824 | minDurationMinutes = value.totalMinutes 825 | } 826 | 827 | var minDurationMinutes: Int 828 | get() = _minDurationMinutes 829 | set(value) { 830 | if (value < 0 || value > 24 * 60) { 831 | throw java.lang.IllegalArgumentException("Minimum duration has to be between 00:00 and 24:00"); 832 | } 833 | 834 | if (value > _maxDurationMinutes) { 835 | throw IllegalArgumentException("Minimum duration cannot be greater than the maximum duration."); 836 | } 837 | 838 | _minDurationMinutes = value 839 | 840 | if (durationMinutes < _minDurationMinutes) { 841 | _angleEnd = minutesToAngle( 842 | endTimeMinutes + abs(durationMinutes - _maxDurationMinutes), 843 | _hourFormat 844 | ) 845 | invalidate() 846 | } 847 | } 848 | 849 | var maxDuration: Time 850 | get() = Time(_maxDurationMinutes) 851 | set(value) { 852 | maxDurationMinutes = value.totalMinutes 853 | } 854 | 855 | var maxDurationMinutes: Int 856 | get() = _maxDurationMinutes 857 | set(value) { 858 | if (value < 0 || value > 24 * 60) { 859 | throw java.lang.IllegalArgumentException("Maximum duration has to be between 00:00 and 24:00"); 860 | } 861 | 862 | if (value < _minDurationMinutes) { 863 | throw IllegalArgumentException("Maximum duration cannot be less than the minimum duration."); 864 | } 865 | 866 | _maxDurationMinutes = value 867 | 868 | if (durationMinutes > _maxDurationMinutes) { 869 | _angleEnd = minutesToAngle( 870 | endTimeMinutes - abs(durationMinutes - _maxDurationMinutes), 871 | _hourFormat 872 | ) 873 | invalidate() 874 | } 875 | } 876 | 877 | var stepTimeMinutes 878 | get() = _stepTimeMinutes 879 | set(value) { 880 | if (value > 24 * 60) { 881 | throw IllegalArgumentException("Minutes per step cannot be above 24 hours (24 * 60).") 882 | } 883 | 884 | _stepTimeMinutes = value 885 | invalidate() 886 | } 887 | 888 | // Slider 889 | var sliderWidth 890 | get() = _sliderWidth 891 | set(@ColorInt value) { 892 | _sliderWidth = value 893 | updatePaint() 894 | computeClockRadius() 895 | } 896 | 897 | var sliderColor 898 | @ColorInt 899 | get() = _sliderColor 900 | set(@ColorInt value) { 901 | _sliderColor = value 902 | updatePaint() 903 | } 904 | 905 | var sliderColorRes 906 | @Deprecated("", level = DeprecationLevel.HIDDEN) 907 | get() = 0 908 | set(@ColorRes value) { 909 | sliderColor = ContextCompat.getColor(context, value) 910 | } 911 | 912 | var sliderRangeColor 913 | @ColorInt 914 | get() = _sliderRangeColor 915 | set(@ColorInt value) { 916 | _sliderRangeGradientStart = null 917 | _sliderRangeGradientEnd = null 918 | _sliderRangeColor = value 919 | updatePaint() 920 | } 921 | 922 | var sliderRangeColorRes 923 | @Deprecated("", level = DeprecationLevel.HIDDEN) 924 | get() = 0 925 | set(@ColorRes value) { 926 | sliderRangeColor = ContextCompat.getColor(context, value) 927 | } 928 | 929 | var sliderRangeGradientStart 930 | @ColorInt 931 | get() = _sliderRangeGradientStart 932 | set(@ColorInt value) { 933 | _sliderRangeGradientStart = value 934 | updatePaint() 935 | } 936 | 937 | var sliderRangeGradientStartRes 938 | @Deprecated("", level = DeprecationLevel.HIDDEN) 939 | get() = 0 940 | set(@ColorRes value) { 941 | sliderRangeGradientStart = ContextCompat.getColor(context, value) 942 | } 943 | 944 | var sliderRangeGradientMiddle 945 | @ColorInt 946 | get() = _sliderRangeGradientMiddle 947 | set(@ColorInt value) { 948 | _sliderRangeGradientMiddle = value 949 | updatePaint() 950 | } 951 | 952 | var sliderRangeGradientMiddleRes 953 | @Deprecated("", level = DeprecationLevel.HIDDEN) 954 | get() = 0 955 | set(@ColorRes value) { 956 | sliderRangeGradientMiddle = ContextCompat.getColor(context, value) 957 | } 958 | 959 | var sliderRangeGradientEnd 960 | @ColorInt 961 | get() = _sliderRangeGradientEnd 962 | set(@ColorInt value) { 963 | _sliderRangeGradientEnd = value 964 | updatePaint() 965 | } 966 | 967 | var sliderRangeGradientEndRes 968 | @Deprecated("", level = DeprecationLevel.HIDDEN) 969 | get() = 0 970 | set(@ColorRes value) { 971 | sliderRangeGradientEnd = ContextCompat.getColor(context, value) 972 | } 973 | 974 | // Thumb 975 | var thumbSize 976 | get() = _thumbSize 977 | set(@ColorInt value) { 978 | _thumbSize = value 979 | updatePaint(invalidate = false) 980 | computeClockRadius() 981 | } 982 | 983 | var thumbSizeActiveGrow 984 | get() = _thumbSizeActiveGrow 985 | set(value) { 986 | _thumbSizeActiveGrow = value 987 | computeClockRadius() 988 | } 989 | 990 | var thumbColor 991 | @ColorInt 992 | get() = _thumbColor 993 | set(@ColorInt value) { 994 | _thumbColor = value 995 | _thumbColorAuto = false 996 | updatePaint() 997 | } 998 | 999 | var thumbColorRes 1000 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1001 | get() = 0 1002 | set(@ColorRes value) { 1003 | thumbColor = ContextCompat.getColor(context, value) 1004 | } 1005 | 1006 | var thumbColorAuto 1007 | get() = _thumbColorAuto 1008 | set(value) { 1009 | _thumbColorAuto = value 1010 | updatePaint() 1011 | } 1012 | 1013 | var thumbIconStart 1014 | get() = _thumbIconStart 1015 | set(value) { 1016 | _thumbIconStart = value?.mutate() 1017 | updateThumbIconColors() 1018 | } 1019 | 1020 | var thumbIconStartRes 1021 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1022 | get() = 0 1023 | set(@DrawableRes value) { 1024 | thumbIconStart = ContextCompat.getDrawable(context, value) 1025 | } 1026 | 1027 | var thumbIconEnd 1028 | get() = _thumbIconEnd 1029 | set(value) { 1030 | _thumbIconEnd = value?.mutate() 1031 | updateThumbIconColors() 1032 | } 1033 | 1034 | var thumbIconEndRes 1035 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1036 | get() = 0 1037 | set(@DrawableRes value) { 1038 | thumbIconEnd = ContextCompat.getDrawable(context, value) 1039 | } 1040 | 1041 | var thumbIconSize 1042 | get() = _thumbIconSize 1043 | set(@ColorInt value) { 1044 | _thumbIconSize = value 1045 | invalidate() 1046 | } 1047 | 1048 | var thumbIconColor 1049 | @ColorInt 1050 | get() = _thumbIconColor 1051 | set(@ColorInt value) { 1052 | _thumbIconColor = value 1053 | updateThumbIconColors() 1054 | } 1055 | 1056 | var thumbIconColorRes 1057 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1058 | get() = 0 1059 | set(@ColorRes value) { 1060 | thumbIconColor = ContextCompat.getColor(context, value) 1061 | } 1062 | 1063 | // Clock 1064 | var clockVisible 1065 | get() = _clockVisible 1066 | set(value) { 1067 | _clockVisible = value 1068 | invalidate() 1069 | } 1070 | 1071 | var clockFace 1072 | get() = _clockFace 1073 | set(value) { 1074 | _clockFace = value 1075 | invalidateBitmapCache() 1076 | invalidate() 1077 | } 1078 | 1079 | var clockTickColor 1080 | @ColorInt 1081 | get() = _clockTickColor 1082 | set(@ColorInt value) { 1083 | _clockTickColor = value 1084 | invalidateBitmapCache() 1085 | invalidate() 1086 | } 1087 | 1088 | var clockTickColorRes 1089 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1090 | get() = 0 1091 | set(@ColorRes value) { 1092 | clockTickColor = ContextCompat.getColor(context, value) 1093 | } 1094 | 1095 | var clockLabelColor 1096 | @ColorInt 1097 | get() = _clockLabelColor 1098 | set(@ColorInt value) { 1099 | _clockLabelColor = value 1100 | invalidateBitmapCache() 1101 | invalidate() 1102 | } 1103 | 1104 | var clockLabelColorRes 1105 | @Deprecated("", level = DeprecationLevel.HIDDEN) 1106 | get() = 0 1107 | set(@ColorRes value) { 1108 | clockLabelColor = ContextCompat.getColor(context, value) 1109 | } 1110 | 1111 | var clockLabelSize 1112 | @Dimension 1113 | get() = _clockLabelSize 1114 | set(@Dimension value) { 1115 | _clockLabelSize = value 1116 | invalidateBitmapCache() 1117 | invalidate() 1118 | } 1119 | 1120 | open class Time(val totalMinutes: Int) { 1121 | constructor(hr: Int, min: Int) : this(hr * 60 + min) 1122 | 1123 | val hour: Int 1124 | get() = totalMinutes / 60 % 24 1125 | val minute: Int 1126 | get() = totalMinutes % 60 1127 | 1128 | val localTime: LocalTime 1129 | @RequiresApi(Build.VERSION_CODES.O) 1130 | get() = LocalTime.of(hour, minute) 1131 | 1132 | val calendar: Calendar 1133 | get() = Calendar.getInstance().apply { 1134 | set(Calendar.HOUR_OF_DAY, hour) 1135 | set(Calendar.MINUTE, minute) 1136 | } 1137 | 1138 | override fun toString(): String { 1139 | return "$hour:${minute.toString().padStart(2, '0')}" 1140 | } 1141 | 1142 | companion object { 1143 | fun parse(time: String): Time { 1144 | return Time(parseToTotalMinutes(time)) 1145 | } 1146 | 1147 | internal fun parseToTotalMinutes(str: String): Int { 1148 | fun throwInvalidFormat(): Nothing { 1149 | throw IllegalArgumentException("Format of time value '$str' is invalid, expected format hh:mm.") 1150 | } 1151 | 1152 | val colonIdx = str.indexOf(':') 1153 | 1154 | if (colonIdx < 0) { 1155 | throwInvalidFormat() 1156 | } 1157 | 1158 | val hour: Int 1159 | val minute: Int 1160 | 1161 | try { 1162 | hour = str.parsePositiveInt(0, colonIdx) 1163 | minute = str.parsePositiveInt(colonIdx + 1, str.length) 1164 | } catch (e: Exception) { 1165 | throwInvalidFormat() 1166 | } 1167 | 1168 | if (hour >= 24 || minute >= 60) { 1169 | throwInvalidFormat() 1170 | } 1171 | 1172 | return hour * 60 + minute 1173 | } 1174 | } 1175 | } 1176 | 1177 | open class TimeDuration(val start: Time, val end: Time) { 1178 | val durationMinutes: Int 1179 | get() { 1180 | return if (start.totalMinutes > end.totalMinutes) { 1181 | 24 * 60 - (start.totalMinutes - end.totalMinutes) 1182 | } else { 1183 | end.totalMinutes - start.totalMinutes 1184 | } 1185 | } 1186 | 1187 | val hour: Int 1188 | get() = durationMinutes / 60 % 24 1189 | val minute: Int 1190 | get() = durationMinutes % 60 1191 | 1192 | val duration: Duration 1193 | @RequiresApi(Build.VERSION_CODES.O) 1194 | get() = Duration.ofMinutes(durationMinutes.toLong()) 1195 | 1196 | val classicDuration: javax.xml.datatype.Duration 1197 | @RequiresApi(Build.VERSION_CODES.FROYO) 1198 | get() = 1199 | DatatypeFactory.newInstance().newDuration(true, 0, 0, 0, hour, minute, 0) 1200 | 1201 | override fun toString(): String { 1202 | return "$hour:${minute.toString().padStart(2, '0')}" 1203 | } 1204 | } 1205 | 1206 | enum class Thumb { 1207 | NONE, START, END, BOTH 1208 | } 1209 | 1210 | enum class HourFormat(val id: Int) { 1211 | FORMAT_SYSTEM(0), 1212 | FORMAT_12(1), 1213 | FORMAT_24(2); 1214 | 1215 | companion object { 1216 | fun fromId(id: Int): HourFormat { 1217 | for (f in values()) { 1218 | if (f.id == id) return f 1219 | } 1220 | throw IllegalArgumentException() 1221 | } 1222 | } 1223 | } 1224 | 1225 | enum class ClockFace(val id: Int) { 1226 | APPLE(0), 1227 | SAMSUNG(1); 1228 | 1229 | companion object { 1230 | fun fromId(id: Int): ClockFace { 1231 | for (f in values()) { 1232 | if (f.id == id) return f 1233 | } 1234 | throw IllegalArgumentException() 1235 | } 1236 | } 1237 | } 1238 | 1239 | interface OnTimeChangeListener { 1240 | fun onStartTimeChange(startTime: Time) 1241 | fun onEndTimeChange(endTime: Time) 1242 | fun onDurationChange(duration: TimeDuration) 1243 | } 1244 | 1245 | interface OnDragChangeListener { 1246 | fun onDragStart(thumb: Thumb): Boolean 1247 | fun onDragStop(thumb: Thumb) 1248 | } 1249 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker.utils 2 | 3 | import android.content.Context 4 | import android.content.res.Resources 5 | import android.util.TypedValue 6 | import androidx.annotation.AttrRes 7 | import androidx.annotation.ColorInt 8 | import androidx.core.content.ContextCompat 9 | 10 | private val displayMetrics = Resources.getSystem().displayMetrics 11 | private val density = displayMetrics.density 12 | private val invDensity = 1f / density 13 | private val sDensity = displayMetrics.scaledDensity 14 | 15 | fun dpToPx(value: Float): Float = value * density 16 | fun pxToDp(value: Float): Float = value * invDensity 17 | fun spToPx(value: Float): Float = value * sDensity 18 | 19 | @ColorInt 20 | internal fun Context.getColorResCompat(@AttrRes id: Int): Int { 21 | return ContextCompat.getColor(this, getResourceId(id)) 22 | } 23 | 24 | @ColorInt 25 | internal fun Context.getTextColor(@AttrRes id: Int): Int { 26 | val typedValue = TypedValue() 27 | theme.resolveAttribute(id, typedValue, true) 28 | val arr = obtainStyledAttributes( 29 | typedValue.data, intArrayOf( 30 | id 31 | ) 32 | ) 33 | val color = arr.getColor(0, -1) 34 | arr.recycle() 35 | return color 36 | } 37 | 38 | internal fun Context.getResourceId(id: Int): Int { 39 | val resolvedAttr = TypedValue() 40 | theme.resolveAttribute(id, resolvedAttr, true) 41 | return resolvedAttr.run { if (resourceId != 0) resourceId else data } 42 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/utils/MathUtils.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker.utils 2 | 3 | import nl.joery.timerangepicker.TimeRangePicker 4 | import kotlin.math.* 5 | 6 | internal object MathUtils { 7 | private const val R2D = 180f / PI.toFloat() 8 | 9 | fun differenceBetweenAngles(a1: Float, a2: Float): Float { 10 | val angle1 = Math.toRadians(a1.toDouble()) 11 | val angle2 = Math.toRadians(a2.toDouble()) 12 | 13 | val sinAngle1 = sin(angle1).toFloat() 14 | val cosAngle1 = cos(angle1).toFloat() 15 | 16 | val sinAngle2 = sin(angle2).toFloat() 17 | val cosAngle2 = cos(angle2).toFloat() 18 | 19 | return atan2( 20 | cosAngle1 * sinAngle2 - sinAngle1 * cosAngle2, 21 | cosAngle1 * cosAngle2 + sinAngle1 * sinAngle2 22 | ) * R2D 23 | } 24 | 25 | fun angleTo360(angle: Float): Float { 26 | var result = angle % 360 27 | if (result < 0) { 28 | result += 360.0f 29 | } 30 | return result 31 | } 32 | 33 | fun angleTo720(angle: Float): Float { 34 | var result = angle % 720 35 | if (result < 0) { 36 | result += 720.0f 37 | } 38 | return result 39 | } 40 | 41 | fun simpleMinutesToAngle(minutes: Int, hourFormat: TimeRangePicker.HourFormat): Float { 42 | return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) { 43 | minutes / (12 * 60.0f) * 360.0f 44 | } else { 45 | minutes / (24 * 60.0f) * 360.0f 46 | } 47 | } 48 | 49 | fun minutesToAngle(minutes: Int, hourFormat: TimeRangePicker.HourFormat): Float { 50 | return angleTo720(90 - simpleMinutesToAngle(minutes, hourFormat)) 51 | } 52 | 53 | fun angleToPreciseMinutes(angle: Float, hourFormat: TimeRangePicker.HourFormat): Float { 54 | return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) { 55 | (angleTo720(90 - angle) / 360 * 12 * 60) % (24 * 60) 56 | } else { 57 | (angleTo720(90 - angle) / 360 * 24 * 60) % (24 * 60) 58 | } 59 | } 60 | 61 | fun angleToMinutes(angle: Float, hourFormat: TimeRangePicker.HourFormat): Int { 62 | return if (hourFormat == TimeRangePicker.HourFormat.FORMAT_12) { 63 | (angleTo720(90 - angle) / 360 * 12 * 60).roundToInt() % (24 * 60) 64 | } else { 65 | (angleTo720(90 - angle) / 360 * 24 * 60).roundToInt() % (24 * 60) 66 | } 67 | } 68 | 69 | fun snapMinutes(minutes: Int, step: Int): Int { 70 | return minutes / step * step + 2 * (minutes % step) / step * step 71 | } 72 | 73 | fun isPointInCircle( 74 | x: Float, 75 | y: Float, 76 | cx: Float, 77 | cy: Float, 78 | radius: Float 79 | ): Boolean { 80 | return sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) < radius 81 | } 82 | 83 | fun distanceBetweenPoints( 84 | x1: Float, 85 | y1: Float, 86 | x2: Float, 87 | y2: Float 88 | ): Float { 89 | val deltaX = x1 - x2 90 | val deltaY = y1 - y2 91 | return sqrt(deltaX * deltaX + deltaY * deltaY) 92 | } 93 | 94 | fun durationBetweenMinutes(startMinutes: Float, endMinutes: Float): Float { 95 | return if (startMinutes > endMinutes) { 96 | 24f * 60f - (startMinutes - endMinutes) 97 | } else { 98 | endMinutes - startMinutes 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/utils/ReflectionUtils.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker.utils 2 | 3 | import nl.joery.timerangepicker.ClockRenderer 4 | import nl.joery.timerangepicker.TimeRangePicker 5 | 6 | internal fun createClockRenderer(name: String, picker: TimeRangePicker): ClockRenderer { 7 | val c = Class.forName(name, true, TimeRangePicker::class.java.classLoader) 8 | 9 | // try to find only public with no arguments 10 | for (constructor in c.constructors) { 11 | val params = constructor.parameterTypes 12 | 13 | if(params.size == 1 && params[0] == TimeRangePicker::class.java) { 14 | val raw = constructor.newInstance(picker) 15 | 16 | try { 17 | return raw as ClockRenderer 18 | } catch (e: ClassCastException) { 19 | throw ClassCastException("Class '$name' is set as clock renderer but it does not extend '${TimeRangePicker::class.java.name}'") 20 | } 21 | } 22 | } 23 | 24 | throw RuntimeException("Clock renderer ($name) does not contain any public constructor with one parameter: ${TimeRangePicker::class.java.name}") 25 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/java/nl/joery/timerangepicker/utils/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker.utils 2 | 3 | internal fun String.parsePositiveInt(start: Int, end: Int): Int { 4 | var result = 0 5 | for(i in start until end) { 6 | val c = this[i].code 7 | 8 | if(c < '0'.code || c > '9'.code) { 9 | throw IllegalArgumentException("String has invalid format (Illegal character '$c')") 10 | } 11 | 12 | result = result * 10 + (c - '0'.code) 13 | } 14 | 15 | return result 16 | } -------------------------------------------------------------------------------- /timerangepicker/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /timerangepicker/src/test/java/nl/joery/timerangepicker/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /timerangepicker/src/test/java/nl/joery/timerangepicker/ReflectionUtilsTests.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import android.graphics.Canvas 4 | import nl.joery.timerangepicker.utils.createClockRenderer 5 | import org.junit.Assert 6 | import org.junit.Test 7 | 8 | class ReflectionUtilsTests { 9 | class ClockRenderer_NoPublicConstructor_NoInstance { 10 | private constructor() 11 | constructor(someArgs: Int) 12 | } 13 | 14 | class ClockRenderer_DoNotExtendClockRenderer 15 | class ClockRenderer_InstanceWithInvalidMods_1 private constructor() { 16 | companion object { 17 | @JvmField 18 | var INSTANCE = Any() // non-final for Java 19 | } 20 | } 21 | class ClockRenderer_InstanceWithInvalidMods_2 private constructor() { 22 | val INSTANCE = Any() // non-static for Java 23 | } 24 | class ClockRenderer_InstanceWithInvalidMods_3 private constructor() { 25 | @Volatile 26 | var INSTANCE = Any() // non-final, non-static and volatile is not expected here 27 | } 28 | 29 | class ClockRenderer_InstanceIsNull private constructor() { 30 | companion object { 31 | @JvmField 32 | val INSTANCE: Any? = null 33 | } 34 | } 35 | 36 | class ClockRenderer_InstanceDoNotExtendClockRenderer private constructor() { 37 | companion object { 38 | @JvmField 39 | val INSTANCE = Any() 40 | } 41 | } 42 | 43 | object ClockRenderer_Valid_GeneratedByKotlin: ClockRenderer { 44 | override fun render(canvas: Canvas, picker: TimeRangePicker, radius: Float) { 45 | throw NotImplementedError() 46 | } 47 | } 48 | 49 | class ClockRenderer_Valid_Manual private constructor() : ClockRenderer { 50 | override fun render(canvas: Canvas, picker: TimeRangePicker, radius: Float) { 51 | throw NotImplementedError() 52 | } 53 | 54 | companion object { 55 | @JvmField 56 | val INSTANCE = ClockRenderer_Valid_Manual() 57 | } 58 | } 59 | 60 | private inline fun createClockRendererThrowsWhen() { 61 | try { 62 | val className = T::class.java.name 63 | createClockRenderer(className) 64 | Assert.fail("Exception wasn't thrown") 65 | } catch (e: Exception) { 66 | val eClass = e.javaClass 67 | if(eClass !== TException::class.java) { 68 | Assert.fail("Throws but exception class is $eClass, but expected ${TException::class.java}") 69 | } 70 | } 71 | } 72 | 73 | @Test 74 | fun createClockRenderer_throwsWhenInvalidClassNameGiven() { 75 | try { 76 | createClockRenderer("123") 77 | Assert.fail("Exception wasn't thrown") 78 | } catch (e: ClassNotFoundException) { 79 | } catch (e: Exception) { 80 | Assert.fail("ClassCastException was expected to be thrown. Actual: ${e.javaClass.name}") 81 | } 82 | } 83 | 84 | @Test 85 | fun createClockRenderer_throwsWhenNoPublicConstructor_NoInstance() { 86 | createClockRendererThrowsWhen() 87 | } 88 | 89 | @Test 90 | fun createClockRenderer_throwsWhenDoNotExtendClockRenderer() { 91 | createClockRendererThrowsWhen() 92 | } 93 | 94 | @Test 95 | fun createClockRenderer_throwsWhenInstanceWithInvalidMods() { 96 | createClockRendererThrowsWhen() 97 | createClockRendererThrowsWhen() 98 | createClockRendererThrowsWhen() 99 | } 100 | 101 | @Test 102 | fun createClockRenderer_throwsWhenInstanceIsNull() { 103 | createClockRendererThrowsWhen() 104 | } 105 | 106 | @Test 107 | fun createClockRenderer_throwsWhenInstanceDoNotExtendClockRenderer() { 108 | createClockRendererThrowsWhen() 109 | } 110 | 111 | @Test 112 | fun createClockRenderer_successWhenRequirementsAreMet() { 113 | createClockRenderer(ClockRenderer_Valid_GeneratedByKotlin::class.java.name) 114 | createClockRenderer(ClockRenderer_Valid_Manual::class.java.name) 115 | } 116 | } -------------------------------------------------------------------------------- /timerangepicker/src/test/java/nl/joery/timerangepicker/TimeRangePickerTests.kt: -------------------------------------------------------------------------------- 1 | package nl.joery.timerangepicker 2 | 3 | import org.junit.Assert 4 | import org.junit.Test 5 | 6 | class TimeRangePickerTests { 7 | @Test 8 | fun time_parse_test() { 9 | fun testCase(timeString: String, expectedHour: Int, expectedMinute: Int) { 10 | val time = TimeRangePicker.Time.parse(timeString) 11 | 12 | Assert.assertEquals(expectedHour, time.hour) 13 | Assert.assertEquals(expectedMinute, time.minute) 14 | } 15 | 16 | testCase("00:00", 0, 0) 17 | testCase("10:45", 10, 45) 18 | testCase("02:06", 2, 6) 19 | testCase("23:59", 23, 59) 20 | testCase("20:40", 20, 40) 21 | } 22 | } --------------------------------------------------------------------------------