├── .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 |
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 | Attribute |
105 | Description |
106 | Default |
107 |
108 |
109 | trp_startTime |
110 | Set the start time by providing a time with format h:mm. |
111 | 0:00 |
112 |
113 |
114 | trp_startTimeMinutes |
115 | Set the start time by providing minutes between 0 and 1440 (24 hours). |
116 | 0 |
117 |
118 |
119 | trp_endTime |
120 | Set the end time by providing a time with format h:mm. |
121 | 8:00 |
122 |
123 |
124 | trp_endTimeMinutes |
125 | Set the end time by providing minutes between 0 and 1440 (24 hours). |
126 | 480 |
127 |
128 |
129 | trp_minDuration |
130 | Set the minimum selectable duration by providing a duration with format h:mm. |
131 | |
132 |
133 |
134 | trp_maxDuration |
135 | Set the maximum selectable duration by providing a duration with format h:mm. |
136 | |
137 |
138 |
139 | trp_maxDurationMinutes |
140 | Set the maximum selectable duration by providing minutes between 0 and 1440 (24 hours). |
141 | 480 |
142 |
143 |
144 | trp_minDurationMinutes |
145 | Set the minimum selectable duration by providing minutes between 0 and 1440 (24 hours). |
146 | 0 |
147 |
148 |
149 | trp_stepTimeMinutes |
150 | Determines at what interval the time should be rounded. Setting it to a less accurate number (e.g. 10 minutes) makes it easier for a user to select his desired time. |
151 | 10 |
152 |
153 |
154 |
155 | ### Slider
156 |
157 |
158 | Attribute |
159 | Description |
160 | Default |
161 |
162 |
163 | trp_sliderWidth |
164 | The width of the slider wheel. |
165 | 8dp |
166 |
167 |
168 | trp_sliderColor |
169 | The background color of the slider wheel. |
170 | #E1E1E1 |
171 |
172 |
173 |
174 | trp_sliderRangeColor |
175 | The color of the active part of the slider wheel. |
176 | ?android:colorPrimary |
177 |
178 |
179 |
180 | trp_sliderRangeGradientStart |
181 | Set the starting gradient color of the active part of the slider wheel.
Please note that both trp_sliderRangeGradientStart and trp_sliderRangeGradientEnd need to be configured.
Tip: Set the thumbColor to transparent to mimic the Apple iOS slider. |
182 | |
183 |
184 |
185 |
186 | trp_sliderRangeGradientStart |
187 | Optional for gradient: set the middle gradient color of the active part of the slider wheel. |
188 | |
189 |
190 |
191 |
192 | trp_sliderRangeGradientEnd |
193 | Set the ending gradient color of the active part of the slider wheel.
Please note that both trp_sliderRangeGradientStart and trp_sliderRangeGradientEnd need to be configured. |
194 | |
195 |
196 |
197 |
198 | ### Thumb
199 |
200 |
201 | Attribute |
202 | Description |
203 | Default |
204 |
205 |
206 | trp_thumbIconStart |
207 | Set the start thumb icon. |
208 | |
209 |
210 |
211 | trp_thumbIconEnd |
212 | Set the end thumb icon. |
213 | |
214 |
215 |
216 | trp_thumbSize |
217 | The size of both the starting and ending thumb. |
218 | 28dp |
219 |
220 |
221 | trp_thumbSizeActiveGrow |
222 | The amount of growth of the size when a thumb is being dragged. |
223 | 1.2 |
224 |
225 |
226 | trp_thumbColor |
227 | The background color of the thumbs. |
228 | ?android:colorPrimary |
229 |
230 |
231 | trp_thumbIconColor |
232 | The color (tint) of the icons inside the thumbs. |
233 | white |
234 |
235 |
236 | trp_thumbIconSize |
237 | The size of the thumb icons. |
238 | 24dp |
239 |
240 |
241 |
242 | ### Clock
243 |
244 |
245 | Attribute |
246 | Description |
247 | Default |
248 |
249 |
250 | trp_clockVisible |
251 | Whether the clock face in the middle should be visible. |
252 | true |
253 |
254 |
255 | trp_clockFace |
256 | There 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 | |
262 | APPLE |
263 |
264 |
265 | trp_clockLabelSize |
266 | The text size of the hour labels in the clock (1, 2, 3, etc.). This value is recommended to be set as scale-independent pixels (sp). |
267 | 16sp |
268 |
269 |
270 | trp_clockLabelColor |
271 | Set the text color of the hour labels in the clock. |
272 | ?android:textColorPrimary |
273 |
274 |
275 | trp_clockIndicatorColor |
276 | Set the color of the small time indicator lines in the clock. |
277 | ?android:textColorPrimary |
278 |
279 |
280 | trp_clockRenderer |
281 |
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 | |
290 | nl.joery.timerangepicker.DefaultClockRenderer |
291 |
292 |
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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------