diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..251778b --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fc885a --- /dev/null +++ b/README.md @@ -0,0 +1,309 @@ +# TimeRangePicker +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/nl.joery.timerangepicker/timerangepicker/badge.svg)](https://maven-badges.herokuapp.com/maven-central/nl.joery.timerangepicker/timerangepicker) +[![API](https://img.shields.io/badge/API-14%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=14) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A customizable, easy-to-use, and functional circular time range picker library for Android. Use this library to mimic Apple's iOS or Samsung's bedtime picker. + +## Examples + + + + + +

+ +## Playground app + + +Download the playground app: [demo.apk](./art/demo.apk). In this app, you can try out all features and even generate XML layouts. + +## Contents +- [Getting started](#getting-started) +- [Managing picker programmatically](#managing-picker-programmatically) +- [Configuration](#configuration) + +## Getting started + + +This library is available on Maven Central, add the following dependency to your build.gradle: +```gradle +implementation 'nl.joery.timerangepicker:timerangepicker:1.0.0' +``` +Define `TimeRangePicker` in your XML layout with custom attributes. See the [Configuration](#configuration) section for more information. +```xml + +``` + +Get notified when the time or duration changes: +```kotlin +picker.setOnTimeChangeListener(object : TimeRangePicker.OnTimeChangeListener { + override fun onStartTimeChange(startTime: TimeRangePicker.Time) { + Log.d("TimeRangePicker", "Start time: " + startTime) + } + + override fun onEndTimeChange(endTime: TimeRangePicker.Time) { + Log.d("TimeRangePicker", "End time: " + endTime.hour) + } + + override fun onDurationChange(duration: TimeRangePicker.TimeDuration) { + Log.d("TimeRangePicker", "Duration: " + duration.hour) + } +}) +``` + +## Managing picker programmatically +### Managing time +Examples of how to set and retrieve start time programmatically, identical properties are available for the end time. + +```kotlin +// Set new time with 'Time' object to 12:00 +picker.startTime = TimeRangePicker.Time(12, 0) +// Set new time by minutes +picker.startTimeMinutes = 320 +``` + +Time +When retrieving the start or end time, the library will provide a `TimeRangePicker.Time` object. +- Use `time.hour`, `time.minute` or `time.totalMinutes` to retrieve literal time. +- Use `time.calendar` to retrieve a `java.util.Calendar` object. +- Use `time.localTime` to retrieve a `java.time.LocalTime` object. (Available since API 26) + +### Managing duration +When retrieving the duration between the start and end time, the library will provide a `TimeRangePicker.Duration` object. +- Use `duration.hour`, `duration.minute` or `duration.durationMinutes` to retrieve literal duration. +- Use `duration.classicDuration` to retrieve a `javax.xml.datatype.Duration` object. (Available since API 8) +- Use `duration.duration` to retrieve a `java.time.Duration` object. (Available since API 26) + +### Listening for starting and stopping of dragging +This listener is called whenever a user starts or stops dragging. It will also provide which thumb the user was dragging: start, end, or both thumbs. You can return false in the `ònDragStart` method to prevent the user from dragging a thumb. + +```kotlin +picker.setOnDragChangeListener(object : TimeRangePicker.OnDragChangeListener { + override fun onDragStart(thumb: TimeRangePicker.Thumb): Boolean { + // Do something on start dragging + return true // Return false to disallow the user from dragging a handle. + } + + override fun onDragStop(thumb: TimeRangePicker.Thumb) { + // Do something on stop dragging + } +}) +``` + +## Configuration +The attributes listed below can be used to configure the look and feel of the picker. Note that all of these values can also be set programmatically using the properties. +### Time + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionDefault
trp_startTimeSet the start time by providing a time with format h:mm.0:00
trp_startTimeMinutesSet the start time by providing minutes between 0 and 1440 (24 hours).0
trp_endTimeSet the end time by providing a time with format h:mm.8:00
trp_endTimeMinutesSet the end time by providing minutes between 0 and 1440 (24 hours).480
trp_minDurationSet the minimum selectable duration by providing a duration with format h:mm.
trp_maxDurationSet the maximum selectable duration by providing a duration with format h:mm.
trp_maxDurationMinutesSet the maximum selectable duration by providing minutes between 0 and 1440 (24 hours).480
trp_minDurationMinutesSet the minimum selectable duration by providing minutes between 0 and 1440 (24 hours).0
trp_stepTimeMinutesDetermines at what interval the time should be rounded. Setting it to a less accurate number (e.g. 10 minutes) makes it easier for a user to select his desired time.10
+ +### Slider + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionDefault
trp_sliderWidthThe width of the slider wheel.8dp
trp_sliderColorThe background color of the slider wheel.#E1E1E1
trp_sliderRangeColorThe color of the active part of the slider wheel.?android:colorPrimary
trp_sliderRangeGradientStartSet the starting gradient color of the active part of the slider wheel.

Please note that both trp_sliderRangeGradientStart and trp_sliderRangeGradientEnd need to be configured.

Tip: Set the thumbColor to transparent to mimic the Apple iOS slider.
trp_sliderRangeGradientStartOptional for gradient: set the middle gradient color of the active part of the slider wheel.
trp_sliderRangeGradientEndSet the ending gradient color of the active part of the slider wheel.

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