aboutsummaryrefslogtreecommitdiff
path: root/core/src/main/kotlin/ca
diff options
context:
space:
mode:
authorAllan Wang <me@allanwang.ca>2017-07-04 16:08:03 -0700
committerAllan Wang <me@allanwang.ca>2017-07-04 16:08:03 -0700
commitcf2a7fcd0880a8d276970124cdb5d5845d5631fe (patch)
treecc38ead7853ddb85c9c988e94a4af605e1e676f8 /core/src/main/kotlin/ca
parentfe4632c34a1d671503e0242a269865b884545e13 (diff)
downloadkau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.tar.gz
kau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.tar.bz2
kau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.zip
Separate core components in its own module
Diffstat (limited to 'core/src/main/kotlin/ca')
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt236
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt85
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt189
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt13
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt45
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java764
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt52
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt63
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt51
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt41
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt23
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt105
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt228
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt349
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt82
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt309
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt92
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt127
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt48
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt49
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt23
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt100
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt50
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt51
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt12
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt35
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt137
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt120
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt89
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt33
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt73
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt25
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt85
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt126
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt29
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt43
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt62
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt6
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt19
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt58
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt26
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt68
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt80
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt412
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt71
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt15
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt153
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt235
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt6
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt165
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt32
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt29
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt12
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt20
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt166
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt48
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt22
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt19
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt111
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt126
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt50
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt183
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt140
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt19
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt234
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java859
-rw-r--r--core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt125
67 files changed, 7553 insertions, 0 deletions
diff --git a/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt b/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt
new file mode 100644
index 0000000..32e8745
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt
@@ -0,0 +1,236 @@
+package ca.allanwang.kau.about
+
+import android.graphics.drawable.Drawable
+import android.os.Bundle
+import android.support.v4.view.PagerAdapter
+import android.support.v4.view.ViewPager
+import android.support.v7.app.AppCompatActivity
+import android.support.v7.widget.RecyclerView
+import android.transition.TransitionInflater
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.FastItemThemedAdapter
+import ca.allanwang.kau.adapters.ThemableIItemColors
+import ca.allanwang.kau.adapters.ThemableIItemColorsDelegate
+import ca.allanwang.kau.animators.FadeScaleAnimator
+import ca.allanwang.kau.iitems.CutoutIItem
+import ca.allanwang.kau.iitems.HeaderIItem
+import ca.allanwang.kau.iitems.LibraryIItem
+import ca.allanwang.kau.utils.*
+import ca.allanwang.kau.widgets.ElasticDragDismissFrameLayout
+import ca.allanwang.kau.widgets.InkPageIndicator
+import com.mikepenz.aboutlibraries.Libs
+import com.mikepenz.aboutlibraries.entity.Library
+import com.mikepenz.fastadapter.IItem
+import org.jetbrains.anko.doAsync
+import org.jetbrains.anko.uiThread
+import java.security.InvalidParameterException
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ *
+ * Floating About Activity Panel for your app
+ * This contains all the necessary layouts, and can be extended and configured using the [configBuilder]
+ * The [rClass] is necessary to generate the list of libraries used in your app, and should point to your app's
+ * R.string::class.java
+ * Note that for the auto detection to work, the R fields must be excluded from Proguard
+ * Manual lib listings and other extra modifications can be done so by overriding the open functions
+ */
+abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Configs.() -> Unit = {}) : AppCompatActivity(), ViewPager.OnPageChangeListener {
+
+ val draggableFrame: ElasticDragDismissFrameLayout by bindView(R.id.about_draggable_frame)
+ val pager: ViewPager by bindView(R.id.about_pager)
+ val indicator: InkPageIndicator by bindView(R.id.about_indicator)
+ /**
+ * Holds some common configurations that may be added directly from the constructor
+ * Applied lazily since it needs the context to fetch resources
+ */
+ val configs: Configs by lazy { Configs().apply { configBuilder() } }
+ /**
+ * Number of pages in the adapter
+ * Defaults to just the main view and lib view
+ */
+ open val pageCount: Int = 2
+ /**
+ * Page position for the libs
+ * This is generated automatically if [inflateLibPage] is called
+ */
+ private var libPage: Int = -2
+ /**
+ * Holds that status of each page
+ * 0 means nothing has happened
+ * 1 means this page has been in view at least once
+ * The rest is up to you
+ */
+ lateinit var pageStatus: IntArray
+ /**
+ * Holds the lib items once they are fetched asynchronously
+ */
+ var libItems: List<LibraryIItem>? = null
+ /**
+ * Holds the adapter for the library page; this is generated later because it uses the config colors
+ */
+ lateinit var libAdapter: FastItemThemedAdapter<IItem<*, *>>
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.kau_activity_about)
+ pageStatus = IntArray(pageCount)
+ libAdapter = FastItemThemedAdapter(configs)
+ LibraryIItem.bindClickEvents(libAdapter)
+ if (configs.textColor != null) indicator.setColour(configs.textColor!!)
+ with(pager) {
+ adapter = AboutPagerAdapter()
+ pageMargin = dimenPixelSize(R.dimen.kau_spacing_normal)
+ addOnPageChangeListener(this@AboutActivityBase)
+ }
+ indicator.setViewPager(pager)
+ draggableFrame.addListener(object : ElasticDragDismissFrameLayout.SystemChromeFader(this) {
+ override fun onDragDismissed() {
+ // if we drag dismiss downward then the default reversal of the enter
+ // transition would slide content upward which looks weird. So reverse it.
+ if (draggableFrame.translationY > 0) {
+ window.returnTransition = TransitionInflater.from(this@AboutActivityBase)
+ .inflateTransition(configs.transitionExitReversed)
+ }
+ finishAfterTransition()
+ }
+ })
+ }
+
+ inner class Configs : ThemableIItemColors by ThemableIItemColorsDelegate() {
+ var cutoutTextRes: Int = -1
+ var cutoutText: String? = null
+ var cutoutDrawableRes: Int = -1
+ var cutoutDrawable: Drawable? = null
+ var cutoutForeground: Int? = null
+ var libPageTitleRes: Int = -1
+ var libPageTitle: String? = string(R.string.kau_about_libraries_intro) //This is in the string by default since it's lower priority
+ /**
+ * Transition to be called if the view is dragged down
+ */
+ var transitionExitReversed: Int = R.transition.kau_about_return_downward
+ }
+
+ /**
+ * Method to fetch the library list
+ * This is fetched asynchronously and you may override it to customize the list
+ */
+ open fun getLibraries(libs: Libs): List<Library> = libs.prepareLibraries(this, null, null, true, true)
+
+ /**
+ * Gets the view associated with the given page position
+ * Keep in mind that when inflating, do NOT add the view to the viewgroup
+ * Use layoutInflater.inflate(id, parent, false)
+ */
+ open fun getPage(position: Int, layoutInflater: LayoutInflater, parent: ViewGroup): View {
+ return when (position) {
+ 0 -> inflateMainPage(layoutInflater, parent, position)
+ pageCount - 1 -> inflateLibPage(layoutInflater, parent, position)
+ else -> throw InvalidParameterException()
+ }
+ }
+
+ /**
+ * Create the main view with the cutout
+ */
+ open fun inflateMainPage(layoutInflater: LayoutInflater, parent: ViewGroup, position: Int): View {
+ val fastAdapter = FastItemThemedAdapter<IItem<*, *>>(configs)
+ val recycler = fullLinearRecycler(fastAdapter)
+ fastAdapter.add(CutoutIItem {
+ with(configs) {
+ text = string(cutoutTextRes, cutoutText)
+ drawable = drawable(cutoutDrawableRes, cutoutDrawable)
+ if (configs.cutoutForeground != null) foregroundColor = configs.cutoutForeground!!
+ }
+ }.apply {
+ themeEnabled = configs.cutoutForeground == null
+ })
+ postInflateMainPage(fastAdapter)
+ return recycler
+ }
+
+ /**
+ * Open hook called just before the main page view is returned
+ * Feel free to add your own items to the adapter in here
+ */
+ open fun postInflateMainPage(adapter: FastItemThemedAdapter<IItem<*, *>>) {
+
+ }
+
+ /**
+ * Create the lib view with the list of libraries
+ */
+ open fun inflateLibPage(layoutInflater: LayoutInflater, parent: ViewGroup, position: Int): View {
+ libPage = position
+ val v = layoutInflater.inflate(R.layout.kau_recycler_detached_background, parent, false)
+ val recycler = v.findViewById<RecyclerView>(R.id.kau_recycler_detached)
+ recycler.adapter = libAdapter
+ recycler.itemAnimator = FadeScaleAnimator(itemDelayFactor = 0.2f).apply { addDuration = 300; interpolator = AnimHolder.decelerateInterpolator(this@AboutActivityBase) }
+ val background = v.findViewById<View>(R.id.kau_recycler_detached_background)
+ if (configs.backgroundColor != null) background.setBackgroundColor(configs.backgroundColor!!.colorToForeground())
+ doAsync {
+ libItems = getLibraries(Libs(this@AboutActivityBase, Libs.toStringArray(rClass.fields))).map { LibraryIItem(it) }
+ if (libPage >= 0 && pageStatus[libPage] == 1)
+ uiThread { addLibItems() }
+ }
+ return v
+ }
+
+ inner class AboutPagerAdapter : PagerAdapter() {
+
+ private val layoutInflater: LayoutInflater = LayoutInflater.from(this@AboutActivityBase)
+ private val views = Array<View?>(pageCount) { null }
+
+ override fun instantiateItem(collection: ViewGroup, position: Int): Any {
+ val layout = getPage(position, collection)
+ collection.addView(layout)
+ return layout
+ }
+
+ override fun destroyItem(collection: ViewGroup, position: Int, view: Any) {
+ collection.removeView(view as View)
+ views[position] = null
+ }
+
+ override fun getCount(): Int = pageCount
+
+ override fun isViewFromObject(view: View, `object`: Any): Boolean = view === `object`
+
+ /**
+ * Only get page if view does not exist
+ */
+ private fun getPage(position: Int, parent: ViewGroup): View {
+ if (views[position] == null) views[position] = getPage(position, layoutInflater, parent)
+ return views[position]!!
+ }
+ }
+
+ override fun onPageScrollStateChanged(state: Int) {}
+
+ override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
+
+ override fun onPageSelected(position: Int) {
+ if (pageStatus[position] == 0) pageStatus[position] = 1 // mark as seen if previously null
+ if (position == libPage && libItems != null && pageStatus[position] == 1) {
+ pageStatus[position] = 2 //add libs and mark as such
+ postDelayed(300) { addLibItems() } //delay so that the animations occur once the page is fully switched
+ }
+ }
+
+ /**
+ * Function that is called when the view is ready to add the lib items
+ * Feel free to add your own items here
+ */
+ open fun addLibItems() {
+ libAdapter.add(HeaderIItem(text = configs.libPageTitle, textRes = configs.libPageTitleRes))
+ .add(libItems)
+ }
+
+ override fun onDestroy() {
+ AnimHolder.decelerateInterpolator.invalidate() //clear the reference to the interpolators we've used
+ super.onDestroy()
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt
new file mode 100644
index 0000000..e1c5c18
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt
@@ -0,0 +1,85 @@
+package ca.allanwang.kau.adapters
+
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.adapters.HeaderAdapter
+import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter
+import org.jetbrains.anko.collections.forEachReversedWithIndex
+import java.util.*
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Once bounded to a [RecyclerView], this will
+ * - Chain together a list of [HeaderAdapter]s, backed by a generic [FastItemAdapter]
+ * - Add a [LinearLayoutManager] to the recycler
+ * - Add a listener for when a new adapter segment is being used
+ */
+class ChainedAdapters<T>(vararg items: Pair<T, SectionAdapter<*>>) {
+ private val chain: MutableList<Pair<T, SectionAdapter<*>>> = mutableListOf(*items)
+ val baseAdapter: FastItemAdapter<IItem<*, *>> = FastItemAdapter()
+ private val indexStack = Stack<Int>()
+ var recycler: RecyclerView? = null
+ val firstVisibleItemPosition: Int
+ get() = (recycler?.layoutManager as LinearLayoutManager?)?.findFirstVisibleItemPosition() ?: throw IllegalArgumentException("No recyclerview was bounded to the chain adapters")
+
+ fun add(vararg items: Pair<T, SectionAdapter<*>>) = add(items.toList())
+
+ fun add(items: Collection<Pair<T, SectionAdapter<*>>>): ChainedAdapters<T> {
+ if (recycler != null) throw IllegalAccessException("Chain adapter is already bounded to a recycler; cannot add directly.")
+ items.map { it.second }.forEachIndexed { index, sectionAdapter -> sectionAdapter.sectionOrder = chain.size + 1 + index }
+ chain.addAll(items)
+ return this
+ }
+
+ operator fun get(index: Int) = chain[index]
+
+ /**
+ * Attaches the chain to a recycler
+ * After this stage, any modifications to the adapters must be done through external references
+ * You may still get the generic header adapters through the get operator
+ * Binding the recycler also involves supplying a callback, which returns
+ * the item (T) associated with the adapter,
+ * the index (Int) of the current adapter
+ * and the dy (Int) as given by the scroll listener
+ */
+ fun bindRecyclerView(recyclerView: RecyclerView, onAdapterSectionChanged: (item: T, index: Int, dy: Int) -> Unit) {
+ if (recycler != null) throw IllegalStateException("Chain adapter is already bounded")
+ if (chain.isEmpty()) throw IllegalArgumentException("No adapters have been added to the adapters list")
+ //wrap adapters
+ chain.map { it.second }.forEachReversedWithIndex { i, headerAdapter ->
+ if (i == chain.size - 1) headerAdapter.wrap(baseAdapter)
+ else headerAdapter.wrap(chain[i + 1].second)
+ }
+ recycler = recyclerView
+ indexStack.push(0)
+ with(recyclerView) {
+ layoutManager = LinearLayoutManager(context)
+ adapter = chain.first().second
+ addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(rv, dx, dy)
+ val topPosition = firstVisibleItemPosition
+ val currentAdapterIndex = indexStack.peek()
+ if (dy > 0) {
+ //look ahead from current adapter
+ val nextAdapterIndex = (currentAdapterIndex until chain.size).asSequence()
+ .firstOrNull {
+ val adapter = chain[it].second
+ adapter.adapterItemCount > 0 && adapter.getGlobalPosition(adapter.adapterItemCount - 1) >= topPosition
+ } ?: currentAdapterIndex
+ if (nextAdapterIndex == currentAdapterIndex) return
+ indexStack.push(nextAdapterIndex)
+ onAdapterSectionChanged(chain[indexStack.peek()].first, indexStack.peek(), dy)
+ } else if (currentAdapterIndex == 0) {
+ return //All adapters may be empty; in this case, if we are already at the beginning, don't bother checking
+ } else if (chain[currentAdapterIndex].second.getGlobalPosition(0) > topPosition) {
+ indexStack.pop()
+ onAdapterSectionChanged(chain[indexStack.peek()].first, indexStack.peek(), dy)
+ }
+ }
+ })
+ }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt
new file mode 100644
index 0000000..66fec4b
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt
@@ -0,0 +1,189 @@
+package ca.allanwang.kau.adapters
+
+import android.content.res.ColorStateList
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import ca.allanwang.kau.utils.adjustAlpha
+import ca.allanwang.kau.views.createSimpleRippleDrawable
+import com.mikepenz.fastadapter.IExpandable
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.ISubItem
+import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter
+
+/**
+ * Created by Allan Wang on 2017-06-29.
+ *
+ * Adapter with a set of colors that will be added to all subsequent items
+ * Changing a color while the adapter is not empty will reload all items
+ *
+ * This adapter overrides every method where an item is added
+ * If that item extends [ThemableIItem], then the colors will be set
+ */
+class FastItemThemedAdapter<Item : IItem<*, *>>(
+ textColor: Int? = null,
+ backgroundColor: Int? = null,
+ accentColor: Int? = null
+) : FastItemAdapter<Item>() {
+ constructor(colors: ThemableIItemColors) : this(colors.textColor, colors.backgroundColor, colors.accentColor)
+
+ var textColor: Int? = textColor
+ set(value) {
+ if (field == value) return
+ field = value
+ themeChanged()
+ }
+ var backgroundColor: Int? = backgroundColor
+ set(value) {
+ if (field == value) return
+ field = value
+ themeChanged()
+ }
+ var accentColor: Int? = accentColor
+ set(value) {
+ if (field == value) return
+ field = value
+ themeChanged()
+ }
+
+ fun setColors(colors: ThemableIItemColors) {
+ this.textColor = colors.textColor
+ this.backgroundColor = colors.backgroundColor
+ this.accentColor = colors.accentColor
+ }
+
+ fun themeChanged() {
+ if (adapterItemCount == 0) return
+ injectTheme(adapterItems)
+ notifyAdapterDataSetChanged()
+ }
+
+ override fun add(position: Int, items: MutableList<Item>): FastItemAdapter<Item> {
+ injectTheme(items)
+ return super.add(position, items)
+ }
+
+ override fun add(position: Int, item: Item): FastItemAdapter<Item> {
+ injectTheme(item)
+ return super.add(position, item)
+ }
+
+ override fun add(item: Item): FastItemAdapter<Item> {
+ injectTheme(item)
+ return super.add(item)
+ }
+
+ override fun add(items: MutableList<Item>): FastItemAdapter<Item> {
+ injectTheme(items)
+ injectTheme(items)
+ return super.add(items)
+ }
+
+ override fun set(items: MutableList<Item>?): FastItemAdapter<Item> {
+ injectTheme(items)
+ return super.set(items)
+ }
+
+ override fun set(position: Int, item: Item): FastItemAdapter<Item> {
+ injectTheme(item)
+ return super.set(position, item)
+ }
+
+ override fun setNewList(items: MutableList<Item>?, retainFilter: Boolean): FastItemAdapter<Item> {
+ injectTheme(items)
+ return super.setNewList(items, retainFilter)
+ }
+
+ override fun setNewList(items: MutableList<Item>?): FastItemAdapter<Item> {
+ injectTheme(items)
+ return super.setNewList(items)
+ }
+
+ override fun <T, S> setSubItems(collapsible: T, subItems: MutableList<S>?): T where S : IItem<*, *>?, T : IItem<*, *>?, T : IExpandable<T, S>?, S : ISubItem<Item, T>? {
+ injectTheme(subItems)
+ return super.setSubItems(collapsible, subItems)
+ }
+
+ internal fun injectTheme(items: Collection<IItem<*, *>?>?) {
+ items?.forEach { injectTheme(it) }
+ }
+
+ internal fun injectTheme(item: IItem<*, *>?) {
+ if (item is ThemableIItem && item.themeEnabled) {
+ item.textColor = textColor
+ item.backgroundColor = backgroundColor
+ item.accentColor = accentColor
+ }
+ }
+}
+
+interface ThemableIItemColors {
+ var textColor: Int?
+ var backgroundColor: Int?
+ var accentColor: Int?
+}
+
+class ThemableIItemColorsDelegate : ThemableIItemColors {
+ override var textColor: Int? = null
+ override var backgroundColor: Int? = null
+ override var accentColor: Int? = null
+}
+
+/**
+ * Interface that needs to be implemented by every iitem
+ * Holds the color values and has helper methods to inject the colors
+ */
+interface ThemableIItem : ThemableIItemColors {
+ var themeEnabled: Boolean
+ fun bindTextColor(vararg views: TextView?)
+ fun bindTextColorSecondary(vararg views: TextView?)
+ fun bindDividerColor(vararg views: View?)
+ fun bindAccentColor(vararg views: TextView?)
+ fun bindBackgroundColor(vararg views: View?)
+ fun bindBackgroundRipple(vararg views: View?)
+ fun bindIconColor(vararg views: ImageView?)
+}
+
+/**
+ * The delegate for [ThemableIItem]
+ */
+class ThemableIItemDelegate : ThemableIItem, ThemableIItemColors by ThemableIItemColorsDelegate() {
+ override var themeEnabled: Boolean = true
+
+ override fun bindTextColor(vararg views: TextView?) {
+ val color = textColor ?: return
+ views.forEach { it?.setTextColor(color) }
+ }
+
+ override fun bindTextColorSecondary(vararg views: TextView?) {
+ val color = textColor?.adjustAlpha(0.8f) ?: return
+ views.forEach { it?.setTextColor(color) }
+ }
+
+ override fun bindAccentColor(vararg views: TextView?) {
+ val color = accentColor ?: textColor ?: return
+ views.forEach { it?.setTextColor(color) }
+ }
+
+ override fun bindDividerColor(vararg views: View?) {
+ val color = (textColor ?: accentColor)?.adjustAlpha(0.1f) ?: return
+ views.forEach { it?.setBackgroundColor(color) }
+ }
+
+ override fun bindBackgroundColor(vararg views: View?) {
+ val color = backgroundColor ?: return
+ views.forEach { it?.setBackgroundColor(color) }
+ }
+
+ override fun bindBackgroundRipple(vararg views: View?) {
+ val foreground = accentColor ?: textColor ?: return
+ val background = backgroundColor ?: return
+ val ripple = createSimpleRippleDrawable(foreground, background)
+ views.forEach { it?.background = ripple }
+ }
+
+ override fun bindIconColor(vararg views: ImageView?) {
+ val color = accentColor ?: textColor ?: return
+ views.forEach { it?.drawable?.setTintList(ColorStateList.valueOf(color)) }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt
new file mode 100644
index 0000000..cf7205a
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt
@@ -0,0 +1,13 @@
+package ca.allanwang.kau.adapters
+
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.adapters.HeaderAdapter
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Extension of [HeaderAdapter] where we can define the order
+ */
+class SectionAdapter<Item : IItem<*, *>>(var sectionOrder: Int = 100) : HeaderAdapter<Item>() {
+ override fun getOrder(): Int = sectionOrder
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt
new file mode 100644
index 0000000..c649376
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt
@@ -0,0 +1,45 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Base for delayed animators
+ * item delay factor by default can be 0.125f
+ */
+abstract class BaseDelayAnimator(val itemDelayFactor: Float) : DefaultAnimator() {
+
+ override abstract fun addAnimationPrepare(holder: RecyclerView.ViewHolder)
+
+ override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ startDelay = Math.max(0L, (holder.adapterPosition * addDuration * itemDelayFactor).toLong())
+ duration = this@BaseDelayAnimator.addDuration
+ interpolator = this@BaseDelayAnimator.interpolator
+ }
+ }
+
+
+ override abstract fun addAnimationCleanup(holder: RecyclerView.ViewHolder)
+
+ override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ /**
+ * Partial removal animation
+ * As of now, all it does it change the alpha
+ * To have it slide, add onto it in a sub class
+ */
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ duration = this@BaseDelayAnimator.removeDuration
+ startDelay = Math.max(0L, (holder.adapterPosition * removeDuration * itemDelayFactor).toLong())
+ interpolator = this@BaseDelayAnimator.interpolator
+ }
+ }
+
+ override abstract fun removeAnimationCleanup(holder: RecyclerView.ViewHolder)
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java
new file mode 100644
index 0000000..69c2cf3
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java
@@ -0,0 +1,764 @@
+package ca.allanwang.kau.animators;
+
+/*
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Based on Item Animator by {@author Mike Penz}
+ * Rewritten to match with the updated compat dependencies
+ */
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.TimeInterpolator;
+import android.animation.ValueAnimator;
+import android.support.annotation.NonNull;
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.support.v7.widget.SimpleItemAnimator;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+import android.view.animation.Interpolator;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This implementation of {@link android.support.v7.widget.RecyclerView.ItemAnimator} provides basic
+ * animations on remove, add, and move events that happen to the items in
+ * a RecyclerView. RecyclerView uses a DefaultItemAnimator by default.
+ *
+ * @see android.support.v7.widget.RecyclerView#setItemAnimator(android.support.v7.widget.RecyclerView.ItemAnimator)
+ */
+public abstract class BaseItemAnimator extends SimpleItemAnimator {
+ private static final boolean DEBUG = false;
+
+ private static TimeInterpolator sDefaultInterpolator;
+
+ private ArrayList<ViewHolder> mPendingRemovals = new ArrayList<>();
+ private ArrayList<ViewHolder> mPendingAdditions = new ArrayList<>();
+ private ArrayList<MoveInfo> mPendingMoves = new ArrayList<>();
+ private ArrayList<ChangeInfo> mPendingChanges = new ArrayList<>();
+
+ ArrayList<ArrayList<ViewHolder>> mAdditionsList = new ArrayList<>();
+ ArrayList<ArrayList<MoveInfo>> mMovesList = new ArrayList<>();
+ ArrayList<ArrayList<ChangeInfo>> mChangesList = new ArrayList<>();
+
+ ArrayList<ViewHolder> mAddAnimations = new ArrayList<>();
+ ArrayList<ViewHolder> mMoveAnimations = new ArrayList<>();
+ ArrayList<ViewHolder> mRemoveAnimations = new ArrayList<>();
+ ArrayList<ViewHolder> mChangeAnimations = new ArrayList<>();
+
+ public Interpolator interpolator;
+
+ private static class MoveInfo {
+ public ViewHolder holder;
+ public int fromX, fromY, toX, toY;
+
+ MoveInfo(ViewHolder holder, int fromX, int fromY, int toX, int toY) {
+ this.holder = holder;
+ this.fromX = fromX;
+ this.fromY = fromY;
+ this.toX = toX;
+ this.toY = toY;
+ }
+ }
+
+ public static class ChangeInfo {
+ public ViewHolder oldHolder, newHolder;
+ public int fromX, fromY, toX, toY;
+
+ private ChangeInfo(ViewHolder oldHolder, ViewHolder newHolder) {
+ this.oldHolder = oldHolder;
+ this.newHolder = newHolder;
+ }
+
+ ChangeInfo(ViewHolder oldHolder, ViewHolder newHolder,
+ int fromX, int fromY, int toX, int toY) {
+ this(oldHolder, newHolder);
+ this.fromX = fromX;
+ this.fromY = fromY;
+ this.toX = toX;
+ this.toY = toY;
+ }
+
+ @Override
+ public String toString() {
+ return "ChangeInfo{"
+ + "oldHolder=" + oldHolder
+ + ", newHolder=" + newHolder
+ + ", fromX=" + fromX
+ + ", fromY=" + fromY
+ + ", toX=" + toX
+ + ", toY=" + toY
+ + '}';
+ }
+ }
+
+ @Override
+ public void runPendingAnimations() {
+ boolean removalsPending = !mPendingRemovals.isEmpty();
+ boolean movesPending = !mPendingMoves.isEmpty();
+ boolean changesPending = !mPendingChanges.isEmpty();
+ boolean additionsPending = !mPendingAdditions.isEmpty();
+ if (!removalsPending && !movesPending && !additionsPending && !changesPending) {
+ // nothing to animate
+ return;
+ }
+ // First, remove stuff
+ for (ViewHolder holder : mPendingRemovals) {
+ animateRemoveImpl(holder);
+ }
+ mPendingRemovals.clear();
+ // Next, move stuff
+ if (movesPending) {
+ final ArrayList<MoveInfo> moves = new ArrayList<>();
+ moves.addAll(mPendingMoves);
+ mMovesList.add(moves);
+ mPendingMoves.clear();
+ Runnable mover = new Runnable() {
+ @Override
+ public void run() {
+ for (MoveInfo moveInfo : moves) {
+ animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY,
+ moveInfo.toX, moveInfo.toY);
+ }
+ moves.clear();
+ mMovesList.remove(moves);
+ }
+ };
+ if (removalsPending) {
+ View view = moves.get(0).holder.itemView;
+ ViewCompat.postOnAnimationDelayed(view, mover, 0);
+ } else {
+ mover.run();
+ }
+ }
+ // Next, change stuff, to run in parallel with move animations
+ if (changesPending) {
+ final ArrayList<ChangeInfo> changes = new ArrayList<>();
+ changes.addAll(mPendingChanges);
+ mChangesList.add(changes);
+ mPendingChanges.clear();
+ Runnable changer = new Runnable() {
+ @Override
+ public void run() {
+ for (ChangeInfo change : changes) {
+ animateChangeImpl(change);
+ }
+ changes.clear();
+ mChangesList.remove(changes);
+ }
+ };
+ if (removalsPending) {
+ ViewHolder holder = changes.get(0).oldHolder;
+ long moveDuration = movesPending ? getMoveDuration() : 0;
+ ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDelay(getRemoveDuration(), moveDuration, getChangeDuration()));
+ } else {
+ changer.run();
+ }
+ }
+ // Next, add stuff
+ if (additionsPending) {
+ final ArrayList<ViewHolder> additions = new ArrayList<>();
+ additions.addAll(mPendingAdditions);
+ mAdditionsList.add(additions);
+ mPendingAdditions.clear();
+ Runnable adder = new Runnable() {
+ @Override
+ public void run() {
+ for (ViewHolder holder : additions) {
+ animateAddImpl(holder);
+ }
+ additions.clear();
+ mAdditionsList.remove(additions);
+ }
+ };
+ if (removalsPending || movesPending || changesPending) {
+ long removeDuration = removalsPending ? getRemoveDuration() : 0;
+ long moveDuration = movesPending ? getMoveDuration() : 0;
+ long changeDuration = changesPending ? getChangeDuration() : 0;
+ View view = additions.get(0).itemView;
+ ViewCompat.postOnAnimationDelayed(view, adder, getAddDelay(removeDuration, moveDuration, changeDuration));
+ } else {
+ adder.run();
+ }
+ }
+ }
+
+ /**
+ * used to calculated the delay until the remove animation should start
+ *
+ * @param remove the remove duration
+ * @param move the move duration
+ * @param change the change duration
+ * @return the calculated delay for the remove items animation
+ */
+ public long getRemoveDelay(long remove, long move, long change) {
+ return remove + Math.max(move, change);
+ }
+
+ /**
+ * used to calculated the delay until the add animation should start
+ *
+ * @param remove the remove duration
+ * @param move the move duration
+ * @param change the change duration
+ * @return the calculated delay for the add items animation
+ */
+ public long getAddDelay(long remove, long move, long change) {
+ return remove + Math.max(move, change);
+ }
+
+ @Override
+ public boolean animateRemove(final ViewHolder holder) {
+ resetAnimation(holder);
+ mPendingRemovals.add(holder);
+ return true;
+ }
+
+ private void animateRemoveImpl(final ViewHolder holder) {
+ final ViewPropertyAnimator animation = removeAnimation(holder);
+ mRemoveAnimations.add(holder);
+ animation.setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ dispatchRemoveStarting(holder);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ animation.setListener(null);
+ removeAnimationCleanup(holder);
+ dispatchRemoveFinished(holder);
+ mRemoveAnimations.remove(holder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+
+ abstract public ViewPropertyAnimator removeAnimation(ViewHolder holder);
+
+ abstract public void removeAnimationCleanup(ViewHolder holder);
+
+ @Override
+ public boolean animateAdd(final ViewHolder holder) {
+ resetAnimation(holder);
+ addAnimationPrepare(holder);
+ mPendingAdditions.add(holder);
+ return true;
+ }
+
+ void animateAddImpl(final ViewHolder holder) {
+ final ViewPropertyAnimator animation = addAnimation(holder);
+ mAddAnimations.add(holder);
+ animation.setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ dispatchAddStarting(holder);
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animator) {
+ addAnimationCleanup(holder);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ animation.setListener(null);
+ dispatchAddFinished(holder);
+ mAddAnimations.remove(holder);
+ dispatchFinishedWhenDone();
+ addAnimationCleanup(holder);
+ }
+ }).start();
+ }
+
+ /**
+ * the animation to prepare the view before the add animation is run
+ *
+ * @param holder
+ */
+ public abstract void addAnimationPrepare(ViewHolder holder);
+
+ /**
+ * the animation for adding a view
+ *
+ * @param holder
+ * @return
+ */
+ public abstract ViewPropertyAnimator addAnimation(ViewHolder holder);
+
+ /**
+ * the cleanup method if the animation needs to be stopped. and tro prepare for the next view
+ *
+ * @param holder
+ */
+ abstract void addAnimationCleanup(ViewHolder holder);
+
+ @Override
+ public boolean animateMove(final ViewHolder holder, int fromX, int fromY,
+ int toX, int toY) {
+ final View view = holder.itemView;
+ fromX += (int) holder.itemView.getTranslationX();
+ fromY += (int) holder.itemView.getTranslationY();
+ resetAnimation(holder);
+ int deltaX = toX - fromX;
+ int deltaY = toY - fromY;
+ if (deltaX == 0 && deltaY == 0) {
+ dispatchMoveFinished(holder);
+ return false;
+ }
+ if (deltaX != 0) {
+ view.setTranslationX(-deltaX);
+ }
+ if (deltaY != 0) {
+ view.setTranslationY(-deltaY);
+ }
+ mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY));
+ return true;
+ }
+
+ void animateMoveImpl(final ViewHolder holder, int fromX, int fromY, int toX, int toY) {
+ final View view = holder.itemView;
+ final int deltaX = toX - fromX;
+ final int deltaY = toY - fromY;
+ if (deltaX != 0) {
+ view.animate().translationX(0);
+ }
+ if (deltaY != 0) {
+ view.animate().translationY(0);
+ }
+ // TODO: make EndActions end listeners instead, since end actions aren't called when
+ // vpas are canceled (and can't end them. why?)
+ // need listener functionality in VPACompat for this. Ick.
+ final ViewPropertyAnimator animation = view.animate();
+ mMoveAnimations.add(holder);
+ animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ dispatchMoveStarting(holder);
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animator) {
+ if (deltaX != 0) {
+ view.setTranslationX(0);
+ }
+ if (deltaY != 0) {
+ view.setTranslationY(0);
+ }
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ animation.setListener(null);
+ dispatchMoveFinished(holder);
+ mMoveAnimations.remove(holder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+
+ @Override
+ public boolean animateChange(ViewHolder oldHolder, ViewHolder newHolder,
+ int fromX, int fromY, int toX, int toY) {
+ if (oldHolder == newHolder) {
+ // Don't know how to run change animations when the same view holder is re-used.
+ // run a move animation to handle position changes.
+ return animateMove(oldHolder, fromX, fromY, toX, toY);
+ }
+ changeAnimation(oldHolder, newHolder,
+ fromX, fromY, toX, toY);
+ mPendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY));
+ return true;
+ }
+
+ void animateChangeImpl(final ChangeInfo changeInfo) {
+ final ViewHolder holder = changeInfo.oldHolder;
+ final View view = holder == null ? null : holder.itemView;
+ final ViewHolder newHolder = changeInfo.newHolder;
+ final View newView = newHolder != null ? newHolder.itemView : null;
+ if (view != null) {
+ final ViewPropertyAnimator oldViewAnim = changeOldAnimation(holder, changeInfo);
+ mChangeAnimations.add(changeInfo.oldHolder);
+ oldViewAnim.setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ dispatchChangeStarting(changeInfo.oldHolder, true);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ oldViewAnim.setListener(null);
+ changeAnimationCleanup(holder);
+ view.setTranslationX(0);
+ view.setTranslationY(0);
+ dispatchChangeFinished(changeInfo.oldHolder, true);
+ mChangeAnimations.remove(changeInfo.oldHolder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+ if (newView != null) {
+ final ViewPropertyAnimator newViewAnimation = changeNewAnimation(newHolder);
+ mChangeAnimations.add(changeInfo.newHolder);
+ newViewAnimation.setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ dispatchChangeStarting(changeInfo.newHolder, false);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ newViewAnimation.setListener(null);
+ changeAnimationCleanup(newHolder);
+ newView.setTranslationX(0);
+ newView.setTranslationY(0);
+ dispatchChangeFinished(changeInfo.newHolder, false);
+ mChangeAnimations.remove(changeInfo.newHolder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+ }
+
+ /**
+ * the whole change animation if we have to cross animate two views
+ *
+ * @param oldHolder
+ * @param newHolder
+ * @param fromX
+ * @param fromY
+ * @param toX
+ * @param toY
+ */
+ public void changeAnimation(ViewHolder oldHolder, ViewHolder newHolder, int fromX, int fromY, int toX, int toY) {
+ final float prevTranslationX = oldHolder.itemView.getTranslationX();
+ final float prevTranslationY = oldHolder.itemView.getTranslationY();
+ final float prevValue = oldHolder.itemView.getAlpha();
+ resetAnimation(oldHolder);
+ int deltaX = (int) (toX - fromX - prevTranslationX);
+ int deltaY = (int) (toY - fromY - prevTranslationY);
+ // recover prev translation state after ending animation
+ oldHolder.itemView.setTranslationX(prevTranslationX);
+ oldHolder.itemView.setTranslationY(prevTranslationY);
+
+ oldHolder.itemView.setAlpha(prevValue);
+ if (newHolder != null) {
+ // carry over translation values
+ resetAnimation(newHolder);
+ newHolder.itemView.setTranslationX(-deltaX);
+ newHolder.itemView.setTranslationY(-deltaY);
+ newHolder.itemView.setAlpha(0);
+ }
+ }
+
+ /**
+ * the animation for removing the old view
+ *
+ * @param holder
+ * @return
+ */
+ public abstract ViewPropertyAnimator changeOldAnimation(ViewHolder holder, ChangeInfo changeInfo);
+
+ /**
+ * the animation for changing the new view
+ *
+ * @param holder
+ * @return
+ */
+ public abstract ViewPropertyAnimator changeNewAnimation(ViewHolder holder);
+
+ /**
+ * the cleanup method if the animation needs to be stopped. and tro prepare for the next view
+ *
+ * @param holder
+ */
+ public abstract void changeAnimationCleanup(ViewHolder holder);
+
+ private void endChangeAnimation(List<ChangeInfo> infoList, ViewHolder item) {
+ for (int i = infoList.size() - 1; i >= 0; i--) {
+ ChangeInfo changeInfo = infoList.get(i);
+ if (endChangeAnimationIfNecessary(changeInfo, item)) {
+ if (changeInfo.oldHolder == null && changeInfo.newHolder == null) {
+ infoList.remove(changeInfo);
+ }
+ }
+ }
+ }
+
+ private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) {
+ if (changeInfo.oldHolder != null) {
+ endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder);
+ }
+ if (changeInfo.newHolder != null) {
+ endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder);
+ }
+ }
+
+ private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, ViewHolder item) {
+ boolean oldItem = false;
+ if (changeInfo.newHolder == item) {
+ changeInfo.newHolder = null;
+ } else if (changeInfo.oldHolder == item) {
+ changeInfo.oldHolder = null;
+ oldItem = true;
+ } else {
+ return false;
+ }
+ changeAnimationCleanup(item);
+ item.itemView.setTranslationX(0);
+ item.itemView.setTranslationY(0);
+ dispatchChangeFinished(item, oldItem);
+ return true;
+ }
+
+ @Override
+ public void endAnimation(ViewHolder item) {
+ final View view = item.itemView;
+ // this will trigger end callback which should set properties to their target values.
+ view.animate().cancel();
+ // TODO if some other animations are chained to end, how do we cancel them as well?
+ for (int i = mPendingMoves.size() - 1; i >= 0; i--) {
+ MoveInfo moveInfo = mPendingMoves.get(i);
+ if (moveInfo.holder == item) {
+ view.setTranslationY(0);
+ view.setTranslationX(0);
+ dispatchMoveFinished(item);
+ mPendingMoves.remove(i);
+ }
+ }
+ endChangeAnimation(mPendingChanges, item);
+ if (mPendingRemovals.remove(item)) {
+ removeAnimationCleanup(item);
+ dispatchRemoveFinished(item);
+ }
+ if (mPendingAdditions.remove(item)) {
+ addAnimationCleanup(item);
+ dispatchAddFinished(item);
+ }
+
+ for (int i = mChangesList.size() - 1; i >= 0; i--) {
+ ArrayList<ChangeInfo> changes = mChangesList.get(i);
+ endChangeAnimation(changes, item);
+ if (changes.isEmpty()) {
+ mChangesList.remove(i);
+ }
+ }
+ for (int i = mMovesList.size() - 1; i >= 0; i--) {
+ ArrayList<MoveInfo> moves = mMovesList.get(i);
+ for (int j = moves.size() - 1; j >= 0; j--) {
+ MoveInfo moveInfo = moves.get(j);
+ if (moveInfo.holder == item) {
+ view.setTranslationY(0);
+ view.setTranslationX(0);
+ dispatchMoveFinished(item);
+ moves.remove(j);
+ if (moves.isEmpty()) {
+ mMovesList.remove(i);
+ }
+ break;
+ }
+ }
+ }
+ for (int i = mAdditionsList.size() - 1; i >= 0; i--) {
+ ArrayList<ViewHolder> additions = mAdditionsList.get(i);
+ if (additions.remove(item)) {
+ addAnimationCleanup(item);
+ dispatchAddFinished(item);
+ if (additions.isEmpty()) {
+ mAdditionsList.remove(i);
+ }
+ }
+ }
+
+ // animations should be ended by the cancel above.
+ //noinspection PointlessBooleanExpression,ConstantConditions
+ if (mRemoveAnimations.remove(item) && DEBUG) {
+ throw new IllegalStateException("after animation is cancelled, item should not be in "
+ + "mRemoveAnimations list");
+ }
+
+ //noinspection PointlessBooleanExpression,ConstantConditions
+ if (mAddAnimations.remove(item) && DEBUG) {
+ throw new IllegalStateException("after animation is cancelled, item should not be in "
+ + "mAddAnimations list");
+ }
+
+ //noinspection PointlessBooleanExpression,ConstantConditions
+ if (mChangeAnimations.remove(item) && DEBUG) {
+ throw new IllegalStateException("after animation is cancelled, item should not be in "
+ + "mChangeAnimations list");
+ }
+
+ //noinspection PointlessBooleanExpression,ConstantConditions
+ if (mMoveAnimations.remove(item) && DEBUG) {
+ throw new IllegalStateException("after animation is cancelled, item should not be in "
+ + "mMoveAnimations list");
+ }
+ dispatchFinishedWhenDone();
+ }
+
+ private void resetAnimation(ViewHolder holder) {
+
+ if (sDefaultInterpolator == null) {
+ sDefaultInterpolator = new ValueAnimator().getInterpolator();
+ }
+ holder.itemView.animate().setInterpolator(sDefaultInterpolator);
+ endAnimation(holder);
+ }
+
+ @Override
+ public boolean isRunning() {
+ return (!mPendingAdditions.isEmpty()
+ || !mPendingChanges.isEmpty()
+ || !mPendingMoves.isEmpty()
+ || !mPendingRemovals.isEmpty()
+ || !mMoveAnimations.isEmpty()
+ || !mRemoveAnimations.isEmpty()
+ || !mAddAnimations.isEmpty()
+ || !mChangeAnimations.isEmpty()
+ || !mMovesList.isEmpty()
+ || !mAdditionsList.isEmpty()
+ || !mChangesList.isEmpty());
+ }
+
+ /**
+ * Check the state of currently pending and running animations. If there are none
+ * pending/running, call {@link #dispatchAnimationsFinished()} to notify any
+ * listeners.
+ */
+ void dispatchFinishedWhenDone() {
+ if (!isRunning()) {
+ dispatchAnimationsFinished();
+ }
+ }
+
+ @Override
+ public void endAnimations() {
+ int count = mPendingMoves.size();
+ for (int i = count - 1; i >= 0; i--) {
+ MoveInfo item = mPendingMoves.get(i);
+ View view = item.holder.itemView;
+ view.setTranslationY(0);
+ view.setTranslationX(0);
+ dispatchMoveFinished(item.holder);
+ mPendingMoves.remove(i);
+ }
+ count = mPendingRemovals.size();
+ for (int i = count - 1; i >= 0; i--) {
+ ViewHolder item = mPendingRemovals.get(i);
+ dispatchRemoveFinished(item);
+ mPendingRemovals.remove(i);
+ }
+ count = mPendingAdditions.size();
+ for (int i = count - 1; i >= 0; i--) {
+ ViewHolder item = mPendingAdditions.get(i);
+ addAnimationCleanup(item);
+ dispatchAddFinished(item);
+ mPendingAdditions.remove(i);
+ }
+ count = mPendingChanges.size();
+ for (int i = count - 1; i >= 0; i--) {
+ endChangeAnimationIfNecessary(mPendingChanges.get(i));
+ }
+ mPendingChanges.clear();
+ if (!isRunning()) {
+ return;
+ }
+
+ int listCount = mMovesList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ ArrayList<MoveInfo> moves = mMovesList.get(i);
+ count = moves.size();
+ for (int j = count - 1; j >= 0; j--) {
+ MoveInfo moveInfo = moves.get(j);
+ ViewHolder item = moveInfo.holder;
+ View view = item.itemView;
+ view.setTranslationY(0);
+ view.setTranslationX(0);
+ dispatchMoveFinished(moveInfo.holder);
+ moves.remove(j);
+ if (moves.isEmpty()) {
+ mMovesList.remove(moves);
+ }
+ }
+ }
+ listCount = mAdditionsList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ ArrayList<ViewHolder> additions = mAdditionsList.get(i);
+ count = additions.size();
+ for (int j = count - 1; j >= 0; j--) {
+ ViewHolder item = additions.get(j);
+ View view = item.itemView;
+ addAnimationCleanup(item);
+ dispatchAddFinished(item);
+ additions.remove(j);
+ if (additions.isEmpty()) {
+ mAdditionsList.remove(additions);
+ }
+ }
+ }
+ listCount = mChangesList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ ArrayList<ChangeInfo> changes = mChangesList.get(i);
+ count = changes.size();
+ for (int j = count - 1; j >= 0; j--) {
+ endChangeAnimationIfNecessary(changes.get(j));
+ if (changes.isEmpty()) {
+ mChangesList.remove(changes);
+ }
+ }
+ }
+
+ cancelAll(mRemoveAnimations);
+ cancelAll(mMoveAnimations);
+ cancelAll(mAddAnimations);
+ cancelAll(mChangeAnimations);
+
+ dispatchAnimationsFinished();
+ }
+
+ void cancelAll(List<ViewHolder> viewHolders) {
+ for (int i = viewHolders.size() - 1; i >= 0; i--) {
+ viewHolders.get(i).itemView.animate().cancel();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * If the payload list is not empty, DefaultItemAnimator returns <code>true</code>.
+ * When this is the case:
+ * <ul>
+ * <li>If you override {@link #animateChange(ViewHolder, ViewHolder, int, int, int, int)}, both
+ * ViewHolder arguments will be the same instance.
+ * </li>
+ * <li>
+ * If you are not overriding {@link #animateChange(ViewHolder, ViewHolder, int, int, int, int)},
+ * then DefaultItemAnimator will call {@link #animateMove(ViewHolder, int, int, int, int)} and
+ * run a move animation instead.
+ * </li>
+ * </ul>
+ */
+ @Override
+ public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
+ @NonNull List<Object> payloads) {
+ return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads);
+ }
+}
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt
new file mode 100644
index 0000000..a963358
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt
@@ -0,0 +1,52 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Base for sliding animators
+ * item delay factor by default can be 0.125f
+ */
+abstract class BaseSlideAlphaAnimator(itemDelayFactor: Float) : BaseDelayAnimator(itemDelayFactor) {
+
+ override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return super.addAnimation(holder).apply {
+ translationY(0f)
+ translationX(0f)
+ alpha(1f)
+ }
+ }
+
+ final override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ translationY = 0f
+ translationX = 0f
+ alpha = 1f
+ }
+ }
+
+ override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ /**
+ * Partial removal animation
+ * As of now, all it does it change the alpha
+ * To have it slide, add onto it in a sub class
+ */
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return super.addAnimation(holder).apply {
+ alpha(0f)
+ }
+ }
+
+ override final fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ translationY = 0f
+ translationX = 0f
+ alpha = 1f
+ }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt
new file mode 100644
index 0000000..9aeafde
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt
@@ -0,0 +1,63 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ */
+open class DefaultAnimator : BaseItemAnimator() {
+
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ alpha(0f)
+ duration = this@DefaultAnimator.removeDuration
+ interpolator = this@DefaultAnimator.interpolator
+ }
+ }
+
+ override fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ holder.itemView.alpha = 1f
+ }
+
+ override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) {
+ holder.itemView.alpha = 0f
+ }
+
+ override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ alpha(1f)
+ duration = this@DefaultAnimator.addDuration
+ interpolator = this@DefaultAnimator.interpolator
+ }
+ }
+
+ override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ holder.itemView.alpha = 1f
+ }
+
+ override fun changeOldAnimation(holder: RecyclerView.ViewHolder, changeInfo: ChangeInfo): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ alpha(0f)
+ translationX(changeInfo.toX.toFloat() - changeInfo.fromX)
+ translationY(changeInfo.toY.toFloat() - changeInfo.fromY)
+ duration = this@DefaultAnimator.changeDuration
+ interpolator = this@DefaultAnimator.interpolator
+ }
+ }
+
+ override fun changeNewAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return holder.itemView.animate().apply {
+ alpha(1f)
+ translationX(0f)
+ translationY(0f)
+ duration = this@DefaultAnimator.changeDuration
+ interpolator = this@DefaultAnimator.interpolator
+ }
+ }
+
+ override fun changeAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ holder.itemView.alpha = 1f
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt
new file mode 100644
index 0000000..e968cda
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt
@@ -0,0 +1,51 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-29.
+ */
+open class FadeScaleAnimator(val scaleFactor: Float = 0.7f, itemDelayFactor: Float = 0.125f) : BaseDelayAnimator(itemDelayFactor) {
+
+ override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ scaleX = scaleFactor
+ scaleY = scaleFactor
+ alpha = 0f
+ }
+ }
+
+ override final fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return super.addAnimation(holder).apply {
+ scaleX(1f)
+ scaleY(1f)
+ alpha(1f)
+ }
+ }
+
+
+ final override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ scaleX = 1f
+ scaleY = 1f
+ alpha = 1f
+ }
+ }
+
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return super.removeAnimation(holder).apply {
+ scaleX(scaleFactor)
+ scaleY(scaleFactor)
+ alpha(0f)
+ }
+ }
+
+ override final fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ translationY = 0f
+ translationX = 0f
+ alpha = 1f
+ }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt
new file mode 100644
index 0000000..244287b
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt
@@ -0,0 +1,41 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ *
+ * Truly have no animation
+ */
+class NoAnimator : DefaultAnimator() {
+ override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) {}
+
+ override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 }
+
+ override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) {}
+
+ override fun changeOldAnimation(holder: RecyclerView.ViewHolder, changeInfo: ChangeInfo): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 }
+
+ override fun changeNewAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 }
+
+ override fun changeAnimationCleanup(holder: RecyclerView.ViewHolder) {}
+
+ override fun changeAnimation(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder?, fromX: Int, fromY: Int, toX: Int, toY: Int) {}
+
+ override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ override fun getAddDuration(): Long = 0
+
+ override fun getMoveDuration(): Long = 0
+
+ override fun getRemoveDuration(): Long = 0
+
+ override fun getChangeDuration(): Long = 0
+
+ override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0
+
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 }
+
+ override fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) {}
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt
new file mode 100644
index 0000000..8670493
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt
@@ -0,0 +1,23 @@
+package ca.allanwang.kau.animators
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewPropertyAnimator
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ */
+class SlideUpExitRightAnimator(itemDelayFactor: Float = 0.125f) : BaseSlideAlphaAnimator(itemDelayFactor) {
+
+ override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) {
+ with(holder.itemView) {
+ translationY = height.toFloat()
+ alpha = 0f
+ }
+ }
+
+ override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
+ return super.removeAnimation(holder).apply {
+ translationX(holder.itemView.width.toFloat())
+ }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt b/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt
new file mode 100644
index 0000000..302d9dc
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt
@@ -0,0 +1,105 @@
+package ca.allanwang.kau.changelog
+
+import android.content.Context
+import android.content.res.XmlResourceParser
+import android.os.Handler
+import android.support.annotation.ColorInt
+import android.support.annotation.LayoutRes
+import android.support.annotation.XmlRes
+import android.support.v7.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.bindOptionalView
+import ca.allanwang.kau.utils.bindView
+import ca.allanwang.kau.utils.materialDialog
+import ca.allanwang.kau.utils.use
+import com.afollestad.materialdialogs.MaterialDialog
+import org.jetbrains.anko.doAsync
+import org.jetbrains.anko.uiThread
+import org.xmlpull.v1.XmlPullParser
+import java.util.*
+
+
+/**
+ * Created by Allan Wang on 2017-05-28.
+ */
+
+fun Context.showChangelog(@XmlRes xmlRes: Int, @ColorInt textColor: Int? = null, customize: MaterialDialog.Builder.() -> Unit = {}) {
+ doAsync {
+ val items = parse(this@showChangelog, xmlRes)
+ uiThread {
+ materialDialog {
+ title(R.string.kau_changelog)
+ positiveText(R.string.kau_great)
+ adapter(ChangelogAdapter(items, textColor), null)
+ customize()
+ }
+ }
+ }
+}
+
+/**
+ * Internals of the changelog dialog
+ * Contains an mainAdapter for each item, as well as the tags to parse
+ */
+internal class ChangelogAdapter(val items: List<Pair<String, ChangelogType>>, @ColorInt val textColor: Int? = null) : RecyclerView.Adapter<ChangelogAdapter.ChangelogVH>() {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ChangelogVH(LayoutInflater.from(parent.context)
+ .inflate(items[viewType].second.layout, parent, false))
+
+ override fun onBindViewHolder(holder: ChangelogVH, position: Int) {
+ holder.text.text = items[position].first
+ if (textColor != null) {
+ holder.text.setTextColor(textColor)
+ holder.bullet?.setTextColor(textColor)
+ }
+ }
+
+ override fun getItemId(position: Int) = position.toLong()
+
+ override fun getItemViewType(position: Int) = position
+
+ override fun getItemCount() = items.size
+
+ internal class ChangelogVH(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val text: TextView by bindView(R.id.kau_changelog_text)
+ val bullet: TextView? by bindOptionalView(R.id.kau_changelog_bullet)
+ }
+}
+
+internal fun parse(context: Context, @XmlRes xmlRes: Int): List<Pair<String, ChangelogType>> {
+ val items = mutableListOf<Pair<String, ChangelogType>>()
+ context.resources.getXml(xmlRes).use {
+ parser: XmlResourceParser ->
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG)
+ ChangelogType.values.any { it.add(parser, items) }
+ eventType = parser.next()
+ }
+ }
+ return items
+}
+
+internal enum class ChangelogType(val tag: String, val attr: String, @LayoutRes val layout: Int) {
+ TITLE("version", "title", R.layout.kau_changelog_title),
+ ITEM("item", "text", R.layout.kau_changelog_content);
+
+ companion object {
+ @JvmStatic val values = values()
+ }
+
+ /**
+ * Returns true if tag matches; false otherwise
+ */
+ fun add(parser: XmlResourceParser, list: MutableList<Pair<String, ChangelogType>>): Boolean {
+ if (parser.name != tag) return false
+ if (parser.getAttributeValue(null, attr).isNotBlank())
+ list.add(Pair(parser.getAttributeValue(null, attr), this))
+ return true
+ }
+}
+
diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt
new file mode 100644
index 0000000..3430b42
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt
@@ -0,0 +1,228 @@
+package ca.allanwang.kau.dialogs.color
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ValueAnimator
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.RippleDrawable
+import android.graphics.drawable.ShapeDrawable
+import android.graphics.drawable.StateListDrawable
+import android.graphics.drawable.shapes.OvalShape
+import android.os.Build
+import android.support.annotation.ColorInt
+import android.support.annotation.ColorRes
+import android.support.annotation.FloatRange
+import android.support.v4.view.GravityCompat
+import android.support.v4.view.ViewCompat
+import android.util.AttributeSet
+import android.view.Gravity
+import android.widget.FrameLayout
+import android.widget.Toast
+import ca.allanwang.kau.utils.color
+import ca.allanwang.kau.utils.getDip
+import ca.allanwang.kau.utils.toColor
+import ca.allanwang.kau.utils.toHSV
+
+/**
+ * Created by Allan Wang on 2017-06-10.
+ *
+ * An extension of MaterialDialog's CircleView with animation selection
+ * [https://github.com/afollestad/material-dialogs/blob/master/commons/src/main/java/com/afollestad/materialdialogs/color/CircleView.java]
+ */
+class CircleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) {
+
+ private val borderWidthMicro: Float = context.getDip(1f)
+ private val borderWidthSmall: Float = context.getDip(3f)
+ private val borderWidthLarge: Float = context.getDip(5f)
+ private var whiteOuterBound: Float = borderWidthLarge
+
+ private val outerPaint: Paint = Paint().apply { isAntiAlias = true }
+ private val whitePaint: Paint = Paint().apply { isAntiAlias = true; color = Color.WHITE }
+ private val innerPaint: Paint = Paint().apply { isAntiAlias = true }
+ private var selected: Boolean = false
+ var withBorder: Boolean = false
+ get() = field
+ set(value) {
+ if (field != value) {
+ field = value
+ invalidate()
+ }
+ }
+
+ init {
+ update(Color.DKGRAY)
+ setWillNotDraw(false)
+ }
+
+ private fun update(@ColorInt color: Int) {
+ innerPaint.color = color
+ outerPaint.color = shiftColorDown(color)
+
+ val selector = createSelector(color)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ val states = arrayOf(intArrayOf(android.R.attr.state_pressed))
+ val colors = intArrayOf(shiftColorUp(color))
+ val rippleColors = ColorStateList(states, colors)
+ foreground = RippleDrawable(rippleColors, selector, null)
+ } else {
+ foreground = selector
+ }
+ }
+
+ override fun setBackgroundColor(@ColorInt color: Int) {
+ update(color)
+ requestLayout()
+ invalidate()
+ }
+
+ override fun setBackgroundResource(@ColorRes color: Int) {
+ setBackgroundColor(context.color(color))
+ }
+
+
+ @Deprecated("")
+ override fun setBackground(background: Drawable) {
+ throw IllegalStateException("Cannot use setBackground() on CircleView.")
+ }
+
+
+ @Deprecated("")
+ override fun setBackgroundDrawable(background: Drawable) {
+ throw IllegalStateException("Cannot use setBackgroundDrawable() on CircleView.")
+ }
+
+
+ @Deprecated("")
+ override fun setActivated(activated: Boolean) {
+ throw IllegalStateException("Cannot use setActivated() on CircleView.")
+ }
+
+ override fun setSelected(selected: Boolean) {
+ this.selected = selected
+ whiteOuterBound = borderWidthLarge
+ invalidate()
+ }
+
+ fun animateSelected(selected: Boolean) {
+ if (this.selected == selected) return
+ this.selected = true // We need to draw the other bands
+ val range = if (selected) Pair(-borderWidthSmall, borderWidthLarge) else Pair(borderWidthLarge, -borderWidthSmall)
+ val animator = ValueAnimator.ofFloat(range.first, range.second)
+ with(animator) {
+ reverse()
+ duration = 150L
+ addUpdateListener { animation ->
+ whiteOuterBound = animation.animatedValue as Float
+ invalidate()
+ }
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ this@CircleView.selected = selected
+ }
+
+ override fun onAnimationCancel(animation: Animator) {
+ this@CircleView.selected = selected
+ }
+ })
+ start()
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec)
+ setMeasuredDimension(measuredWidth, measuredWidth)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val centerWidth = (measuredWidth / 2).toFloat()
+ val centerHeight = (measuredHeight / 2).toFloat()
+ if (withBorder) canvas.drawCircle(centerWidth, centerHeight, centerWidth, whitePaint)
+ if (selected) {
+ val whiteRadius = centerWidth - whiteOuterBound
+ val innerRadius = whiteRadius - borderWidthSmall
+ if (whiteRadius >= centerWidth) {
+ canvas.drawCircle(centerWidth, centerHeight, centerWidth, whitePaint)
+ } else {
+ canvas.drawCircle(centerWidth, centerHeight, if (withBorder) centerWidth - borderWidthMicro else centerWidth, outerPaint)
+ canvas.drawCircle(centerWidth, centerHeight, whiteRadius, whitePaint)
+ }
+ canvas.drawCircle(centerWidth, centerHeight, innerRadius, innerPaint)
+ } else {
+ canvas.drawCircle(centerWidth, centerHeight, if (withBorder) centerWidth - borderWidthMicro else centerWidth, innerPaint)
+ }
+ }
+
+ private fun createSelector(color: Int): Drawable {
+ val darkerCircle = ShapeDrawable(OvalShape())
+ darkerCircle.paint.color = translucentColor(shiftColorUp(color))
+ val stateListDrawable = StateListDrawable()
+ stateListDrawable.addState(intArrayOf(android.R.attr.state_pressed), darkerCircle)
+ return stateListDrawable
+ }
+
+ fun showHint(color: Int) {
+ val screenPos = IntArray(2)
+ val displayFrame = Rect()
+ getLocationOnScreen(screenPos)
+ getWindowVisibleDisplayFrame(displayFrame)
+ val context = context
+ val width = width
+ val height = height
+ val midy = screenPos[1] + height / 2
+ var referenceX = screenPos[0] + width / 2
+ if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ val screenWidth = context.resources.displayMetrics.widthPixels
+ referenceX = screenWidth - referenceX // mirror
+ }
+ val cheatSheet = Toast
+ .makeText(context, String.format("#%06X", 0xFFFFFF and color), Toast.LENGTH_SHORT)
+ if (midy < displayFrame.height()) {
+ // Show along the top; follow action buttons
+ cheatSheet.setGravity(Gravity.TOP or GravityCompat.END, referenceX,
+ screenPos[1] + height - displayFrame.top)
+ } else {
+ // Show along the bottom center
+ cheatSheet.setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, 0, height)
+ }
+ cheatSheet.show()
+ }
+
+ companion object {
+
+ @ColorInt
+ @JvmStatic
+ private fun translucentColor(color: Int): Int {
+ val factor = 0.7f
+ val alpha = Math.round(Color.alpha(color) * factor)
+ val red = Color.red(color)
+ val green = Color.green(color)
+ val blue = Color.blue(color)
+ return Color.argb(alpha, red, green, blue)
+ }
+
+ @ColorInt
+ @JvmStatic
+ fun shiftColor(@ColorInt color: Int,
+ @FloatRange(from = 0.0, to = 2.0) by: Float): Int {
+ if (by == 1f) return color
+ val hsv = color.toHSV()
+ hsv[2] *= by // value component
+ return hsv.toColor()
+ }
+
+ @ColorInt
+ @JvmStatic
+ fun shiftColorDown(@ColorInt color: Int): Int = shiftColor(color, 0.9f)
+
+ @ColorInt
+ @JvmStatic
+ fun shiftColorUp(@ColorInt color: Int): Int = shiftColor(color, 1.1f)
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt
new file mode 100644
index 0000000..22bd0d4
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt
@@ -0,0 +1,349 @@
+package ca.allanwang.kau.dialogs.color
+
+import android.graphics.Color
+
+/**
+ * @author Aidan Follestad (afollestad)
+ */
+internal object ColorPalette {
+
+ val PRIMARY_COLORS: IntArray by lazy {
+ colorArrayOf(
+ "#F44336",
+ "#E91E63",
+ "#9C27B0",
+ "#673AB7",
+ "#3F51B5",
+ "#2196F3",
+ "#03A9F4",
+ "#00BCD4",
+ "#009688",
+ "#4CAF50",
+ "#8BC34A",
+ "#CDDC39",
+ "#FFEB3B",
+ "#FFC107",
+ "#FF9800",
+ "#FF5722",
+ "#795548",
+ "#9E9E9E",
+ "#607D8B")
+ }
+
+ val PRIMARY_COLORS_SUB: Array<IntArray> by lazy {
+ arrayOf(colorArrayOf(
+ "#FFEBEE",
+ "#FFCDD2",
+ "#EF9A9A",
+ "#E57373",
+ "#EF5350",
+ "#F44336",
+ "#E53935",
+ "#D32F2F",
+ "#C62828",
+ "#B71C1C"
+ ), colorArrayOf(
+ "#FCE4EC",
+ "#F8BBD0",
+ "#F48FB1",
+ "#F06292",
+ "#EC407A",
+ "#E91E63",
+ "#D81B60",
+ "#C2185B",
+ "#AD1457",
+ "#880E4F"
+ ), colorArrayOf(
+ "#F3E5F5",
+ "#E1BEE7",
+ "#CE93D8",
+ "#BA68C8",
+ "#AB47BC",
+ "#9C27B0",
+ "#8E24AA",
+ "#7B1FA2",
+ "#6A1B9A",
+ "#4A148C"
+ ), colorArrayOf(
+ "#EDE7F6",
+ "#D1C4E9",
+ "#B39DDB",
+ "#9575CD",
+ "#7E57C2",
+ "#673AB7",
+ "#5E35B1",
+ "#512DA8",
+ "#4527A0",
+ "#311B92"
+ ), colorArrayOf(
+ "#E8EAF6",
+ "#C5CAE9",
+ "#9FA8DA",
+ "#7986CB",
+ "#5C6BC0",
+ "#3F51B5",
+ "#3949AB",
+ "#303F9F",
+ "#283593",
+ "#1A237E"
+ ), colorArrayOf(
+ "#E3F2FD",
+ "#BBDEFB",
+ "#90CAF9",
+ "#64B5F6",
+ "#42A5F5",
+ "#2196F3",
+ "#1E88E5",
+ "#1976D2",
+ "#1565C0",
+ "#0D47A1"
+ ), colorArrayOf(
+ "#E1F5FE",
+ "#B3E5FC",
+ "#81D4FA",
+ "#4FC3F7",
+ "#29B6F6",
+ "#03A9F4",
+ "#039BE5",
+ "#0288D1",
+ "#0277BD",
+ "#01579B"
+ ), colorArrayOf(
+ "#E0F7FA",
+ "#B2EBF2",
+ "#80DEEA",
+ "#4DD0E1",
+ "#26C6DA",
+ "#00BCD4",
+ "#00ACC1",
+ "#0097A7",
+ "#00838F",
+ "#006064"
+ ), colorArrayOf(
+ "#E0F2F1",
+ "#B2DFDB",
+ "#80CBC4",
+ "#4DB6AC",
+ "#26A69A",
+ "#009688",
+ "#00897B",
+ "#00796B",
+ "#00695C",
+ "#004D40"
+ ), colorArrayOf(
+ "#E8F5E9",
+ "#C8E6C9",
+ "#A5D6A7",
+ "#81C784",
+ "#66BB6A",
+ "#4CAF50",
+ "#43A047",
+ "#388E3C",
+ "#2E7D32",
+ "#1B5E20"
+ ), colorArrayOf(
+ "#F1F8E9",
+ "#DCEDC8",
+ "#C5E1A5",
+ "#AED581",
+ "#9CCC65",
+ "#8BC34A",
+ "#7CB342",
+ "#689F38",
+ "#558B2F",
+ "#33691E"
+ ), colorArrayOf(
+ "#F9FBE7",
+ "#F0F4C3",
+ "#E6EE9C",
+ "#DCE775",
+ "#D4E157",
+ "#CDDC39",
+ "#C0CA33",
+ "#AFB42B",
+ "#9E9D24",
+ "#827717"
+ ), colorArrayOf(
+ "#FFFDE7",
+ "#FFF9C4",
+ "#FFF59D",
+ "#FFF176",
+ "#FFEE58",
+ "#FFEB3B",
+ "#FDD835",
+ "#FBC02D",
+ "#F9A825",
+ "#F57F17"
+ ), colorArrayOf(
+ "#FFF8E1",
+ "#FFECB3",
+ "#FFE082",
+ "#FFD54F",
+ "#FFCA28",
+ "#FFC107",
+ "#FFB300",
+ "#FFA000",
+ "#FF8F00",
+ "#FF6F00"
+ ), colorArrayOf(
+ "#FFF3E0",
+ "#FFE0B2",
+ "#FFCC80",
+ "#FFB74D",
+ "#FFA726",
+ "#FF9800",
+ "#FB8C00",
+ "#F57C00",
+ "#EF6C00",
+ "#E65100"
+ ), colorArrayOf(
+ "#FBE9E7",
+ "#FFCCBC",
+ "#FFAB91",
+ "#FF8A65",
+ "#FF7043",
+ "#FF5722",
+ "#F4511E",
+ "#E64A19",
+ "#D84315",
+ "#BF360C"
+ ), colorArrayOf(
+ "#EFEBE9",
+ "#D7CCC8",
+ "#BCAAA4",
+ "#A1887F",
+ "#8D6E63",
+ "#795548",
+ "#6D4C41",
+ "#5D4037",
+ "#4E342E",
+ "#3E2723"
+ ), colorArrayOf(
+ "#FAFAFA",
+ "#F5F5F5",
+ "#EEEEEE",
+ "#E0E0E0",
+ "#BDBDBD",
+ "#9E9E9E",
+ "#757575",
+ "#616161",
+ "#424242",
+ "#212121"
+ ), colorArrayOf(
+ "#ECEFF1",
+ "#CFD8DC",
+ "#B0BEC5",
+ "#90A4AE",
+ "#78909C",
+ "#607D8B",
+ "#546E7A",
+ "#455A64",
+ "#37474F",
+ "#263238"))
+ }
+
+ val ACCENT_COLORS: IntArray by lazy {
+ colorArrayOf(
+ "#FF1744",
+ "#F50057",
+ "#D500F9",
+ "#651FFF",
+ "#3D5AFE",
+ "#2979FF",
+ "#00B0FF",
+ "#00E5FF",
+ "#1DE9B6",
+ "#00E676",
+ "#76FF03",
+ "#C6FF00",
+ "#FFEA00",
+ "#FFC400",
+ "#FF9100",
+ "#FF3D00")
+ }
+
+ val ACCENT_COLORS_SUB: Array<IntArray> by lazy {
+ arrayOf(colorArrayOf("#FF8A80",
+ "#FF5252",
+ "#FF1744",
+ "#D50000"
+ ), colorArrayOf(
+ "#FF80AB",
+ "#FF4081",
+ "#F50057",
+ "#C51162"
+ ), colorArrayOf(
+ "#EA80FC",
+ "#E040FB",
+ "#D500F9",
+ "#AA00FF"
+ ), colorArrayOf(
+ "#B388FF",
+ "#7C4DFF",
+ "#651FFF",
+ "#6200EA"
+ ), colorArrayOf(
+ "#8C9EFF",
+ "#536DFE",
+ "#3D5AFE",
+ "#304FFE"
+ ), colorArrayOf(
+ "#82B1FF",
+ "#448AFF",
+ "#2979FF",
+ "#2962FF"
+ ), colorArrayOf(
+ "#80D8FF",
+ "#40C4FF",
+ "#00B0FF",
+ "#0091EA"
+ ), colorArrayOf(
+ "#84FFFF",
+ "#18FFFF",
+ "#00E5FF",
+ "#00B8D4"
+ ), colorArrayOf(
+ "#A7FFEB",
+ "#64FFDA",
+ "#1DE9B6",
+ "#00BFA5"
+ ), colorArrayOf(
+ "#B9F6CA",
+ "#69F0AE",
+ "#00E676",
+ "#00C853"
+ ), colorArrayOf(
+ "#CCFF90",
+ "#B2FF59",
+ "#76FF03",
+ "#64DD17"
+ ), colorArrayOf(
+ "#F4FF81",
+ "#EEFF41",
+ "#C6FF00",
+ "#AEEA00"
+ ), colorArrayOf(
+ "#FFFF8D",
+ "#FFFF00",
+ "#FFEA00",
+ "#FFD600"
+ ), colorArrayOf(
+ "#FFE57F",
+ "#FFD740",
+ "#FFC400",
+ "#FFAB00"
+ ), colorArrayOf(
+ "#FFD180",
+ "#FFAB40",
+ "#FF9100",
+ "#FF6D00"
+ ), colorArrayOf(
+ "#FF9E80",
+ "#FF6E40",
+ "#FF3D00",
+ "#DD2C00"))
+ }
+
+ fun colorArrayOf(vararg colors: String) = colors.map { Color.parseColor(it) }.toIntArray()
+}
+
diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt
new file mode 100644
index 0000000..7c57c26
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt
@@ -0,0 +1,82 @@
+package ca.allanwang.kau.dialogs.color
+
+import android.content.Context
+import android.graphics.Color
+import android.support.annotation.DimenRes
+import android.support.annotation.StringRes
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.string
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.materialdialogs.Theme
+
+class ColorBuilder : ColorContract {
+ override var title: String? = null
+ override var titleRes: Int = -1
+ override var allowCustom: Boolean = true
+ override var allowCustomAlpha: Boolean = false
+ override var isAccent: Boolean = false
+ override var defaultColor: Int = Color.BLACK
+ override var doneText: Int = R.string.kau_done
+ override var backText: Int = R.string.kau_back
+ override var cancelText: Int = R.string.kau_cancel
+ override var presetText: Int = R.string.kau_md_presets
+ override var customText: Int = R.string.kau_md_custom
+ get() = if (allowCustom) field else 0
+ override var dynamicButtonColors: Boolean = true
+ override var circleSizeRes: Int = R.dimen.kau_color_circle_size
+ override var colorCallback: ((selectedColor: Int) -> Unit)? = null
+ override var colorsTop: IntArray? = null
+ override var colorsSub: Array<IntArray>? = null
+ override var theme: Theme? = null
+}
+
+interface ColorContract {
+ var title: String?
+ var titleRes: Int @StringRes set
+ var allowCustom: Boolean
+ var allowCustomAlpha: Boolean
+ var isAccent: Boolean
+ var defaultColor: Int @StringRes set
+ var doneText: Int @StringRes set
+ var backText: Int @StringRes set
+ var cancelText: Int @StringRes set
+ var presetText: Int
+ @StringRes set
+ var customText: Int @StringRes set
+ var dynamicButtonColors: Boolean
+ var circleSizeRes: Int @DimenRes set
+ var colorCallback: ((selectedColor: Int) -> Unit)?
+ var colorsTop: IntArray?
+ var colorsSub: Array<IntArray>?
+ var theme: Theme?
+}
+
+/**
+ * This is the extension that allows us to initialize the dialog
+ * Note that this returns just the dialog; you still need to call .show() to show it
+ */
+fun Context.colorPickerDialog(action: ColorContract.() -> Unit): MaterialDialog {
+ val b = ColorBuilder()
+ b.action()
+ return colorPickerDialog(b)
+}
+
+fun Context.colorPickerDialog(contract: ColorContract): MaterialDialog {
+ val view = ColorPickerView(this)
+ val dialog = with(MaterialDialog.Builder(this)) {
+ title(string(contract.titleRes, contract.title) ?: string(R.string.kau_md_color_palette))
+ customView(view, false)
+ autoDismiss(false)
+ positiveText(contract.doneText)
+ negativeText(contract.cancelText)
+ if (contract.allowCustom) neutralText(contract.presetText)
+ onPositive { dialog, _ -> contract.colorCallback?.invoke(view.selectedColor); dialog.dismiss() }
+ onNegative { _, _ -> view.backOrCancel() }
+ if (contract.allowCustom) onNeutral { _, _ -> view.toggleCustom() }
+ showListener { view.refreshColors() }
+ if (contract.theme != null) theme(contract.theme!!)
+ build()
+ }
+ view.bind(contract, dialog)
+ return dialog
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt
new file mode 100644
index 0000000..da864c9
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt
@@ -0,0 +1,309 @@
+package ca.allanwang.kau.dialogs.color
+
+import android.content.Context
+import android.graphics.Color
+import android.support.annotation.ColorInt
+import android.support.v4.content.res.ResourcesCompat
+import android.text.Editable
+import android.text.InputFilter
+import android.text.TextWatcher
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.widget.*
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.*
+import com.afollestad.materialdialogs.DialogAction
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.materialdialogs.color.FillGridView
+import java.util.*
+
+/**
+ * Created by Allan Wang on 2017-06-08.
+ *
+ * ColorPicker component of the ColorPickerDialog
+ */
+internal class ColorPickerView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : ScrollView(context, attrs, defStyleAttr) {
+ var selectedColor: Int = -1
+ var isInSub: Boolean = false
+ var isInCustom: Boolean = false
+ var circleSize: Int = context.dimen(R.dimen.kau_color_circle_size).toInt()
+ val backgroundColor = context.resolveColor(R.attr.md_background_color,
+ if (context.resolveColor(android.R.attr.textColorPrimary).isColorDark()) Color.WHITE else 0xff424242.toInt())
+ val backgroundColorTint = backgroundColor.colorToForeground()
+ lateinit var dialog: MaterialDialog
+ lateinit var builder: ColorContract
+ lateinit var colorsTop: IntArray
+ var colorsSub: Array<IntArray>? = null
+ var topIndex: Int = -1
+ var subIndex: Int = -1
+ var colorIndex: Int
+ get() = if (isInSub) subIndex else topIndex
+ set(value) {
+ if (isInSub) subIndex = value
+ else {
+ topIndex = value
+ if (colorsSub != null && colorsSub!!.size > value) {
+ dialog.setActionButton(DialogAction.NEGATIVE, builder.backText)
+ isInSub = true
+ invalidateGrid()
+ }
+ }
+ }
+
+
+ val gridView: FillGridView by bindView(R.id.md_grid)
+ val customFrame: LinearLayout by bindView(R.id.md_colorChooserCustomFrame)
+ val customColorIndicator: View by bindView(R.id.md_colorIndicator)
+ val hexInput: EditText by bindView(R.id.md_hexInput)
+ val alphaLabel: TextView by bindView(R.id.md_colorALabel)
+ val alphaSeekbar: SeekBar by bindView(R.id.md_colorA)
+ val alphaValue: TextView by bindView(R.id.md_colorAValue)
+ val redSeekbar: SeekBar by bindView(R.id.md_colorR)
+ val redValue: TextView by bindView(R.id.md_colorRValue)
+ val greenSeekbar: SeekBar by bindView(R.id.md_colorG)
+ val greenValue: TextView by bindView(R.id.md_colorGValue)
+ val blueSeekbar: SeekBar by bindView(R.id.md_colorB)
+ val blueValue: TextView by bindView(R.id.md_colorBValue)
+
+ var customHexTextWatcher: TextWatcher? = null
+ var customRgbListener: SeekBar.OnSeekBarChangeListener? = null
+
+ init {
+ View.inflate(context, R.layout.md_dialog_colorchooser, this)
+ }
+
+ fun bind(builder: ColorContract, dialog: MaterialDialog) {
+ this.builder = builder
+ this.dialog = dialog
+ this.colorsTop = with(builder) {
+ if (colorsTop != null) colorsTop!!
+ else if (isAccent) ColorPalette.ACCENT_COLORS
+ else ColorPalette.PRIMARY_COLORS
+ }
+ this.colorsSub = with(builder) {
+ if (colorsTop != null) colorsSub
+ else if (isAccent) ColorPalette.ACCENT_COLORS_SUB
+ else ColorPalette.PRIMARY_COLORS_SUB
+ }
+ this.selectedColor = builder.defaultColor
+ if (builder.allowCustom) {
+ if (!builder.allowCustomAlpha) {
+ alphaLabel.gone()
+ alphaSeekbar.gone()
+ alphaValue.gone()
+ hexInput.hint = String.format("%06X", selectedColor)
+ hexInput.filters = arrayOf(InputFilter.LengthFilter(6))
+ } else {
+ hexInput.hint = String.format("%08X", selectedColor)
+ hexInput.filters = arrayOf(InputFilter.LengthFilter(8))
+ }
+ }
+ if (findColor(builder.defaultColor) || !builder.allowCustom) isInCustom = true //when toggled this will be false
+ toggleCustom()
+ }
+
+ fun backOrCancel() {
+ if (isInSub) {
+ dialog.setActionButton(DialogAction.NEGATIVE, builder.cancelText)
+ //to top
+ isInSub = false
+ subIndex = -1
+ invalidateGrid()
+ } else {
+ dialog.cancel()
+ }
+ }
+
+ fun toggleCustom() {
+ isInCustom = !isInCustom
+ if (isInCustom) {
+ isInSub = false
+ if (builder.allowCustom) dialog.setActionButton(DialogAction.NEUTRAL, builder.presetText)
+ dialog.setActionButton(DialogAction.NEGATIVE, builder.cancelText)
+ customHexTextWatcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+ try {
+ selectedColor = Color.parseColor("#" + s.toString())
+ } catch (e: IllegalArgumentException) {
+ selectedColor = Color.BLACK
+ }
+
+ customColorIndicator.setBackgroundColor(selectedColor)
+ if (alphaSeekbar.isVisible()) {
+ val alpha = Color.alpha(selectedColor)
+ alphaSeekbar.progress = alpha
+ alphaValue.text = String.format(Locale.CANADA, "%d", alpha)
+ }
+ redSeekbar.progress = Color.red(selectedColor)
+ greenSeekbar.progress = Color.green(selectedColor)
+ blueSeekbar.progress = Color.blue(selectedColor)
+ isInSub = false
+ topIndex = -1
+ subIndex = -1
+ refreshColors()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ }
+ hexInput.setText(selectedColor.toHexString(builder.allowCustomAlpha, false))
+ hexInput.addTextChangedListener(customHexTextWatcher)
+ customRgbListener = object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
+ if (fromUser) {
+ val color = if (builder.allowCustomAlpha)
+ Color.argb(alphaSeekbar.progress,
+ redSeekbar.progress,
+ greenSeekbar.progress,
+ blueSeekbar.progress)
+ else Color.rgb(redSeekbar.progress,
+ greenSeekbar.progress,
+ blueSeekbar.progress)
+
+ hexInput.setText(color.toHexString(builder.allowCustomAlpha, false))
+ }
+ if (builder.allowCustomAlpha) alphaValue.text = alphaSeekbar.progress.toString()
+ redValue.text = redSeekbar.progress.toString()
+ greenValue.text = greenSeekbar.progress.toString()
+ blueValue.text = blueSeekbar.progress.toString()
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar) {}
+
+ override fun onStopTrackingTouch(seekBar: SeekBar) {}
+ }
+ redSeekbar.setOnSeekBarChangeListener(customRgbListener)
+ greenSeekbar.setOnSeekBarChangeListener(customRgbListener)
+ blueSeekbar.setOnSeekBarChangeListener(customRgbListener)
+ if (alphaSeekbar.isVisible())
+ alphaSeekbar.setOnSeekBarChangeListener(customRgbListener)
+ hexInput.setText(selectedColor.toHexString(alphaSeekbar.isVisible(), false))
+ gridView.fadeOut(onFinish = { gridView.gone() })
+ customFrame.fadeIn()
+ } else {
+ findColor(selectedColor)
+ if (builder.allowCustom) dialog.setActionButton(DialogAction.NEUTRAL, builder.customText)
+ dialog.setActionButton(DialogAction.NEGATIVE, if (isInSub) builder.backText else builder.cancelText)
+ gridView.fadeIn(onStart = { invalidateGrid() })
+ customFrame.fadeOut(onFinish = { customFrame.gone() })
+ hexInput.removeTextChangedListener(customHexTextWatcher)
+ customHexTextWatcher = null
+ alphaSeekbar.setOnSeekBarChangeListener(null)
+ redSeekbar.setOnSeekBarChangeListener(null)
+ greenSeekbar.setOnSeekBarChangeListener(null)
+ blueSeekbar.setOnSeekBarChangeListener(null)
+ customRgbListener = null
+ }
+ }
+
+ fun refreshColors() {
+ if (!isInCustom) findColor(selectedColor)
+ //Ensure that our tinted color is still visible against the background
+ val visibleColor = if (selectedColor.isColorVisibleOn(backgroundColor)) selectedColor else backgroundColorTint
+ if (builder.dynamicButtonColors) {
+ dialog.getActionButton(DialogAction.POSITIVE).setTextColor(visibleColor)
+ dialog.getActionButton(DialogAction.NEGATIVE).setTextColor(visibleColor)
+ dialog.getActionButton(DialogAction.NEUTRAL).setTextColor(visibleColor)
+ }
+ if (!builder.allowCustom || !isInCustom) return
+ if (builder.allowCustomAlpha)
+ alphaSeekbar.visible().tint(visibleColor)
+ redSeekbar.tint(visibleColor)
+ greenSeekbar.tint(visibleColor)
+ blueSeekbar.tint(visibleColor)
+ hexInput.tint(visibleColor)
+ }
+
+ fun findColor(@ColorInt color: Int): Boolean {
+ topIndex = -1
+ subIndex = -1
+ colorsTop.forEachIndexed {
+ index, topColor ->
+ if (findSubColor(color, index)) {
+ topIndex = index
+ return true
+ }
+ if (topColor == color) { // If no sub colors exists and top color matches
+ topIndex = index
+ return true
+ }
+ }
+ return false
+ }
+
+ fun findSubColor(@ColorInt color: Int, topIndex: Int): Boolean {
+ if (colorsSub == null || colorsSub!!.size <= topIndex) return false
+ colorsSub!![topIndex].forEachIndexed {
+ index, subColor ->
+ if (subColor == color) {
+ subIndex = index
+ return true
+ }
+ }
+ return false
+ }
+
+ fun invalidateGrid() {
+ if (gridView.adapter == null) {
+ gridView.adapter = ColorGridAdapter()
+ gridView.selector = ResourcesCompat.getDrawable(resources, R.drawable.kau_transparent, null)
+ } else {
+ (gridView.adapter as BaseAdapter).notifyDataSetChanged()
+ }
+ }
+
+ inner class ColorGridAdapter : BaseAdapter(), OnClickListener, OnLongClickListener {
+ override fun onClick(v: View) {
+ if (v.tag != null && v.tag is String) {
+ val tags = (v.tag as String).split(":")
+ if (colorIndex == tags[0].toInt()) {
+ colorIndex = tags[0].toInt() //Go to sub list if exists
+ return
+ }
+ if (colorIndex != -1) (gridView.getChildAt(colorIndex) as CircleView).animateSelected(false)
+ selectedColor = tags[1].toInt()
+ refreshColors()
+ val currentSub = isInSub
+ colorIndex = tags[0].toInt()
+ if (currentSub == isInSub) (gridView.getChildAt(colorIndex) as CircleView).animateSelected(true)
+ //Otherwise we are invalidating our grid, so there is no point in animating
+ }
+ }
+
+ override fun onLongClick(v: View): Boolean {
+ if (v.tag != null && v.tag is String) {
+ val tag = (v.tag as String).split(":")
+ val color = tag[1].toInt()
+ (v as CircleView).showHint(color)
+ return true
+ }
+ return false
+ }
+
+ override fun getItem(position: Int): Any = if (isInSub) colorsSub!![topIndex][position] else colorsTop[position]
+
+ override fun getCount(): Int = if (isInSub) colorsSub!![topIndex].size else colorsTop.size
+
+ override fun getItemId(position: Int): Long = position.toLong()
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view: CircleView = if (convertView == null)
+ CircleView(context).apply { layoutParams = AbsListView.LayoutParams(circleSize, circleSize) }
+ else
+ convertView as CircleView
+ val color: Int = if (isInSub) colorsSub!![topIndex][position] else colorsTop[position]
+ return view.apply {
+ setBackgroundColor(color)
+ isSelected = colorIndex == position
+ tag = "$position:$color"
+ setOnClickListener(this@ColorGridAdapter)
+ setOnLongClickListener(this@ColorGridAdapter)
+ }
+ }
+
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt b/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt
new file mode 100644
index 0000000..b03a620
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt
@@ -0,0 +1,92 @@
+package ca.allanwang.kau.email
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.support.annotation.StringRes
+import android.util.DisplayMetrics
+import ca.allanwang.kau.R
+import ca.allanwang.kau.logging.KL
+import ca.allanwang.kau.utils.installerPackageName
+import ca.allanwang.kau.utils.isAppInstalled
+import ca.allanwang.kau.utils.string
+
+
+/**
+ * Created by Allan Wang on 2017-06-20.
+ */
+class EmailBuilder(val email: String, val subject: String) {
+ var message: String = "Write here."
+ var deviceDetails: Boolean = true
+ var appInfo: Boolean = true
+ var footer: String? = null
+ private val pairs: MutableMap<String, String> = mutableMapOf()
+ private val packages: MutableList<Package> = mutableListOf()
+
+ fun checkPackage(packageName: String, appName: String) = packages.add(Package(packageName, appName))
+
+ fun addItem(key: String, value: String) = pairs.put(key, value)
+
+ data class Package(val packageName: String, val appName: String)
+
+ fun execute(context: Context) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:$email"))
+ intent.putExtra(Intent.EXTRA_SUBJECT, subject)
+ val emailBuilder = StringBuilder()
+ emailBuilder.append(message).append("\n\n")
+ if (deviceDetails) {
+ val deviceItems = mutableMapOf(
+ "OS Version" to "${System.getProperty("os.version")} (${Build.VERSION.INCREMENTAL})",
+ "OS API Level" to Build.DEVICE,
+ "Manufacturer" to Build.MANUFACTURER,
+ "Model (and Product)" to "${Build.MODEL} (${Build.PRODUCT})",
+ "Package Installer" to (context.installerPackageName ?: "None")
+ )
+ if (context is Activity) {
+ val metric = DisplayMetrics()
+ context.windowManager.defaultDisplay.getMetrics(metric)
+ deviceItems.put("Screen Dimensions", "${metric.widthPixels} x ${metric.heightPixels}")
+ }
+ deviceItems.forEach { (k, v) -> emailBuilder.append("$k: $v\n") }
+ }
+ if (appInfo) {
+ try {
+ val appInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ emailBuilder.append("\nApp: ").append(context.packageName)
+ .append("\nApp Version Name: ").append(appInfo.versionName)
+ .append("\nApp Version Code: ").append(appInfo.versionCode).append("\n")
+ } catch (e: PackageManager.NameNotFoundException) {
+ KL.e("EmailBuilder packageInfo not found")
+ }
+ }
+
+ if (packages.isNotEmpty()) emailBuilder.append("\n")
+ packages.forEach {
+ if (context.isAppInstalled(it.packageName))
+ emailBuilder.append(String.format("\n%s is installed", it.appName))
+ }
+
+ if (pairs.isNotEmpty()) emailBuilder.append("\n")
+ pairs.forEach { (k, v) -> emailBuilder.append("$k: $v\n") }
+
+ if (footer != null)
+ emailBuilder.append("\n").append(footer)
+
+ intent.putExtra(Intent.EXTRA_TEXT, emailBuilder.toString())
+ context.startActivity(Intent.createChooser(intent, context.resources.getString(R.string.kau_send_via)))
+ }
+}
+
+fun Context.sendEmail(@StringRes emailId: Int, @StringRes subjectId: Int, builder: EmailBuilder.() -> Unit = {})
+ = sendEmail(string(emailId), string(subjectId), builder)
+
+
+fun Context.sendEmail(email: String, subject: String, builder: EmailBuilder.() -> Unit = {}) {
+ EmailBuilder(email, subject).apply {
+ builder()
+ execute(this@sendEmail)
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt
new file mode 100644
index 0000000..3380ade
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt
@@ -0,0 +1,127 @@
+package ca.allanwang.kau.iitems
+
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.support.v7.widget.CardView
+import android.support.v7.widget.RecyclerView
+import android.view.View
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.ThemableIItem
+import ca.allanwang.kau.adapters.ThemableIItemDelegate
+import ca.allanwang.kau.utils.*
+import com.mikepenz.fastadapter.FastAdapter
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.items.AbstractItem
+import com.mikepenz.fastadapter.listeners.ClickEventHook
+import com.mikepenz.iconics.typeface.IIcon
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ *
+ * Simple generic card item with an icon, title, description and button
+ * The icon and button are hidden by default unless values are given
+ */
+class CardIItem(val builder: Config.() -> Unit = {}
+) : AbstractItem<CardIItem, CardIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() {
+
+ companion object {
+ @JvmStatic fun bindClickEvents(fastAdapter: FastAdapter<IItem<*,*>>) {
+ fastAdapter.withEventHook(object : ClickEventHook<IItem<*,*>>() {
+ override fun onBindMany(viewHolder: RecyclerView.ViewHolder): List<View>? {
+ return if (viewHolder is ViewHolder) listOf(viewHolder.card, viewHolder.button) else null
+ }
+
+ override fun onClick(v: View, position: Int, adapter: FastAdapter<IItem<*,*>>, item: IItem<*,*>) {
+ if (item !is CardIItem) return
+ with(item.configs) {
+ when (v.id) {
+ R.id.kau_card_container -> cardClick?.onClick(v)
+ R.id.kau_card_button -> buttonClick?.onClick(v)
+ else -> {
+ }
+ }
+ }
+ }
+ })
+ }
+ }
+
+ val configs = Config().apply { builder() }
+
+ class Config {
+ var title: String? = null
+ var titleRes: Int = -1
+ var desc: String? = null
+ var descRes: Int = -1
+ var button: String? = null
+ var buttonRes: Int = -1
+ var buttonClick: View.OnClickListener? = null
+ var cardClick: View.OnClickListener? = null
+ var image: Drawable? = null
+ var imageIIcon: IIcon? = null
+ var imageIIconColor: Int = Color.WHITE
+ var imageRes: Int = -1
+ }
+
+
+ override fun getType(): Int = R.id.kau_item_card
+
+ override fun getLayoutRes(): Int = R.layout.kau_iitem_card
+
+ override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) {
+ super.bindView(holder, payloads)
+ with(holder.itemView.context) context@ {
+ with(configs) {
+ holder.title.text = string(titleRes, title)
+ holder.description.text = string(descRes, desc)
+ val buttonText = string(buttonRes, button)
+ if (buttonText != null) {
+ holder.bottomRow.visible()
+ holder.button.text = buttonText
+ holder.button.setOnClickListener(buttonClick)
+ }
+ holder.icon.setImageDrawable(
+ if (imageRes > 0) drawable(imageRes)
+ else if (imageIIcon != null) imageIIcon!!.toDrawable(this@context, sizeDp = 40, color = imageIIconColor)
+ else image
+ )
+ holder.card.setOnClickListener(cardClick)
+ }
+ with(holder) {
+ bindTextColor(title)
+ bindTextColorSecondary(description)
+ bindAccentColor(button)
+ if (configs.imageIIcon != null) bindIconColor(icon)
+ bindBackgroundRipple(card)
+ }
+ }
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ with(holder) {
+ icon.gone().setImageDrawable(null)
+ title.text = null
+ description.text = null
+ bottomRow.gone()
+ button.setOnClickListener(null)
+ card.setOnClickListener(null)
+ }
+ }
+
+ override fun getViewHolder(v: View): ViewHolder = ViewHolder(v)
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val card: CardView by bindView(R.id.kau_card_container)
+ val icon: ImageView by bindView(R.id.kau_card_image)
+ val title: TextView by bindView(R.id.kau_card_title)
+ val description: TextView by bindView(R.id.kau_card_description)
+ val bottomRow: LinearLayout by bindView(R.id.kau_card_bottom_row)
+ val button: Button by bindView(R.id.kau_card_button)
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt
new file mode 100644
index 0000000..627e1df
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt
@@ -0,0 +1,48 @@
+package ca.allanwang.kau.iitems
+
+import android.support.v7.widget.RecyclerView
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.ThemableIItem
+import ca.allanwang.kau.adapters.ThemableIItemDelegate
+import ca.allanwang.kau.utils.bindView
+import ca.allanwang.kau.views.CutoutView
+import com.mikepenz.fastadapter.items.AbstractItem
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ *
+ * Just a cutout item with some defaults in [R.layout.kau_iitem_cutout]
+ */
+class CutoutIItem(val config: CutoutView.() -> Unit = {}
+) : AbstractItem<CutoutIItem, CutoutIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() {
+
+ override fun getType(): Int = R.id.kau_item_cutout
+
+ override fun getLayoutRes(): Int = R.layout.kau_iitem_cutout
+
+ override fun isSelectable(): Boolean = false
+
+ override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) {
+ super.bindView(holder, payloads)
+ with(holder) {
+ if (accentColor != null && themeEnabled) cutout.foregroundColor = accentColor!!
+ cutout.config()
+ }
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ with(holder) {
+ cutout.drawable = null
+ cutout.text = "Text" //back to default
+ }
+ }
+
+ override fun getViewHolder(v: View): ViewHolder = ViewHolder(v)
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val cutout: CutoutView by bindView(R.id.kau_cutout)
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt
new file mode 100644
index 0000000..e994781
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt
@@ -0,0 +1,49 @@
+package ca.allanwang.kau.iitems
+
+import android.support.v7.widget.CardView
+import android.support.v7.widget.RecyclerView
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.ThemableIItem
+import ca.allanwang.kau.adapters.ThemableIItemDelegate
+import ca.allanwang.kau.utils.bindView
+import ca.allanwang.kau.utils.string
+import com.mikepenz.fastadapter.items.AbstractItem
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ *
+ * Simple Header with lots of padding on the top
+ * Contains only one text view
+ */
+class HeaderIItem(text: String? = null, var textRes: Int = -1
+) : AbstractItem<HeaderIItem, HeaderIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() {
+
+ var text: String = text ?: "Header Placeholder"
+
+ override fun getType(): Int = R.id.kau_item_header_big_margin_top
+
+ override fun getLayoutRes(): Int = R.layout.kau_iitem_header
+
+ override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) {
+ super.bindView(holder, payloads)
+ holder.text.text = holder.itemView.context.string(textRes, text)
+ bindTextColor(holder.text)
+ bindBackgroundColor(holder.container)
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ holder.text.text = null
+ }
+
+ override fun getViewHolder(v: View): ViewHolder = ViewHolder(v)
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val text: TextView by bindView(R.id.kau_header_text)
+ val container: CardView by bindView(R.id.kau_header_container)
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt
new file mode 100644
index 0000000..00b165c
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt
@@ -0,0 +1,23 @@
+package ca.allanwang.kau.iitems
+
+import android.support.annotation.LayoutRes
+import android.support.v7.widget.RecyclerView
+import android.view.View
+import com.mikepenz.fastadapter.IClickable
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.items.AbstractItem
+
+/**
+ * Created by Allan Wang on 2017-07-03.
+ *
+ * Kotlin implementation of the [AbstractItem] to make things shorter
+ */
+open class KauIItem<Item, VH : RecyclerView.ViewHolder>(
+ private val type: Int,
+ @param:LayoutRes private val layoutRes: Int,
+ private val viewHolder: (v: View) -> VH
+) : AbstractItem<Item, VH>() where Item : IItem<*, *>, Item : IClickable<*> {
+ override final fun getType(): Int = type
+ override final fun getViewHolder(v: View): VH = viewHolder(v)
+ override final fun getLayoutRes(): Int = layoutRes
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt
new file mode 100644
index 0000000..aabd9e3
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt
@@ -0,0 +1,100 @@
+package ca.allanwang.kau.iitems
+
+import android.os.Build
+import android.support.v7.widget.CardView
+import android.support.v7.widget.RecyclerView
+import android.text.Html
+import android.view.View
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.ThemableIItem
+import ca.allanwang.kau.adapters.ThemableIItemDelegate
+import ca.allanwang.kau.utils.bindView
+import ca.allanwang.kau.utils.gone
+import ca.allanwang.kau.utils.startLink
+import ca.allanwang.kau.utils.visible
+import ca.allanwang.kau.views.createSimpleRippleDrawable
+import com.mikepenz.aboutlibraries.entity.Library
+import com.mikepenz.fastadapter.FastAdapter
+import com.mikepenz.fastadapter.IItem
+import com.mikepenz.fastadapter.items.AbstractItem
+
+/**
+ * Created by Allan Wang on 2017-06-27.
+ */
+class LibraryIItem(val lib: Library
+) : AbstractItem<LibraryIItem, LibraryIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() {
+
+ companion object {
+ @JvmStatic fun bindClickEvents(fastAdapter: FastAdapter<IItem<*, *>>) {
+ fastAdapter.withOnClickListener { v, _, item, _ ->
+ if (item !is LibraryIItem) false
+ else {
+ val c = v.context
+ with(item.lib) {
+ c.startLink(libraryWebsite, repositoryLink, authorWebsite)
+ }
+ true
+ }
+ }
+ }
+ }
+
+ override fun getType(): Int = R.id.kau_item_library
+
+ override fun getLayoutRes(): Int = R.layout.kau_iitem_library
+
+ override fun isSelectable(): Boolean = false
+
+ override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) {
+ super.bindView(holder, payloads)
+ with(holder) {
+ name.text = lib.libraryName
+ creator.text = lib.author
+ description.text = if (lib.libraryDescription.isBlank()) lib.libraryDescription
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
+ Html.fromHtml(lib.libraryDescription, Html.FROM_HTML_MODE_LEGACY)
+ else Html.fromHtml(lib.libraryDescription)
+ bottomDivider.gone()
+ if (lib.libraryVersion?.isNotBlank() ?: false) {
+ bottomDivider.visible()
+ version.visible().text = lib.libraryVersion
+ }
+ if (lib.license?.licenseName?.isNotBlank() ?: false) {
+ bottomDivider.visible()
+ license.visible().text = lib.license?.licenseName
+ }
+ bindTextColor(name, creator)
+ bindTextColorSecondary(description)
+ bindAccentColor(license, version)
+ bindDividerColor(divider, bottomDivider)
+ bindBackgroundRipple(card)
+ }
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ with(holder) {
+ name.text = null
+ creator.text = null
+ description.text = null
+ bottomDivider.gone()
+ version.gone().text = null
+ license.gone().text = null
+ }
+ }
+
+ override fun getViewHolder(v: View): ViewHolder = ViewHolder(v)
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val card: CardView by bindView(R.id.lib_item_card)
+ val name: TextView by bindView(R.id.lib_item_name)
+ val creator: TextView by bindView(R.id.lib_item_author)
+ val description: TextView by bindView(R.id.lib_item_description)
+ val version: TextView by bindView(R.id.lib_item_version)
+ val license: TextView by bindView(R.id.lib_item_license)
+ val divider: View by bindView(R.id.lib_item_top_divider)
+ val bottomDivider: View by bindView(R.id.lib_item_bottom_divider)
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt
new file mode 100644
index 0000000..8b59539
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt
@@ -0,0 +1,50 @@
+package ca.allanwang.kau.kotlin
+
+import android.content.Context
+import android.support.annotation.AnimatorRes
+import android.support.annotation.InterpolatorRes
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.view.animation.Interpolator
+
+/**
+ * Created by Allan Wang on 2017-05-30.
+ *
+ * Lazy retrieval of context based items
+ * Items are retrieved using delegateName[context]
+ *
+ */
+
+fun lazyInterpolator(@InterpolatorRes id: Int) = lazyContext<Interpolator> { AnimationUtils.loadInterpolator(it, id) }
+fun lazyAnimation(@AnimatorRes id: Int) = lazyContext<Animation> { AnimationUtils.loadAnimation(it, id) }
+
+fun <T : Any> lazyContext(initializer: (context: Context) -> T): LazyContext<T> = LazyContext<T>(initializer)
+
+class LazyContext<out T : Any>(private val initializer: (context: Context) -> T, lock: Any? = null) {
+ @Volatile private var _value: Any = UNINITIALIZED
+ private val lock = lock ?: this
+
+ fun invalidate() {
+ _value = UNINITIALIZED
+ }
+
+ operator fun invoke(context: Context): T {
+ val _v1 = _value
+ if (_v1 !== UNINITIALIZED)
+ @Suppress("UNCHECKED_CAST")
+ return _v1 as T
+
+ return synchronized(lock) {
+ val _v2 = _value
+ if (_v2 !== UNINITIALIZED) {
+ @Suppress("UNCHECKED_CAST")
+ _v2 as T
+ } else {
+ val typedValue = initializer(context)
+ _value = typedValue
+ typedValue
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt
new file mode 100644
index 0000000..f8947f3
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt
@@ -0,0 +1,51 @@
+package ca.allanwang.kau.kotlin
+
+import java.io.Serializable
+import kotlin.reflect.KProperty
+
+/**
+ * Created by Allan Wang on 2017-05-30.
+ *
+ * Lazy delegate that can be invalidated if needed
+ * https://stackoverflow.com/a/37294840/4407321
+ */
+internal object UNINITIALIZED
+
+fun <T : Any> lazyResettable(initializer: () -> T): LazyResettable<T> = LazyResettable<T>(initializer)
+
+class LazyResettable<T : Any>(private val initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
+ @Volatile private var _value: Any = UNINITIALIZED
+ private val lock = lock ?: this
+
+ fun invalidate() {
+ _value = UNINITIALIZED
+ }
+
+ override val value: T
+ get() {
+ val _v1 = _value
+ if (_v1 !== UNINITIALIZED)
+ @Suppress("UNCHECKED_CAST")
+ return _v1 as T
+
+ return synchronized(lock) {
+ val _v2 = _value
+ if (_v2 !== UNINITIALIZED) {
+ @Suppress("UNCHECKED_CAST")
+ _v2 as T
+ } else {
+ val typedValue = initializer()
+ _value = typedValue
+ typedValue
+ }
+ }
+ }
+
+ override fun isInitialized(): Boolean = _value !== UNINITIALIZED
+
+ override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
+
+ operator fun setValue(any: Any, property: KProperty<*>, t: T) {
+ _value = t
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt
new file mode 100644
index 0000000..f3add48
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt
@@ -0,0 +1,12 @@
+package ca.allanwang.kau.kotlin
+
+/**
+ * Created by Allan Wang on 2017-06-24.
+ *
+ * Credits to @zsmb13
+ *
+ * https://github.com/zsmb13/MaterialDrawerKt/blob/master/library/src/main/java/co/zsmb/materialdrawerkt/NonReadablePropertyException.kt
+ */
+class NonReadablePropertyException : Exception()
+
+fun nonReadable(): Nothing = throw NonReadablePropertyException() \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt
new file mode 100644
index 0000000..7fd8955
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt
@@ -0,0 +1,35 @@
+package ca.allanwang.kau.kpref
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * Created by Allan Wang on 2017-06-07.
+ */
+open class KPref {
+
+ lateinit private var c: Context
+ lateinit internal var PREFERENCE_NAME: String
+ private var initialized = false
+
+ fun initialize(c: Context, preferenceName: String) {
+ if (initialized) throw KPrefException("KPref object $preferenceName has already been initialized; please only do so once")
+ initialized = true
+ this.c = c.applicationContext
+ PREFERENCE_NAME = preferenceName
+ }
+
+ internal val sp: SharedPreferences by lazy {
+ if (!initialized) throw KPrefException("KPref object has not yet been initialized; please initialize it with a context and preference name")
+ c.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
+ }
+
+ internal val prefMap: MutableMap<String, KPrefDelegate<*>> = mutableMapOf()
+
+ fun reset() {
+ prefMap.values.forEach { it.invalidate() }
+ }
+
+ operator fun get(key: String): KPrefDelegate<*>? = prefMap[key]
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt
new file mode 100644
index 0000000..9a9f7d4
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt
@@ -0,0 +1,137 @@
+package ca.allanwang.kau.kpref
+
+import android.os.Bundle
+import android.support.annotation.StringRes
+import android.support.constraint.ConstraintLayout
+import android.support.v7.app.AppCompatActivity
+import android.support.v7.widget.RecyclerView
+import android.support.v7.widget.Toolbar
+import android.view.View
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.FrameLayout
+import android.widget.ViewAnimator
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.items.KPrefItemCore
+import ca.allanwang.kau.utils.bindView
+import ca.allanwang.kau.utils.resolveColor
+import ca.allanwang.kau.utils.statusBarColor
+import ca.allanwang.kau.utils.string
+import ca.allanwang.kau.widgets.TextSlider
+import ca.allanwang.kau.views.RippleCanvas
+import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter
+
+abstract class KPrefActivity : AppCompatActivity(), KPrefActivityContract {
+
+ val adapter: FastItemAdapter<KPrefItemCore>
+ @Suppress("UNCHECKED_CAST")
+ get() = recycler.adapter as FastItemAdapter<KPrefItemCore>
+ val recycler: RecyclerView
+ get() = prefHolder.currentView as RecyclerView
+ val container: ConstraintLayout by bindView(R.id.kau_container)
+ val bgCanvas: RippleCanvas by bindView(R.id.kau_ripple)
+ val toolbarCanvas: RippleCanvas by bindView(R.id.kau_toolbar_ripple)
+ val toolbar: Toolbar by bindView(R.id.kau_toolbar)
+ val toolbarTitle: TextSlider by bindView(R.id.kau_toolbar_text)
+ val prefHolder: ViewAnimator by bindView(R.id.kau_holder)
+ private lateinit var globalOptions: GlobalOptions
+ var animate: Boolean = true
+ set(value) {
+ field = value
+ toolbarTitle.animationType = if (value) TextSlider.ANIMATION_SLIDE_HORIZONTAL else TextSlider.ANIMATION_NONE
+ }
+
+ private val SLIDE_IN_LEFT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_in_left) }
+ private val SLIDE_IN_RIGHT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_in_right) }
+ private val SLIDE_OUT_LEFT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_out_left) }
+ private val SLIDE_OUT_RIGHT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_out_right) }
+
+ /**
+ * Core attribute builder that is consistent throughout all items
+ * Leave blank to use defaults
+ */
+ abstract fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ //setup layout
+ setContentView(R.layout.kau_activity_kpref)
+ setSupportActionBar(toolbar)
+ if (supportActionBar != null)
+ with(supportActionBar!!) {
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowHomeEnabled(true)
+ toolbar.setNavigationOnClickListener { onBackPressed() }
+ setDisplayShowTitleEnabled(false)
+ }
+ window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ statusBarColor = 0x30000000
+ toolbarCanvas.set(resolveColor(R.attr.colorPrimary))
+ bgCanvas.set(resolveColor(android.R.attr.colorBackground))
+ prefHolder.animateFirstView = false
+ //setup prefs
+ val core = CoreAttributeBuilder()
+ val builder = kPrefCoreAttributes()
+ core.builder()
+ globalOptions = GlobalOptions(core, this)
+ showNextPrefs(R.string.kau_settings, onCreateKPrefs(savedInstanceState))
+ }
+
+ override fun onPostCreate(savedInstanceState: Bundle?) {
+ super.onPostCreate(savedInstanceState)
+ }
+
+ override fun showNextPrefs(@StringRes toolbarTitleRes: Int, builder: KPrefAdapterBuilder.() -> Unit) {
+ val rv = RecyclerView(this).apply {
+ layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
+ setKPrefAdapter(globalOptions, builder)
+ }
+ with(prefHolder) {
+ inAnimation = if (animate) SLIDE_IN_RIGHT_ITEMS else null
+ outAnimation = if (animate) SLIDE_OUT_LEFT_ITEMS else null
+ addView(rv)
+ showNext()
+ }
+ toolbarTitle.setNextText(string(toolbarTitleRes))
+ }
+
+ override fun showPrevPrefs() {
+ val current = prefHolder.currentView
+ with(prefHolder) {
+ inAnimation = if (animate) SLIDE_IN_LEFT_ITEMS else null
+ outAnimation = if (animate) SLIDE_OUT_RIGHT_ITEMS else null
+ showPrevious()
+ removeView(current)
+ adapter.notifyAdapterDataSetChanged()
+ }
+ toolbarTitle.setPrevText()
+ }
+
+ fun reload(vararg index: Int) {
+ if (index.isEmpty()) adapter.notifyAdapterDataSetChanged()
+ else index.forEach { adapter.notifyItemChanged(it) }
+ }
+
+ override fun reloadByTitle(@StringRes vararg title: Int) {
+ if (title.isEmpty()) return
+ adapter.adapterItems.forEachIndexed { index, item ->
+ if (title.any { item.core.titleRes == it })
+ adapter.notifyItemChanged(index)
+ }
+ }
+
+ abstract fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit
+
+ override fun onBackPressed() {
+ if (!backPress()) super.onBackPressed()
+ }
+
+ fun backPress(): Boolean {
+ if (!toolbarTitle.isRoot) {
+ showPrevPrefs()
+ return true
+ }
+ return false
+ }
+}
+
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt
new file mode 100644
index 0000000..7f42d2a
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt
@@ -0,0 +1,120 @@
+package ca.allanwang.kau.kpref
+
+import android.support.annotation.StringRes
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.items.*
+import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter
+import org.jetbrains.anko.doAsync
+import org.jetbrains.anko.uiThread
+
+/**
+ * Created by Allan Wang on 2017-06-08.
+ *
+ * Houses all the components that can be called externally to setup the kpref mainAdapter
+ */
+
+/**
+ * Base extension that will register the layout manager and mainAdapter with the given items
+ * Returns FastAdapter
+ */
+fun RecyclerView.setKPrefAdapter(globalOptions: GlobalOptions, builder: KPrefAdapterBuilder.() -> Unit): FastItemAdapter<KPrefItemCore> {
+ layoutManager = LinearLayoutManager(context)
+ val adapter = FastItemAdapter<KPrefItemCore>()
+ adapter.withOnClickListener { v, _, item, _ -> item.onClick(v, v.findViewById(R.id.kau_pref_inner_content)) }
+ this.adapter = adapter
+ doAsync {
+ val items = KPrefAdapterBuilder(globalOptions)
+ builder.invoke(items)
+ uiThread {
+ adapter.add(items.list)
+ }
+ }
+ return adapter
+}
+
+@DslMarker
+annotation class KPrefMarker
+
+/**
+ * Contains attributes shared amongst all kpref items
+ */
+@KPrefMarker
+interface CoreAttributeContract {
+ var textColor: (() -> Int)?
+ var accentColor: (() -> Int)?
+}
+
+/**
+ * Implementation of [CoreAttributeContract]
+ */
+class CoreAttributeBuilder : CoreAttributeContract {
+ override var textColor: (() -> Int)? = null
+ override var accentColor: (() -> Int)? = textColor
+}
+
+interface KPrefActivityContract {
+ fun showNextPrefs(@StringRes toolbarTitleRes: Int, builder: KPrefAdapterBuilder.() -> Unit)
+ fun showPrevPrefs()
+ fun reloadByTitle(@StringRes vararg title: Int)
+}
+
+
+class GlobalOptions(core: CoreAttributeContract, activity: KPrefActivityContract
+) : CoreAttributeContract by core, KPrefActivityContract by activity
+
+
+/**
+ * Builder for kpref items
+ * Contains DSLs for every possible item
+ * The arguments are all the mandatory values plus an optional builder housing all the possible configurations
+ * The mandatory values are final so they cannot be edited in the builder
+ */
+@KPrefMarker
+class KPrefAdapterBuilder(internal val globalOptions: GlobalOptions) {
+
+ @KPrefMarker
+ fun header(@StringRes title: Int)
+ = list.add(KPrefHeader(KPrefItemCore.CoreBuilder(globalOptions, title)))
+
+ @KPrefMarker
+ fun checkbox(@StringRes title: Int,
+ getter: (() -> Boolean),
+ setter: ((value: Boolean) -> Unit),
+ builder: KPrefItemBase.BaseContract<Boolean>.() -> Unit = {})
+ = list.add(KPrefCheckbox(KPrefItemBase.BaseBuilder(globalOptions, title, getter, setter)
+ .apply { builder() }))
+
+ @KPrefMarker
+ fun colorPicker(@StringRes title: Int,
+ getter: (() -> Int),
+ setter: ((value: Int) -> Unit),
+ builder: KPrefColorPicker.KPrefColorContract.() -> Unit = {})
+ = list.add(KPrefColorPicker(KPrefColorPicker.KPrefColorBuilder(globalOptions, title, getter, setter)
+ .apply { builder() }))
+
+ @KPrefMarker
+ fun <T> text(@StringRes title: Int,
+ getter: (() -> T),
+ setter: ((value: T) -> Unit),
+ builder: KPrefText.KPrefTextContract<T>.() -> Unit = {})
+ = list.add(KPrefText<T>(KPrefText.KPrefTextBuilder<T>(globalOptions, title, getter, setter)
+ .apply { builder() }))
+
+ @KPrefMarker
+ fun subItems(@StringRes title: Int,
+ itemBuilder: KPrefAdapterBuilder.() -> Unit,
+ builder: KPrefSubItems.KPrefSubItemsContract.() -> Unit)
+ = list.add(KPrefSubItems(KPrefSubItems.KPrefSubItemsBuilder(globalOptions, title, itemBuilder)
+ .apply { builder() }))
+
+ @KPrefMarker
+ fun plainText(@StringRes title: Int,
+ builder: KPrefItemBase.BaseContract<Unit>.() -> Unit = {})
+ = list.add(KPrefPlainText(KPrefPlainText.KPrefPlainTextBuilder(globalOptions, title)
+ .apply { builder() }))
+
+ @KPrefMarker
+ internal val list: MutableList<KPrefItemCore> = mutableListOf()
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt
new file mode 100644
index 0000000..4d57ff1
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt
@@ -0,0 +1,89 @@
+package ca.allanwang.kau.kpref
+
+/**
+ * Created by Allan Wang on 2017-06-07.
+ */
+private object UNINITIALIZED
+
+fun KPref.kpref(key: String, fallback: Boolean, postSetter: (value: Boolean) -> Unit = {}): KPrefDelegate<Boolean> = KPrefDelegate(key, fallback, this, postSetter)
+fun KPref.kpref(key: String, fallback: Double, postSetter: (value: Float) -> Unit = {}): KPrefDelegate<Float> = KPrefDelegate(key, fallback.toFloat(), this, postSetter)
+fun KPref.kpref(key: String, fallback: Float, postSetter: (value: Float) -> Unit = {}): KPrefDelegate<Float> = KPrefDelegate(key, fallback, this, postSetter)
+fun KPref.kpref(key: String, fallback: Int, postSetter: (value: Int) -> Unit = {}): KPrefDelegate<Int> = KPrefDelegate(key, fallback, this, postSetter)
+fun KPref.kpref(key: String, fallback: Long, postSetter: (value: Long) -> Unit = {}): KPrefDelegate<Long> = KPrefDelegate(key, fallback, this, postSetter)
+fun KPref.kpref(key: String, fallback: Set<String>, postSetter: (value: Set<String>) -> Unit = {}): KPrefDelegate<StringSet> = KPrefDelegate(key, StringSet(fallback), this, postSetter)
+fun KPref.kpref(key: String, fallback: String, postSetter: (value: String) -> Unit = {}): KPrefDelegate<String> = KPrefDelegate(key, fallback, this, postSetter)
+
+class StringSet(set: Collection<String>) : LinkedHashSet<String>(set)
+
+/**
+ * Implementation of a kpref data item
+ * Contains a unique key for the shared preference as well as a nonnull fallback item
+ * Also contains an optional mutable postSetter that will be called every time a new value is given
+ */
+class KPrefDelegate<T : Any> internal constructor(private val key: String, private val fallback: T, private val pref: KPref, var postSetter: (value: T) -> Unit = {}, lock: Any? = null) : Lazy<T>, java.io.Serializable {
+
+ @Volatile private var _value: Any = UNINITIALIZED
+ private val lock = lock ?: this
+
+ init {
+ if (pref.prefMap.containsKey(key))
+ throw KPrefException("$key is already used elsewhere in preference ${pref.PREFERENCE_NAME}")
+ pref.prefMap.put(key, this@KPrefDelegate)
+ }
+
+ fun invalidate() {
+ _value = UNINITIALIZED
+ }
+
+ override val value: T
+ get() {
+ val _v1 = _value
+ if (_v1 !== UNINITIALIZED)
+ @Suppress("UNCHECKED_CAST")
+ return _v1 as T
+
+ return synchronized(lock) {
+ val _v2 = _value
+ if (_v2 !== UNINITIALIZED) {
+ @Suppress("UNCHECKED_CAST")
+ _v2 as T
+ } else {
+ _value = when (fallback) {
+ is Boolean -> pref.sp.getBoolean(key, fallback)
+ is Float -> pref.sp.getFloat(key, fallback)
+ is Int -> pref.sp.getInt(key, fallback)
+ is Long -> pref.sp.getLong(key, fallback)
+ is StringSet -> StringSet(pref.sp.getStringSet(key, fallback))
+ is String -> pref.sp.getString(key, fallback)
+ else -> throw KPrefException(fallback)
+ }
+ @Suppress("UNCHECKED_CAST")
+ _value as T
+ }
+ }
+ }
+
+ override fun isInitialized(): Boolean = _value !== UNINITIALIZED
+
+ override fun toString(): String = if (isInitialized()) value.toString() else "Lazy kPref $key not initialized yet."
+
+ operator fun setValue(any: Any, property: kotlin.reflect.KProperty<*>, t: T) {
+ _value = t
+ val editor = pref.sp.edit()
+ when (t) {
+ is Boolean -> editor.putBoolean(key, t)
+ is Float -> editor.putFloat(key, t)
+ is Int -> editor.putInt(key, t)
+ is Long -> editor.putLong(key, t)
+ is StringSet -> editor.putStringSet(key, t)
+ is String -> editor.putString(key, t)
+ else -> throw KPrefException(t)
+ }
+ editor.apply()
+ postSetter.invoke(t)
+ }
+}
+
+class KPrefException(message: String) : IllegalAccessException(message) {
+ constructor(element: Any?) : this("Invalid type in pref cache: ${element?.javaClass?.simpleName ?: "null"}")
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt
new file mode 100644
index 0000000..22cc927
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt
@@ -0,0 +1,33 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import android.widget.CheckBox
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.KPrefMarker
+import ca.allanwang.kau.utils.tint
+
+/**
+ * Created by Allan Wang on 2017-06-07.
+ *
+ * Checkbox preference
+ * When clicked, will toggle the preference and the apply the result to the checkbox
+ */
+class KPrefCheckbox(builder: BaseContract<Boolean>) : KPrefItemBase<Boolean>(builder) {
+
+ override fun defaultOnClick(itemView: View, innerContent: View?): Boolean {
+ pref = !pref
+ (innerContent as CheckBox).isChecked = pref
+ return true
+ }
+
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {
+ super.onPostBindView(viewHolder, textColor, accentColor)
+ val checkbox = viewHolder.bindInnerView<CheckBox>(R.layout.kau_preference_checkbox)
+ if (accentColor != null) checkbox.tint(accentColor)
+ checkbox.isChecked = pref
+ checkbox.jumpDrawablesToCurrentState() //Cancel the animation
+ }
+
+ override fun getType(): Int = R.id.kau_item_pref_checkbox
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt
new file mode 100644
index 0000000..c573939
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt
@@ -0,0 +1,73 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.dialogs.color.CircleView
+import ca.allanwang.kau.dialogs.color.ColorBuilder
+import ca.allanwang.kau.dialogs.color.ColorContract
+import ca.allanwang.kau.dialogs.color.colorPickerDialog
+import ca.allanwang.kau.kpref.CoreAttributeContract
+import ca.allanwang.kau.kpref.GlobalOptions
+import ca.allanwang.kau.kpref.KPrefMarker
+
+/**
+ * Created by Allan Wang on 2017-06-07.
+ *
+ * ColorPicker preference
+ * When a color is successfully selected in the dialog, it will be saved as an int
+ */
+class KPrefColorPicker(val builder: KPrefColorContract) : KPrefItemBase<Int>(builder) {
+
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {
+ super.onPostBindView(viewHolder, textColor, accentColor)
+ builder.apply {
+ titleRes = core.titleRes
+ colorCallback = {
+ pref = it
+ }
+ }
+ if (builder.showPreview) {
+ val preview = viewHolder.bindInnerView<CircleView>(R.layout.kau_preference_color_preview)
+ preview.setBackgroundColor(pref)
+ preview.withBorder = true
+ builder.apply {
+ colorCallback = {
+ pref = it
+ if (builder.showPreview)
+ preview.setBackgroundColor(it)
+ }
+ }
+ }
+ }
+
+
+ override fun defaultOnClick(itemView: View, innerContent: View?): Boolean {
+ builder.apply {
+ defaultColor = pref //update color
+ }
+ itemView.context.colorPickerDialog(builder).show()
+ return true
+ }
+
+ /**
+ * Extension of the base contract and [ColorContract] along with a showPreview option
+ */
+ interface KPrefColorContract : BaseContract<Int>, ColorContract {
+ var showPreview: Boolean
+ }
+
+ /**
+ * Default implementation of [KPrefColorContract]
+ */
+ class KPrefColorBuilder(globalOptions: GlobalOptions,
+ override var titleRes: Int,
+ getter: () -> Int,
+ setter: (value: Int) -> Unit
+ ) : KPrefColorContract, BaseContract<Int> by BaseBuilder<Int>(globalOptions, titleRes, getter, setter),
+ ColorContract by ColorBuilder() {
+ override var showPreview: Boolean = true
+ }
+
+ override fun getType(): Int = R.id.kau_item_pref_color_picker
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt
new file mode 100644
index 0000000..fa8efff
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt
@@ -0,0 +1,25 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.KPrefMarker
+
+/**
+ * Created by Allan Wang on 2017-06-07.
+ *
+ * Header preference
+ * This view just holds a title and is not clickable. It is styled using the accent color
+ */
+class KPrefHeader(builder: CoreContract) : KPrefItemCore(builder) {
+
+ override fun getLayoutRes(): Int = R.layout.kau_preference_header
+
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {
+ if (accentColor != null) viewHolder.title.setTextColor(accentColor)
+ }
+
+ override fun onClick(itemView: View, innerContent: View?): Boolean = true
+
+ override fun getType() = R.id.kau_item_pref_header
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt
new file mode 100644
index 0000000..bb0f0a3
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt
@@ -0,0 +1,85 @@
+package ca.allanwang.kau.kpref.items
+
+import android.support.annotation.CallSuper
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.CoreAttributeContract
+import ca.allanwang.kau.kpref.GlobalOptions
+import ca.allanwang.kau.utils.resolveDrawable
+
+/**
+ * Created by Allan Wang on 2017-06-05.
+ *
+ * Base class for pref setters that include the Shared Preference hooks
+ */
+abstract class KPrefItemBase<T>(val base: BaseContract<T>) : KPrefItemCore(base) {
+
+ open var pref: T
+ get() = base.getter.invoke()
+ set(value) {
+ base.setter.invoke(value)
+ }
+
+ var enabled: Boolean = true
+
+ init {
+ if (base.onClick == null) base.onClick = {
+ itemView, innerContent, _ ->
+ defaultOnClick(itemView, innerContent)
+ }
+ }
+
+ abstract fun defaultOnClick(itemView: View, innerContent: View?): Boolean
+
+ @CallSuper
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {
+ enabled = base.enabler.invoke()
+ with(viewHolder) {
+ if (!enabled) container?.background = null
+ container?.alpha = if (enabled) 1.0f else 0.3f
+ }
+ }
+
+ override final fun onClick(itemView: View, innerContent: View?): Boolean {
+ return if (enabled) base.onClick?.invoke(itemView, innerContent, this) ?: false
+ else base.onDisabledClick?.invoke(itemView, innerContent, this) ?: false
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ with(holder) {
+ container?.isEnabled = true
+ container?.background = itemView.context.resolveDrawable(android.R.attr.selectableItemBackground)
+ container?.alpha = 1.0f
+ }
+ }
+
+ override final fun getLayoutRes(): Int = R.layout.kau_preference
+
+ /**
+ * Extension of the core contract
+ * Since everything that extends the base is an actual preference, there must be a getter and setter
+ * The rest are optional and will have their defaults
+ */
+ interface BaseContract<T> : CoreContract {
+ var enabler: () -> Boolean
+ var onClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)?
+ var onDisabledClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)?
+ val getter: () -> T
+ val setter: (value: T) -> Unit
+ }
+
+ /**
+ * Default implementation of [BaseContract]
+ */
+ class BaseBuilder<T>(globalOptions: GlobalOptions,
+ titleRes: Int,
+ override val getter: () -> T,
+ override val setter: (value: T) -> Unit
+ ) : CoreContract by CoreBuilder(globalOptions, titleRes), BaseContract<T> {
+ override var enabler: () -> Boolean = { true }
+ override var onClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? = null
+ override var onDisabledClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? = null
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt
new file mode 100644
index 0000000..5f684ba
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt
@@ -0,0 +1,126 @@
+package ca.allanwang.kau.kpref.items
+
+import android.support.annotation.CallSuper
+import android.support.annotation.IdRes
+import android.support.annotation.LayoutRes
+import android.support.annotation.StringRes
+import android.support.v7.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.adapters.ThemableIItem
+import ca.allanwang.kau.adapters.ThemableIItemDelegate
+import ca.allanwang.kau.kpref.GlobalOptions
+import ca.allanwang.kau.kpref.KPrefMarker
+import ca.allanwang.kau.utils.*
+import com.mikepenz.fastadapter.items.AbstractItem
+import com.mikepenz.iconics.typeface.IIcon
+
+/**
+ * Created by Allan Wang on 2017-06-05.
+ *
+ * Core class containing nothing but the view items
+ */
+
+abstract class KPrefItemCore(val core: CoreContract) : AbstractItem<KPrefItemCore, KPrefItemCore.ViewHolder>(),
+ ThemableIItem by ThemableIItemDelegate() {
+
+ override final fun getViewHolder(v: View) = ViewHolder(v)
+
+ @CallSuper
+ override fun bindView(viewHolder: ViewHolder, payloads: List<Any>) {
+ super.bindView(viewHolder, payloads)
+ with(viewHolder) {
+ val context = itemView.context
+ title.text = context.string(core.titleRes)
+ if (core.descRes > 0)
+ desc?.visible()?.setText(core.descRes)
+ else
+ desc?.gone()
+ if (core.iicon != null) icon?.visible()?.setIcon(core.iicon, 24)
+ else icon?.gone()
+ innerFrame?.removeAllViews()
+ val textColor = core.globalOptions.textColor?.invoke()
+ if (textColor != null) {
+ title.setTextColor(textColor)
+ desc?.setTextColor(textColor)
+ }
+ val accentColor = core.globalOptions.accentColor?.invoke()
+ if (accentColor != null) {
+ icon?.drawable?.setTint(accentColor)
+ }
+ onPostBindView(this, textColor, accentColor)
+ }
+ }
+
+ abstract fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?)
+
+ abstract fun onClick(itemView: View, innerContent: View?): Boolean
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ with(holder) {
+ title.text = null
+ desc?.text = null
+ icon?.setImageDrawable(null)
+// innerFrame?.removeAllViews()
+ }
+ }
+
+ /**
+ * Core values for all kpref items
+ */
+ @KPrefMarker
+ interface CoreContract {
+ val globalOptions: GlobalOptions
+ @get:StringRes val titleRes: Int
+ var descRes: Int
+ @StringRes get
+ var iicon: IIcon?
+
+ /**
+ * Attempts to reload current item by identifying it with its [titleRes]
+ */
+ fun reloadSelf()
+ }
+
+ /**
+ * Default implementation of [CoreContract]
+ */
+ class CoreBuilder(override val globalOptions: GlobalOptions,
+ override @param:StringRes val titleRes: Int) : CoreContract {
+ override var descRes: Int = -1
+ override var iicon: IIcon? = null
+
+ override fun reloadSelf() {
+ globalOptions.reloadByTitle(titleRes)
+ }
+ }
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val title: TextView by bindView(R.id.kau_pref_title)
+ val container: ViewGroup? by bindOptionalView(R.id.kau_pref_container)
+ val desc: TextView? by bindOptionalView(R.id.kau_pref_desc)
+ val icon: ImageView? by bindOptionalView(R.id.kau_pref_icon)
+ val innerFrame: LinearLayout? by bindOptionalView(R.id.kau_pref_inner_frame)
+ val innerContent: View?
+ get() = itemView.findViewById(R.id.kau_pref_inner_content)
+
+ inline fun <reified T : View> bindInnerView(@LayoutRes id: Int): T {
+ if (innerFrame == null) throw IllegalStateException("Cannot bind inner view when innerFrame does not exist")
+ if (innerContent !is T) {
+ innerFrame!!.removeAllViews()
+ LayoutInflater.from(innerFrame!!.context).inflate(id, innerFrame)
+ }
+ return innerContent as T
+ }
+
+ inline fun <reified T : View> getInnerView() = innerContent as T
+
+ operator fun get(@IdRes id: Int): View = itemView.findViewById(id)
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt
new file mode 100644
index 0000000..a782430
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt
@@ -0,0 +1,29 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.GlobalOptions
+
+/**
+ * Created by Allan Wang on 2017-06-14.
+ *
+ * Just text with the core options. Extends base preference but has an empty getter and setter
+ * Useful replacement of [KPrefText] when nothing is displayed on the right side,
+ * and when the preference is completely handled by the click
+ *
+ */
+class KPrefPlainText(val builder: KPrefPlainTextBuilder) : KPrefItemBase<Unit>(builder) {
+
+ override fun defaultOnClick(itemView: View, innerContent: View?): Boolean {
+ //nothing
+ return true
+ }
+
+ class KPrefPlainTextBuilder(
+ globalOptions: GlobalOptions,
+ titleRes: Int
+ ) : BaseContract<Unit> by BaseBuilder(globalOptions, titleRes, {}, {})
+
+ override fun getType(): Int = R.id.kau_item_pref_plain_text
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt
new file mode 100644
index 0000000..51625ab
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt
@@ -0,0 +1,43 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.GlobalOptions
+import ca.allanwang.kau.kpref.KPrefAdapterBuilder
+
+/**
+ * Created by Allan Wang on 2017-06-14.
+ *
+ * Sub item preference
+ * When clicked, will navigate to a new set of preferences and add the old list to a stack
+ *
+ */
+class KPrefSubItems(val builder: KPrefSubItemsContract) : KPrefItemCore(builder) {
+
+ override fun onClick(itemView: View, innerContent: View?): Boolean {
+ builder.globalOptions.showNextPrefs(builder.titleRes, builder.itemBuilder)
+ return true
+ }
+
+ override fun getLayoutRes(): Int = R.layout.kau_preference
+
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {}
+ /**
+ * Extension of the base contract with an optional text getter
+ */
+ interface KPrefSubItemsContract : CoreContract {
+ val itemBuilder: KPrefAdapterBuilder.() -> Unit
+ }
+
+ /**
+ * Default implementation of [KPrefTextContract]
+ */
+ class KPrefSubItemsBuilder(
+ globalOptions: GlobalOptions,
+ titleRes: Int,
+ override val itemBuilder: KPrefAdapterBuilder.() -> Unit
+ ) : KPrefSubItemsContract, CoreContract by CoreBuilder(globalOptions, titleRes)
+
+ override fun getType(): Int = R.id.kau_item_pref_sub_item
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt
new file mode 100644
index 0000000..8662b6a
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt
@@ -0,0 +1,62 @@
+package ca.allanwang.kau.kpref.items
+
+import android.view.View
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.kpref.GlobalOptions
+import ca.allanwang.kau.utils.toast
+
+/**
+ * Created by Allan Wang on 2017-06-14.
+ *
+ * Text preference
+ * Holds a textview to display data on the right
+ * This is still a generic preference
+ *
+ */
+class KPrefText<T>(val builder: KPrefTextContract<T>) : KPrefItemBase<T>(builder) {
+
+ /**
+ * Automatically reload on set
+ */
+ override var pref: T
+ get() = base.getter.invoke()
+ set(value) {
+ base.setter.invoke(value)
+ builder.reloadSelf()
+ }
+
+ override fun defaultOnClick(itemView: View, innerContent: View?): Boolean {
+ itemView.context.toast("No click function set")
+ return true
+ }
+
+ override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {
+ super.onPostBindView(viewHolder, textColor, accentColor)
+ val textview = viewHolder.bindInnerView<TextView>(R.layout.kau_preference_text)
+ if (textColor != null) textview.setTextColor(textColor)
+ textview.text = builder.textGetter.invoke(pref)
+ }
+
+ /**
+ * Extension of the base contract with an optional text getter
+ */
+ interface KPrefTextContract<T> : BaseContract<T> {
+ var textGetter: (T) -> String?
+ }
+
+ /**
+ * Default implementation of [KPrefTextContract]
+ */
+ class KPrefTextBuilder<T>(
+ globalOptions: GlobalOptions,
+ titleRes: Int,
+ getter: () -> T,
+ setter: (value: T) -> Unit
+ ) : KPrefTextContract<T>, BaseContract<T> by BaseBuilder<T>(globalOptions, titleRes, getter, setter) {
+ override var textGetter: (T) -> String? = { it?.toString() }
+ }
+
+ override fun getType(): Int = R.id.kau_item_pref_text
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt b/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt
new file mode 100644
index 0000000..4fa3360
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt
@@ -0,0 +1,6 @@
+package ca.allanwang.kau.logging
+
+/**
+ * Created by Allan Wang on 2017-06-19.
+ */
+object KL : TimberLogger("KAU") \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt b/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt
new file mode 100644
index 0000000..5969fd5
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt
@@ -0,0 +1,19 @@
+package ca.allanwang.kau.logging
+
+import timber.log.Timber
+
+
+/**
+ * Created by Allan Wang on 2017-05-28.
+ *
+ * Timber extension that will embed the tag as part of the message for each log item
+ */
+open class TimberLogger(tag: String) {
+ internal val TAG = "$tag: %s"
+ fun e(s: String) = Timber.e(TAG, s)
+ fun e(t: Throwable, s: String = "error") = Timber.e(t, TAG, s)
+ fun d(s: String) = Timber.d(TAG, s)
+ fun i(s: String) = Timber.i(TAG, s)
+ fun v(s: String) = Timber.v(TAG, s)
+ fun eThrow(s: String) = e(Throwable(s))
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt
new file mode 100644
index 0000000..6f93c9f
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt
@@ -0,0 +1,58 @@
+package ca.allanwang.kau.permissions
+
+import android.app.Activity
+import android.content.Context
+import android.support.v4.app.ActivityCompat
+import ca.allanwang.kau.logging.KL
+import ca.allanwang.kau.utils.KauException
+import ca.allanwang.kau.utils.buildIsMarshmallowAndUp
+import ca.allanwang.kau.utils.hasPermission
+import java.lang.ref.WeakReference
+
+/**
+ * Created by Allan Wang on 2017-07-03.
+ */
+internal object PermissionManager {
+
+ var requestInProgress = false
+ val pendingResults: MutableList<WeakReference<PermissionResult>> by lazy { mutableListOf<WeakReference<PermissionResult>>() }
+
+ operator fun invoke(context: Context, permissions: Array<out String>, callback: (granted: Boolean, deniedPerm: String?) -> Unit) {
+ KL.d("Requesting permissions: ${permissions.contentToString()}")
+ if (!buildIsMarshmallowAndUp) return callback(true, null)
+ val missingPermissions = permissions.filter { !context.hasPermission(it) }
+ if (missingPermissions.isEmpty()) return callback(true, null)
+ pendingResults.add(WeakReference(PermissionResult(permissions, callback = callback)))
+ if (!requestInProgress) {
+ requestInProgress = true
+ requestPermissions(context, missingPermissions.toTypedArray())
+ } else KL.d("Request is postponed since another one is still in progress; did you remember to override onRequestPermissionsResult?")
+ }
+
+ @Synchronized internal fun requestPermissions(context: Context, permissions: Array<out String>) {
+ val activity = (context as? Activity) ?: throw KauException("Context is not an instance of an activity; cannot request permissions")
+ ActivityCompat.requestPermissions(activity, permissions, 1)
+ }
+
+ fun onRequestPermissionsResult(context: Context, permissions: Array<out String>, grantResults: IntArray) {
+ val count = Math.min(permissions.size, grantResults.size)
+ val iter = pendingResults.iterator()
+ while (iter.hasNext()) {
+ val action = iter.next().get()
+ if ((0 until count).any { action?.onResult(permissions[it], grantResults[it]) ?: true })
+ iter.remove()
+ }
+ if (pendingResults.isEmpty())
+ requestInProgress = false
+ else {
+ val action = pendingResults.map { it.get() }.firstOrNull { it != null }
+ if (action == null) { //actions have been unlinked from their weak references
+ pendingResults.clear()
+ requestInProgress = false
+ return
+ }
+ requestPermissions(context, action.permissions.toTypedArray())
+ }
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt
new file mode 100644
index 0000000..14bfdff
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt
@@ -0,0 +1,26 @@
+package ca.allanwang.kau.permissions
+
+import android.content.pm.PackageManager
+
+/**
+ * Created by Allan Wang on 2017-07-03.
+ */
+class PermissionResult(permissions: Array<out String>, val callback: (granted: Boolean, deniedPerm: String?) -> Unit) {
+ val permissions = mutableSetOf(*permissions)
+
+ /**
+ * Called from the manager whenever a permission has changed
+ * Returns true if result is completed, false otherwise
+ */
+ fun onResult(permission: String, result: Int): Boolean {
+ if (result != PackageManager.PERMISSION_GRANTED) {
+ callback(false, permission)
+ permissions.clear()
+ return true
+ }
+ permissions.remove(permission)
+ if (permissions.isNotEmpty()) return false
+ callback(true, null)
+ return true
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt
new file mode 100644
index 0000000..fd43102
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt
@@ -0,0 +1,68 @@
+package ca.allanwang.kau.permissions
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+
+
+/**
+ * Created by Allan Wang on 2017-07-02.
+ *
+ * Bindings for the permission manager
+ */
+
+/**
+ * Hook that should be added inside all [Activity.onRequestPermissionsResult] so that the Permission manager can handle the responses
+ */
+fun Activity.kauOnRequestPermissionsResult(permissions: Array<out String>, grantResults: IntArray)
+ = PermissionManager.onRequestPermissionsResult(this, permissions, grantResults)
+
+/**
+ * Request a permission with a callback
+ * In reality, an activity is needed to fulfill the request, but a context is enough if those permissions are already granted
+ * To be safe, you may want to check that the context can be casted successfully first
+ * The [callback] returns [granted], which is true if all permissions are granted
+ * [deniedPerm] is the first denied permission, if granted is false
+ */
+fun Context.kauRequestPermissions(vararg permissions: String, callback: (granted: Boolean, deniedPerm: String?) -> Unit)
+ = PermissionManager(this, permissions, callback)
+
+/**
+ * See http://developer.android.com/guide/topics/security/permissions.html#normal-dangerous for a
+ * list of 'dangerous' permissions that require a permission request on API 23.
+ */
+const val PERMISSION_READ_CALENDAR = Manifest.permission.READ_CALENDAR
+
+const val PERMISSION_WRITE_CALENDAR = Manifest.permission.WRITE_CALENDAR
+
+const val PERMISSION_CAMERA = Manifest.permission.CAMERA
+
+const val PERMISSION_READ_CONTACTS = Manifest.permission.READ_CONTACTS
+const val PERMISSION_WRITE_CONTACTS = Manifest.permission.WRITE_CONTACTS
+const val PERMISSION_GET_ACCOUNTS = Manifest.permission.GET_ACCOUNTS
+
+const val PERMISSION_ACCESS_FINE_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION
+const val PERMISSION_ACCESS_COARSE_LOCATION = Manifest.permission.ACCESS_COARSE_LOCATION
+
+const val PERMISSION_RECORD_AUDIO = Manifest.permission.RECORD_AUDIO
+
+const val PERMISSION_READ_PHONE_STATE = Manifest.permission.READ_PHONE_STATE
+const val PERMISSION_CALL_PHONE = Manifest.permission.CALL_PHONE
+const val PERMISSION_READ_CALL_LOG = Manifest.permission.READ_CALL_LOG
+const val PERMISSION_WRITE_CALL_LOG = Manifest.permission.WRITE_CALL_LOG
+const val PERMISSION_ADD_VOICEMAIL = Manifest.permission.ADD_VOICEMAIL
+const val PERMISSION_USE_SIP = Manifest.permission.USE_SIP
+const val PERMISSION_PROCESS_OUTGOING_CALLS = Manifest.permission.PROCESS_OUTGOING_CALLS
+
+const val PERMISSION_BODY_SENSORS = Manifest.permission.BODY_SENSORS
+
+const val PERMISSION_SEND_SMS = Manifest.permission.SEND_SMS
+const val PERMISSION_RECEIVE_SMS = Manifest.permission.RECEIVE_SMS
+const val PERMISSION_READ_SMS = Manifest.permission.READ_SMS
+const val PERMISSION_RECEIVE_WAP_PUSH = Manifest.permission.RECEIVE_WAP_PUSH
+const val PERMISSION_RECEIVE_MMS = Manifest.permission.RECEIVE_MMS
+
+const val PERMISSION_READ_EXTERNAL_STORAGE = Manifest.permission.READ_EXTERNAL_STORAGE
+const val PERMISSION_WRITE_EXTERNAL_STORAGE = Manifest.permission.WRITE_EXTERNAL_STORAGE
+
+const val PERMISSION_SYSTEM_ALERT_WINDOW = Manifest.permission.SYSTEM_ALERT_WINDOW \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt
new file mode 100644
index 0000000..ac8ec2e
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt
@@ -0,0 +1,80 @@
+package ca.allanwang.kau.searchview
+
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.support.constraint.ConstraintLayout
+import android.support.v7.widget.RecyclerView
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.style.StyleSpan
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import ca.allanwang.kau.R
+import ca.allanwang.kau.iitems.KauIItem
+import ca.allanwang.kau.utils.*
+import com.mikepenz.google_material_typeface_library.GoogleMaterial
+import com.mikepenz.iconics.typeface.IIcon
+
+/**
+ * Created by Allan Wang on 2017-06-23.
+ *
+ * A holder for each individual search item
+ * Contains a [key] which acts as a unique identifier (eg url)
+ * and a [content] which is displayed in the item
+ */
+class SearchItem(val key: String,
+ val content: String = key,
+ val description: String? = null,
+ val iicon: IIcon? = GoogleMaterial.Icon.gmd_search,
+ val image: Drawable? = null
+) : KauIItem<SearchItem, SearchItem.ViewHolder>(
+ R.id.kau_item_search,
+ R.layout.kau_search_iitem,
+ {ViewHolder(it)}
+) {
+
+ companion object {
+ @JvmStatic var foregroundColor: Int = 0xdd000000.toInt()
+ @JvmStatic var backgroundColor: Int = 0xfffafafa.toInt()
+ }
+
+ var styledContent: SpannableStringBuilder? = null
+
+ /**
+ * Highlight the subText if it is present in the content
+ */
+ fun withHighlights(subText: String) {
+ val index = content.indexOf(subText)
+ if (index == -1) return
+ styledContent = SpannableStringBuilder(content)
+ styledContent!!.setSpan(StyleSpan(Typeface.BOLD), index, index + subText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) {
+ super.bindView(holder, payloads)
+ holder.title.setTextColor(foregroundColor)
+ holder.desc.setTextColor(foregroundColor.adjustAlpha(0.6f))
+
+ if (image != null) holder.icon.setImageDrawable(image)
+ else holder.icon.setIcon(iicon, sizeDp = 18, color = foregroundColor)
+
+ holder.container.setRippleBackground(foregroundColor, backgroundColor)
+ holder.title.text = styledContent ?: content
+ if (description?.isNotBlank() ?: false) holder.desc.visible().text = description
+ }
+
+ override fun unbindView(holder: ViewHolder) {
+ super.unbindView(holder)
+ holder.title.text = null
+ holder.desc.gone().text = null
+ holder.icon.setImageDrawable(null)
+ }
+
+ class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ val icon: ImageView by bindView(R.id.search_icon)
+ val title: TextView by bindView(R.id.search_title)
+ val desc: TextView by bindView(R.id.search_desc)
+ val container: ConstraintLayout by bindView(R.id.search_item_frame)
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt
new file mode 100644
index 0000000..c077a06
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt
@@ -0,0 +1,412 @@
+package ca.allanwang.kau.searchview
+
+import android.app.Activity
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.support.annotation.ColorInt
+import android.support.annotation.IdRes
+import android.support.annotation.StringRes
+import android.support.transition.AutoTransition
+import android.support.v7.widget.AppCompatEditText
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.util.AttributeSet
+import android.view.*
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.ProgressBar
+import ca.allanwang.kau.R
+import ca.allanwang.kau.animators.NoAnimator
+import ca.allanwang.kau.kotlin.nonReadable
+import ca.allanwang.kau.searchview.SearchView.Configs
+import ca.allanwang.kau.utils.*
+import ca.allanwang.kau.views.BoundedCardView
+import com.jakewharton.rxbinding2.widget.RxTextView
+import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter
+import com.mikepenz.google_material_typeface_library.GoogleMaterial
+import com.mikepenz.iconics.typeface.IIcon
+import io.reactivex.Observable
+import io.reactivex.schedulers.Schedulers
+import org.jetbrains.anko.runOnUiThread
+
+
+/**
+ * Created by Allan Wang on 2017-06-23.
+ *
+ * A materialized SearchView with complete theming and observables
+ * This view can be added programmatically and configured using the [Configs] DSL
+ * It is preferred to add the view through an activity, but it can be attached to any ViewGroup
+ * Beware of where specifically this is added, as its view or the keyboard may affect positioning
+ *
+ * Huge thanks to @lapism for his base
+ * https://github.com/lapism/SearchView
+ */
+class SearchView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ /**
+ * Collection of all possible arguments when building the SearchView
+ * Everything is made as opened as possible so other components may be found in the [SearchView]
+ * However, these are the notable options put together an an inner class for better visibility
+ */
+ inner class Configs {
+ /**
+ * In the searchview, foreground color accounts for all text colors and icon colors
+ * Various alpha levels may be used for sub texts/dividers etc
+ */
+ var foregroundColor: Int
+ get() = SearchItem.foregroundColor
+ set(value) {
+ if (SearchItem.foregroundColor == value) return
+ SearchItem.foregroundColor = value
+ tintForeground(value)
+ }
+ /**
+ * Namely the background for the card and recycler view
+ */
+ var backgroundColor: Int
+ get() = SearchItem.backgroundColor
+ set(value) {
+ if (SearchItem.backgroundColor == value) return
+ SearchItem.backgroundColor = value
+ tintBackground(value)
+ }
+ /**
+ * Icon for the leftmost ImageView, which typically contains the hamburger menu/back arror
+ */
+ var navIcon: IIcon? = GoogleMaterial.Icon.gmd_arrow_back
+ set(value) {
+ field = value
+ iconNav.setSearchIcon(value)
+ if (value == null) iconNav.gone()
+ }
+
+ /**
+ * Optional icon just to the left of the clear icon
+ * This is not implemented by default, but can be used for anything, such as mic or redirects
+ * Returns the extra imageview
+ * Set the iicon as null to hide the extra icon
+ */
+ fun setExtraIcon(iicon: IIcon?, onClick: OnClickListener?): ImageView {
+ iconExtra.setSearchIcon(iicon)
+ if (iicon == null) iconClear.gone()
+ iconExtra.setOnClickListener(onClick)
+ return iconExtra
+ }
+
+ /**
+ * Icon for the rightmost ImageView, which typically contains a close icon
+ */
+ var clearIcon: IIcon? = GoogleMaterial.Icon.gmd_clear
+ set(value) {
+ field = value
+ iconClear.setSearchIcon(value)
+ if (value == null) iconClear.gone()
+ }
+ /**
+ * Duration for the circular reveal animation
+ */
+ var revealDuration: Long = 300L
+ /**
+ * Duration for the auto transition, which is namely used to resize the recycler view
+ */
+ var transitionDuration: Long = 100L
+ /**
+ * Defines whether the edit text and mainAdapter should clear themselves when the searchView is closed
+ */
+ var shouldClearOnClose: Boolean = false
+ /**
+ * Callback that will be called every time the searchView opens
+ */
+ var openListener: ((searchView: SearchView) -> Unit)? = null
+ /**
+ * Callback that will be called every time the searchView closes
+ */
+ var closeListener: ((searchView: SearchView) -> Unit)? = null
+ /**
+ * Draw a divider between the search bar and the suggestion items
+ * The divider is colored based on the [foregroundColor]
+ */
+ var withDivider: Boolean = true
+ set(value) {
+ field = value
+ if (value) divider.visible() else divider.invisible()
+ }
+ /**
+ * Hint string to be set in the searchView
+ */
+ var hintText: String?
+ get() = editText.hint?.toString()
+ set(value) {
+ editText.hint = value
+ }
+ /**
+ * Hint string res to be set in the searchView
+ */
+ var hintTextRes: Int
+ @Deprecated(level = DeprecationLevel.ERROR, message = "Non readable property")
+ get() = nonReadable()
+ @StringRes set(value) {
+ hintText = context.string(value)
+ }
+ /**
+ * StringRes for a "no results found" item
+ * If [results] is ever set to an empty list, it will default to
+ * a list with one item with this string
+ *
+ * For simplicity, kau contains [R.string.kau_no_results_found]
+ * which you may use
+ */
+ var noResultsFound: Int = -1
+ /**
+ * Text watcher configurations on init
+ * By default, the observable is on a separate thread, so you may directly execute background processes
+ * This builder acts on an observable, so you may switch threads, debounce, and do anything else that you require
+ */
+ var textObserver: (observable: Observable<String>, searchView: SearchView) -> Unit = { _, _ -> }
+ /**
+ * Click event for suggestion items
+ * This event is only triggered when [key] is not blank (like in [noResultsFound]
+ */
+ var onItemClick: (position: Int, key: String, content: String, searchView: SearchView) -> Unit = { _, _, _, _ -> }
+ /**
+ * Long click event for suggestion items
+ * This event is only triggered when [key] is not blank (like in [noResultsFound]
+ */
+ var onItemLongClick: (position: Int, key: String, content: String, searchView: SearchView) -> Unit = { _, _, _, _ -> }
+ /**
+ * If a [SearchItem]'s title contains the submitted query, make that portion bold
+ * See [SearchItem.withHighlights]
+ */
+ var highlightQueryText: Boolean = true
+ }
+
+ /**
+ * Contract for mainAdapter items
+ * Setting results will ensure that the values are sent on the UI thread
+ */
+ var results: List<SearchItem>
+ get() = adapter.adapterItems
+ set(value) = context.runOnUiThread {
+ val list = if (value.isEmpty() && configs.noResultsFound > 0)
+ listOf(SearchItem("", context.string(configs.noResultsFound), iicon = null))
+ else value
+ if (configs.highlightQueryText && value.isNotEmpty()) list.forEach { it.withHighlights(editText.text.toString()) }
+ cardTransition()
+ adapter.setNewList(list)
+ }
+
+ /**
+ * Empties the list on the UI thread
+ * The noResults item will not be added
+ */
+ internal fun clearResults() = context.runOnUiThread { cardTransition(); adapter.clear() }
+
+ val configs = Configs()
+ //views
+ private val shadow: View by bindView(R.id.search_shadow)
+ private val card: BoundedCardView by bindView(R.id.search_cardview)
+ private val iconNav: ImageView by bindView(R.id.search_nav)
+ private val editText: AppCompatEditText by bindView(R.id.search_edit_text)
+ val textEvents: Observable<String>
+ private val progress: ProgressBar by bindView(R.id.search_progress)
+ val iconExtra: ImageView by bindView(R.id.search_extra)
+ private val iconClear: ImageView by bindView(R.id.search_clear)
+ private val divider: View by bindView(R.id.search_divider)
+ private val recycler: RecyclerView by bindView(R.id.search_recycler)
+ val adapter = FastItemAdapter<SearchItem>()
+ var menuItem: MenuItem? = null
+ val isOpen: Boolean
+ get() = card.isVisible()
+
+ /*
+ * Ripple start points and search view offset
+ * These are calculated every time the search view is opened,
+ * and can be overridden with the open listener if necessary
+ */
+ var menuX: Int = -1 //starting x for circular reveal
+ var menuY: Int = -1 //reference for cardview's marginTop
+ var menuHalfHeight: Int = -1 //starting y for circular reveal (relative to the cardview)
+
+ init {
+ View.inflate(context, R.layout.kau_search_view, this)
+ z = 99f
+ iconNav.setSearchIcon(configs.navIcon).setOnClickListener { revealClose() }
+ iconClear.setSearchIcon(configs.clearIcon).setOnClickListener { editText.text.clear() }
+ tintForeground(configs.foregroundColor)
+ tintBackground(configs.backgroundColor)
+ with(recycler) {
+ isNestedScrollingEnabled = false
+ layoutManager = LinearLayoutManager(context)
+ addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) hideKeyboard()
+ }
+ })
+ adapter = this@SearchView.adapter
+ itemAnimator = NoAnimator()
+ }
+ with(adapter) {
+ withSelectable(true)
+ withOnClickListener { _, _, item, position ->
+ if (item.key.isNotBlank()) configs.onItemClick(position, item.key, item.content, this@SearchView); true
+ }
+ withOnLongClickListener { _, _, item, position ->
+ if (item.key.isNotBlank()) configs.onItemLongClick(position, item.key, item.content, this@SearchView); true
+ }
+ }
+ textEvents = RxTextView.textChangeEvents(editText)
+ .skipInitialValue()
+ .observeOn(Schedulers.newThread())
+ .map { it.text().toString().trim() }
+ textEvents.filter { it.isBlank() }
+ .subscribe { clearResults() }
+ }
+
+ internal fun ImageView.setSearchIcon(iicon: IIcon?): ImageView {
+ setIcon(iicon, sizeDp = 18, color = configs.foregroundColor)
+ return this
+ }
+
+ internal fun cardTransition(builder: AutoTransition.() -> Unit = {}) {
+ card.transitionAuto { duration = configs.transitionDuration; builder() }
+ }
+
+ fun config(config: Configs.() -> Unit) {
+ configs.config()
+ }
+
+ /**
+ * Binds the SearchView to a menu item and handles everything internally
+ * This is assuming that SearchView has already been added to a ViewGroup
+ * If not, see the extension function [bindSearchView]
+ */
+ fun bind(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: Configs.() -> Unit = {}): SearchView {
+ config(config)
+ configs.textObserver(textEvents.filter { it.isNotBlank() }, this)
+ menuItem = menu.findItem(id)
+ if (menuItem!!.icon == null) menuItem!!.icon = GoogleMaterial.Icon.gmd_search.toDrawable(context, 18, menuIconColor)
+ card.gone()
+ menuItem!!.setOnMenuItemClickListener { configureCoords(it); revealOpen(); true }
+ shadow.setOnClickListener { revealClose() }
+ return this
+ }
+
+ fun unBind(replacementMenuItemClickListener: MenuItem.OnMenuItemClickListener? = null) {
+ parentViewGroup.removeView(this)
+ menuItem?.setOnMenuItemClickListener(replacementMenuItemClickListener)
+ }
+
+ fun configureCoords(item: MenuItem) {
+ val view = parentViewGroup.findViewById<View>(item.itemId) ?: return
+ val locations = IntArray(2)
+ view.getLocationOnScreen(locations)
+ menuX = (locations[0] + view.width / 2)
+ menuHalfHeight = view.height / 2
+ menuY = (locations[1] + menuHalfHeight)
+ card.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ view.viewTreeObserver.removeOnPreDrawListener(this)
+ val topAlignment = menuY - card.height / 2
+ val params = (card.layoutParams as MarginLayoutParams).apply {
+ topMargin = topAlignment
+ }
+ card.layoutParams = params
+ return false
+ }
+ })
+ }
+
+ /**
+ * Handle a back press event
+ * Returns true if back press is consumed, false otherwise
+ */
+ fun onBackPressed(): Boolean {
+ if (isOpen && menuItem != null) {
+ revealClose()
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Tint foreground attributes
+ * This can be done publicly through [configs], which will also save the color
+ */
+ internal fun tintForeground(@ColorInt color: Int) {
+ iconNav.drawable.setTint(color)
+ iconClear.drawable.setTint(color)
+ divider.setBackgroundColor(color.adjustAlpha(0.1f))
+ editText.tint(color)
+ editText.setTextColor(ColorStateList.valueOf(color))
+ }
+
+ /**
+ * Tint background attributes
+ * This can be done publicly through [configs], which will also save the color
+ */
+ internal fun tintBackground(@ColorInt color: Int) {
+ card.setCardBackgroundColor(color)
+ }
+
+ fun revealOpen() {
+ if (isOpen) return
+ /**
+ * The y component is relative to the cardView, but it hasn't been drawn yet so its own height is 0
+ * We therefore use half the menuItem height, which is a close approximation to our intended value
+ * The cardView matches the parent's width, so menuX is correct
+ */
+ configs.openListener?.invoke(this)
+ shadow.fadeIn()
+ editText.showKeyboard()
+ card.circularReveal(menuX, menuHalfHeight, duration = configs.revealDuration) {
+ cardTransition()
+ recycler.visible()
+ }
+ }
+
+ fun revealClose() {
+ if (!isOpen) return
+ shadow.fadeOut(duration = configs.transitionDuration)
+ cardTransition {
+ addEndListener {
+ card.circularHide(menuX, menuHalfHeight, duration = configs.revealDuration,
+ onFinish = {
+ configs.closeListener?.invoke(this@SearchView)
+ if (configs.shouldClearOnClose) editText.text.clear()
+ recycler.gone()
+ })
+ }
+ }
+ recycler.gone()
+ editText.hideKeyboard()
+ }
+}
+
+@DslMarker
+annotation class KauSearch
+
+/**
+ * Helper function that binds to an activity's main view
+ */
+@KauSearch
+fun Activity.bindSearchView(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: SearchView.Configs.() -> Unit = {}): SearchView
+ = findViewById<ViewGroup>(android.R.id.content).bindSearchView(menu, id, menuIconColor, config)
+
+/**
+ * Bind searchView to a menu item; call this in [Activity.onCreateOptionsMenu]
+ * Be wary that if you may reinflate the menu many times (eg through [Activity.invalidateOptionsMenu]),
+ * it may be worthwhile to hold a reference to the searchview and only bind it if it hasn't been bound before
+ */
+@KauSearch
+fun ViewGroup.bindSearchView(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: SearchView.Configs.() -> Unit = {}): SearchView {
+ val searchView = SearchView(context)
+ searchView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
+ addView(searchView)
+ searchView.bind(menu, id, menuIconColor, config)
+ return searchView
+}
+
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt
new file mode 100644
index 0000000..ec51bfd
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt
@@ -0,0 +1,71 @@
+package ca.allanwang.kau.utils
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Color
+import android.support.annotation.ColorInt
+import android.support.annotation.StringRes
+import android.support.design.widget.Snackbar
+import android.support.v4.app.Fragment
+import android.view.Menu
+import ca.allanwang.kau.R
+import com.mikepenz.iconics.typeface.IIcon
+import org.jetbrains.anko.contentView
+import org.jetbrains.anko.withArguments
+
+/**
+ * Created by Allan Wang on 2017-06-21.
+ */
+
+/**
+ * Restarts an activity from itself without animations
+ * Keeps its existing extra bundles and has a builder to accept other parameters
+ */
+fun Activity.restart(builder: Intent.() -> Unit = {}) {
+ val i = Intent(this, this::class.java)
+ i.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
+ i.putExtras(intent.extras)
+ i.builder()
+ startActivity(i)
+ overridePendingTransition(0, 0) //No transitions
+ finish()
+ overridePendingTransition(0, 0)
+}
+
+fun Activity.finishSlideOut() {
+ finish()
+ overridePendingTransition(R.anim.kau_fade_in, R.anim.kau_slide_out_right_top)
+}
+
+var Activity.navigationBarColor: Int
+ get() = if (buildIsLollipopAndUp) window.navigationBarColor else Color.BLACK
+ set(value) {
+ if (buildIsLollipopAndUp) window.navigationBarColor = value
+ }
+
+var Activity.statusBarColor: Int
+ get() = if (buildIsLollipopAndUp) window.statusBarColor else Color.BLACK
+ set(value) {
+ if (buildIsLollipopAndUp) window.statusBarColor = value
+ }
+
+/**
+ * Themes the base menu icons and adds iicons programmatically based on ids
+ *
+ * Call in [Activity.onCreateOptionsMenu]
+ */
+fun Activity.setMenuIcons(menu: Menu, @ColorInt color: Int = Color.WHITE, vararg iicons: Pair<Int, IIcon>) {
+ iicons.forEach { (id, iicon) ->
+ menu.findItem(id).icon = iicon.toDrawable(this, sizeDp = 18, color = color)
+ }
+}
+
+fun Activity.hideKeyboard() = currentFocus.hideKeyboard()
+
+fun Activity.showKeyboard() = currentFocus.showKeyboard()
+
+fun Activity.snackbar(text: String, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {})
+ = contentView!!.snackbar(text, duration, builder)
+
+fun Activity.snackbar(@StringRes textId: Int, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {})
+ = contentView!!.snackbar(textId, duration, builder) \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt
new file mode 100644
index 0000000..3db8b9c
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt
@@ -0,0 +1,15 @@
+package ca.allanwang.kau.utils
+
+import ca.allanwang.kau.kotlin.lazyInterpolator
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ *
+ * Holder for a bunch of common animators/interpolators used throughout this library
+ */
+object AnimHolder {
+
+ val fastOutSlowInInterpolator = lazyInterpolator(android.R.interpolator.fast_out_linear_in)
+ val decelerateInterpolator = lazyInterpolator(android.R.interpolator.decelerate_cubic)
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt
new file mode 100644
index 0000000..86b049e
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt
@@ -0,0 +1,153 @@
+package ca.allanwang.kau.utils
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.content.Context
+import android.support.annotation.StringRes
+import android.view.View
+import android.view.ViewAnimationUtils
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.view.animation.DecelerateInterpolator
+import android.view.animation.Interpolator
+import android.widget.TextView
+import ca.allanwang.kau.kotlin.lazyContext
+
+/**
+ * Created by Allan Wang on 2017-06-01.
+ *
+ * Animation extension @KauUtils functions for Views
+ */
+@KauUtils fun View.rootCircularReveal(x: Int = 0, y: Int = 0, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) {
+ this.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
+ override @KauUtils fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int,
+ oldRight: Int, oldBottom: Int) {
+ v.removeOnLayoutChangeListener(this)
+ var x2 = x
+ var y2 = y
+ if (x2 > right) x2 = 0
+ if (y2 > bottom) y2 = 0
+ val radius = Math.hypot(Math.max(x2, right - x2).toDouble(), Math.max(y2, bottom - y2).toDouble()).toInt()
+ val reveal = ViewAnimationUtils.createCircularReveal(v, x2, y2, 0f, radius.toFloat())
+ reveal.interpolator = DecelerateInterpolator(1f)
+ reveal.duration = duration
+ reveal.addListener(object : AnimatorListenerAdapter() {
+ override @KauUtils fun onAnimationStart(animation: Animator?) {
+ visible()
+ onStart?.invoke()
+ }
+
+ override @KauUtils fun onAnimationEnd(animation: Animator?) = onFinish?.invoke() ?: Unit
+ override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit
+ })
+ reveal.start()
+ }
+ })
+}
+
+@KauUtils fun View.circularReveal(x: Int = 0, y: Int = 0, offset: Long = 0L, radius: Float = -1.0f, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) {
+ if (!isAttachedToWindow) {
+ onStart?.invoke()
+ visible()
+ onFinish?.invoke()
+ return
+ }
+ var r = radius
+ if (r < 0.0f) {
+ r = Math.max(Math.hypot(x.toDouble(), y.toDouble()), Math.hypot((width - x.toDouble()), (height - y.toDouble()))).toFloat()
+ }
+ val anim = ViewAnimationUtils.createCircularReveal(this, x, y, 0f, r).setDuration(duration)
+ anim.startDelay = offset
+ anim.addListener(object : AnimatorListenerAdapter() {
+ override @KauUtils fun onAnimationStart(animation: Animator?) {
+ visible()
+ onStart?.invoke()
+ }
+
+ override @KauUtils fun onAnimationEnd(animation: Animator?) = onFinish?.invoke() ?: Unit
+ override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit
+ })
+ anim.start()
+}
+
+@KauUtils fun View.circularHide(x: Int = 0, y: Int = 0, offset: Long = 0L, radius: Float = -1.0f, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) {
+ if (!isAttachedToWindow) {
+ onStart?.invoke()
+ invisible()
+ onFinish?.invoke()
+ return
+ }
+ var r = radius
+ if (r < 0.0f) {
+ r = Math.max(Math.hypot(x.toDouble(), y.toDouble()), Math.hypot((width - x.toDouble()), (height - y.toDouble()))).toFloat()
+ }
+ val anim = ViewAnimationUtils.createCircularReveal(this, x, y, r, 0f).setDuration(duration)
+ anim.startDelay = offset
+ anim.addListener(object : AnimatorListenerAdapter() {
+ override @KauUtils fun onAnimationStart(animation: Animator?) = onStart?.invoke() ?: Unit
+
+ override @KauUtils fun onAnimationEnd(animation: Animator?) {
+ invisible()
+ onFinish?.invoke() ?: Unit
+ }
+
+ override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit
+ })
+ anim.start()
+}
+
+@KauUtils fun View.fadeIn(offset: Long = 0L, duration: Long = 200L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) {
+ if (!isAttachedToWindow) {
+ onStart?.invoke()
+ visible()
+ onFinish?.invoke()
+ return
+ }
+ if (isAttachedToWindow) {
+ val anim = AnimationUtils.loadAnimation(context, android.R.anim.fade_in)
+ anim.startOffset = offset
+ anim.duration = duration
+ anim.setAnimationListener(object : Animation.AnimationListener {
+ override @KauUtils fun onAnimationRepeat(animation: Animation?) {}
+ override @KauUtils fun onAnimationEnd(animation: Animation?) = onFinish?.invoke() ?: Unit
+ override @KauUtils fun onAnimationStart(animation: Animation?) {
+ visible()
+ onStart?.invoke()
+ }
+ })
+ startAnimation(anim)
+ }
+}
+
+@KauUtils fun View.fadeOut(offset: Long = 0L, duration: Long = 200L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) {
+ if (!isAttachedToWindow) {
+ onStart?.invoke()
+ invisible()
+ onFinish?.invoke()
+ return
+ }
+ val anim = AnimationUtils.loadAnimation(context, android.R.anim.fade_out)
+ anim.startOffset = offset
+ anim.duration = duration
+ anim.setAnimationListener(object : Animation.AnimationListener {
+ override @KauUtils fun onAnimationRepeat(animation: Animation?) {}
+ override @KauUtils fun onAnimationEnd(animation: Animation?) {
+ invisible()
+ onFinish?.invoke()
+ }
+
+ override @KauUtils fun onAnimationStart(animation: Animation?) {
+ onStart?.invoke()
+ }
+ })
+ startAnimation(anim)
+}
+
+@KauUtils fun TextView.setTextWithFade(text: String, duration: Long = 200, onFinish: (() -> Unit)? = null) {
+ fadeOut(duration = duration, onFinish = {
+ setText(text)
+ fadeIn(duration = duration, onFinish = onFinish)
+ })
+}
+
+@KauUtils fun TextView.setTextWithFade(@StringRes textId: Int, duration: Long = 200, onFinish: (() -> Unit)? = null) = setTextWithFade(context.getString(textId), duration, onFinish) \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt
new file mode 100644
index 0000000..8590d6f
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt
@@ -0,0 +1,235 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.support.annotation.ColorInt
+import android.support.annotation.FloatRange
+import android.support.annotation.IntRange
+import android.support.v4.content.ContextCompat
+import android.support.v4.graphics.drawable.DrawableCompat
+import android.support.v7.widget.AppCompatEditText
+import android.support.v7.widget.Toolbar
+import android.widget.*
+import com.afollestad.materialdialogs.R
+
+/**
+ * Created by Allan Wang on 2017-06-08.
+ */
+fun Int.isColorDark(): Boolean
+ = (0.299 * Color.red(this) + 0.587 * Color.green(this) + 0.114 * Color.blue(this)) / 255.0 < 0.5
+
+fun Int.toHexString(withAlpha: Boolean = false, withHexPrefix: Boolean = true): String {
+ val hex = if (withAlpha) String.format("#%08X", this)
+ else String.format("#%06X", 0xFFFFFF and this)
+ return if (withHexPrefix) hex else hex.substring(1)
+}
+
+fun Int.toRgbaString(): String = "rgba(${Color.red(this)}, ${Color.green(this)}, ${Color.blue(this)}, ${(Color.alpha(this) / 255f).round(3)})"
+
+fun Int.toHSV(): FloatArray {
+ val hsv = FloatArray(3)
+ Color.colorToHSV(this, hsv)
+ return hsv
+}
+
+fun FloatArray.toColor(): Int = Color.HSVToColor(this)
+
+fun Int.isColorVisibleOn(@ColorInt color: Int, @IntRange(from = 0L, to = 255L) delta: Int = 25,
+ @IntRange(from = 0L, to = 255L) minAlpha: Int = 50): Boolean =
+ if (Color.alpha(this) < minAlpha) false
+ else !(Math.abs(Color.red(this) - Color.red(color)) < delta
+ && Math.abs(Color.green(this) - Color.green(color)) < delta
+ && Math.abs(Color.blue(this) - Color.blue(color)) < delta)
+
+
+@ColorInt
+fun Context.getDisabledColor(): Int {
+ val primaryColor = resolveColor(android.R.attr.textColorPrimary)
+ val disabledColor = if (primaryColor.isColorDark()) Color.BLACK else Color.WHITE
+ return disabledColor.adjustAlpha(0.3f)
+}
+
+@ColorInt
+fun Int.adjustAlpha(factor: Float): Int {
+ val alpha = Math.round(Color.alpha(this) * factor)
+ return Color.argb(alpha, Color.red(this), Color.green(this), Color.blue(this))
+}
+
+val Int.isColorTransparent: Boolean
+ get() = Color.alpha(this) != 255
+
+@ColorInt
+fun Int.withAlpha(@IntRange(from = 0L, to = 255L) alpha: Int): Int
+ = Color.argb(alpha, Color.red(this), Color.green(this), Color.blue(this))
+
+@ColorInt
+fun Int.withMinAlpha(@IntRange(from = 0L, to = 255L) alpha: Int): Int
+ = Color.argb(Math.max(alpha, Color.alpha(this)), Color.red(this), Color.green(this), Color.blue(this))
+
+@ColorInt
+fun Int.lighten(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int {
+ val (red, green, blue) = intArrayOf(Color.red(this), Color.green(this), Color.blue(this))
+ .map { (it * (1f - factor) + 255f * factor).toInt() }
+ return Color.argb(Color.alpha(this), red, green, blue)
+}
+
+@ColorInt
+fun Int.darken(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int {
+ val (red, green, blue) = intArrayOf(Color.red(this), Color.green(this), Color.blue(this))
+ .map { (it * (1f - factor)).toInt() }
+ return Color.argb(Color.alpha(this), red, green, blue)
+}
+
+@ColorInt
+fun Int.colorToBackground(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int
+ = if (isColorDark()) darken(factor) else lighten(factor)
+
+@ColorInt
+fun Int.colorToForeground(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int
+ = if (isColorDark()) lighten(factor) else darken(factor)
+
+@Throws(IllegalArgumentException::class)
+fun String.toColor(): Int {
+ val toParse: String
+ if (startsWith("#") && length == 4)
+ toParse = "#${this[1]}${this[1]}${this[2]}${this[2]}${this[3]}${this[3]}"
+ else
+ toParse = this
+ return Color.parseColor(toParse)
+}
+
+//Get ColorStateList
+fun Context.colorStateList(@ColorInt color: Int): ColorStateList {
+ val disabledColor = color.adjustAlpha(0.3f)
+ return ColorStateList(arrayOf(intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked),
+ intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked),
+ intArrayOf(-android.R.attr.state_enabled, -android.R.attr.state_checked),
+ intArrayOf(-android.R.attr.state_enabled, android.R.attr.state_checked)),
+ intArrayOf(color.adjustAlpha(0.8f), color, disabledColor, disabledColor))
+}
+
+/*
+ * Tint Helpers
+ * Kotlin tint bindings that start with 'tint' so it doesn't conflict with existing methods
+ * Largely based on MDTintHelper
+ * https://github.com/afollestad/material-dialogs/blob/master/core/src/main/java/com/afollestad/materialdialogs/internal/MDTintHelper.java
+ */
+fun RadioButton.tint(colors: ColorStateList) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ buttonTintList = colors
+ } else {
+ val radioDrawable = context.drawable(R.drawable.abc_btn_radio_material)
+ val d = DrawableCompat.wrap(radioDrawable)
+ DrawableCompat.setTintList(d, colors)
+ buttonDrawable = d
+ }
+}
+
+fun RadioButton.tint(@ColorInt color: Int) = tint(context.colorStateList(color))
+
+fun CheckBox.tint(colors: ColorStateList) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ buttonTintList = colors
+ } else {
+ val checkDrawable = context.drawable(R.drawable.abc_btn_check_material)
+ val drawable = DrawableCompat.wrap(checkDrawable)
+ DrawableCompat.setTintList(drawable, colors)
+ buttonDrawable = drawable
+ }
+}
+
+fun CheckBox.tint(@ColorInt color: Int) = tint(context.colorStateList(color))
+
+fun SeekBar.tint(@ColorInt color: Int) {
+ val s1 = ColorStateList.valueOf(color)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ thumbTintList = s1
+ progressTintList = s1
+ } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.GINGERBREAD_MR1) {
+ val progressDrawable = DrawableCompat.wrap(progressDrawable)
+ this.progressDrawable = progressDrawable
+ DrawableCompat.setTintList(progressDrawable, s1)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ val thumbDrawable = DrawableCompat.wrap(thumb)
+ DrawableCompat.setTintList(thumbDrawable, s1)
+ thumb = thumbDrawable
+ }
+ } else {
+ val mode: PorterDuff.Mode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1)
+ PorterDuff.Mode.MULTIPLY else PorterDuff.Mode.SRC_IN
+ indeterminateDrawable?.setColorFilter(color, mode)
+ progressDrawable?.setColorFilter(color, mode)
+ }
+}
+
+fun ProgressBar.tint(@ColorInt color: Int, skipIndeterminate: Boolean = false) {
+ val sl = ColorStateList.valueOf(color)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ progressTintList = sl
+ secondaryProgressTintList = sl
+ if (!skipIndeterminate) indeterminateTintList = sl
+ } else {
+ val mode: PorterDuff.Mode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1)
+ PorterDuff.Mode.MULTIPLY else PorterDuff.Mode.SRC_IN
+ indeterminateDrawable?.setColorFilter(color, mode)
+ progressDrawable?.setColorFilter(color, mode)
+ }
+}
+
+fun Context.textColorStateList(@ColorInt color: Int): ColorStateList {
+ val states = arrayOf(
+ intArrayOf(-android.R.attr.state_enabled),
+ intArrayOf(-android.R.attr.state_pressed, -android.R.attr.state_focused),
+ intArrayOf()
+ )
+ val colors = intArrayOf(
+ resolveColor(R.attr.colorControlNormal),
+ resolveColor(R.attr.colorControlNormal),
+ color
+ )
+ return ColorStateList(states, colors)
+}
+
+fun EditText.tint(@ColorInt color: Int) {
+ val editTextColorStateList = context.textColorStateList(color)
+ if (this is AppCompatEditText) {
+ supportBackgroundTintList = editTextColorStateList
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ backgroundTintList = editTextColorStateList
+ }
+ tintCursor(color)
+}
+
+fun EditText.tintCursor(@ColorInt color: Int) {
+ try {
+ val fCursorDrawableRes = TextView::class.java.getDeclaredField("mCursorDrawableRes")
+ fCursorDrawableRes.isAccessible = true
+ val mCursorDrawableRes = fCursorDrawableRes.getInt(this)
+ val fEditor = TextView::class.java.getDeclaredField("mEditor")
+ fEditor.isAccessible = true
+ val editor = fEditor.get(this)
+ val clazz = editor.javaClass
+ val fCursorDrawable = clazz.getDeclaredField("mCursorDrawable")
+ fCursorDrawable.isAccessible = true
+ val drawables: Array<Drawable> = Array(2, {
+ val drawable = ContextCompat.getDrawable(context, mCursorDrawableRes)
+ drawable.setColorFilter(color, PorterDuff.Mode.SRC_IN)
+ drawable
+ })
+ fCursorDrawable.set(editor, drawables)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+}
+
+fun Toolbar.tint(@ColorInt color: Int, tintTitle: Boolean = true) {
+ if (tintTitle) {
+ setTitleTextColor(color)
+ setSubtitleTextColor(color)
+ }
+ (0 until childCount).asSequence().forEach { (getChildAt(it) as? ImageButton)?.setColorFilter(color) }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt
new file mode 100644
index 0000000..944caa4
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt
@@ -0,0 +1,6 @@
+package ca.allanwang.kau.utils
+
+/**
+ * Created by Allan Wang on 2017-06-08.
+ */
+const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android" \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt
new file mode 100644
index 0000000..21021e2
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt
@@ -0,0 +1,165 @@
+package ca.allanwang.kau.utils
+
+import android.app.Activity
+import android.app.ActivityOptions
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.net.ConnectivityManager
+import android.net.Uri
+import android.os.Bundle
+import android.support.annotation.*
+import android.support.v4.app.ActivityOptionsCompat
+import android.support.v4.content.ContextCompat
+import android.util.TypedValue
+import android.view.View
+import android.widget.Toast
+import ca.allanwang.kau.R
+import ca.allanwang.kau.logging.KL
+import com.afollestad.materialdialogs.MaterialDialog
+
+
+/**
+ * Created by Allan Wang on 2017-06-03.
+ */
+fun Context.startActivity(
+ clazz: Class<out Activity>,
+ clearStack: Boolean = false,
+ transition: Boolean = false,
+ bundle: Bundle? = null,
+ intentBuilder: Intent.() -> Unit = {}) {
+ val intent = (Intent(this, clazz))
+ if (clearStack) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.intentBuilder()
+ val fullBundle = if (transition && this is Activity) ActivityOptions.makeSceneTransitionAnimation(this).toBundle() else Bundle()
+ if (transition && this !is Activity) KL.d("Cannot make scene transition when context is not an instance of an Activity")
+ if (bundle != null) fullBundle.putAll(bundle)
+ ContextCompat.startActivity(this, intent, if (fullBundle.isEmpty) null else fullBundle)
+ if (this is Activity && clearStack) finish()
+}
+
+/**
+ * Bring in activity from the right
+ */
+fun Context.startActivitySlideIn(clazz: Class<out Activity>, clearStack: Boolean = false, intentBuilder: Intent.() -> Unit = {}, bundleBuilder: Bundle.() -> Unit = {}) {
+ val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.kau_slide_in_right, R.anim.kau_fade_out).toBundle()
+ bundle.bundleBuilder()
+ startActivity(clazz, clearStack, intentBuilder = intentBuilder, bundle = bundle)
+}
+
+/**
+ * Bring in activity from behind while pushing the current activity to the right
+ * This replicates the exit animation of a sliding activity, but is a forward creation
+ * For the animation to work, the previous activity should not be in the stack (otherwise you wouldn't need this in the first place)
+ * Consequently, the stack will be cleared by default
+ */
+fun Context.startActivitySlideOut(clazz: Class<out Activity>, clearStack: Boolean = true, intentBuilder: Intent.() -> Unit = {}, bundleBuilder: Bundle.() -> Unit = {}) {
+ val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.kau_fade_in, R.anim.kau_slide_out_right_top).toBundle()
+ bundle.bundleBuilder()
+ startActivity(clazz, clearStack, intentBuilder = intentBuilder, bundle = bundle)
+}
+
+fun Context.startPlayStoreLink(@StringRes packageIdRes: Int) = startPlayStoreLink(string(packageIdRes))
+
+fun Context.startPlayStoreLink(packageId: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageId")))
+}
+
+/**
+ * Starts a url
+ * If given a series of links, will open the first one that isn't null
+ */
+fun Context.startLink(vararg url: String?) {
+ val link = url.firstOrNull { !it.isNullOrBlank() } ?: return
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(browserIntent)
+}
+
+//Toast helpers
+fun Context.toast(@StringRes id: Int, duration: Int = Toast.LENGTH_LONG) = toast(this.string(id), duration)
+
+fun Context.toast(text: String, duration: Int = Toast.LENGTH_LONG) {
+ Toast.makeText(this, text, duration).show()
+}
+
+//Resource retrievers
+fun Context.string(@StringRes id: Int): String = getString(id)
+
+fun Context.string(@StringRes id: Int, fallback: String?): String? = if (id > 0) string(id) else fallback
+fun Context.string(holder: StringHolder?): String? = holder?.getString(this)
+fun Context.color(@ColorRes id: Int): Int = ContextCompat.getColor(this, id)
+fun Context.integer(@IntegerRes id: Int): Int = resources.getInteger(id)
+fun Context.dimen(@DimenRes id: Int): Float = resources.getDimension(id)
+fun Context.dimenPixelSize(@DimenRes id: Int): Int = resources.getDimensionPixelSize(id)
+fun Context.drawable(@DrawableRes id: Int): Drawable = ContextCompat.getDrawable(this, id)
+fun Context.drawable(@DrawableRes id: Int, fallback: Drawable?): Drawable? = if (id > 0) drawable(id) else fallback
+
+//Attr retrievers
+fun Context.resolveColor(@AttrRes attr: Int, fallback: Int = 0): Int {
+ val a = theme.obtainStyledAttributes(intArrayOf(attr))
+ try {
+ return a.getColor(0, fallback)
+ } finally {
+ a.recycle()
+ }
+}
+
+fun Context.resolveDrawable(@AttrRes attr: Int): Drawable? {
+ val a = theme.obtainStyledAttributes(intArrayOf(attr))
+ try {
+ return a.getDrawable(0)
+ } finally {
+ a.recycle()
+ }
+}
+
+fun Context.resolveBoolean(@AttrRes attr: Int, fallback: Boolean = false): Boolean {
+ val a = theme.obtainStyledAttributes(intArrayOf(attr))
+ try {
+ return a.getBoolean(0, fallback)
+ } finally {
+ a.recycle()
+ }
+}
+
+fun Context.resolveString(@AttrRes attr: Int, fallback: String = ""): String {
+ val v = TypedValue()
+ return if (theme.resolveAttribute(attr, v, true)) v.string.toString() else fallback
+}
+
+/**
+ * Wrapper function for the MaterialDialog adapterBuilder
+ * There is no need to call build() or show() as those are done by default
+ */
+inline fun Context.materialDialog(action: MaterialDialog.Builder.() -> Unit): MaterialDialog {
+ val builder = MaterialDialog.Builder(this)
+ builder.action()
+ return builder.show()
+}
+
+inline val Context.isNetworkAvailable: Boolean
+ get() {
+ val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val activeNetworkInfo = connectivityManager.activeNetworkInfo
+ return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting
+ }
+
+fun Context.getDip(value: Float): Float = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics)
+
+inline val Context.isRtl: Boolean
+ get() = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
+
+/**
+ * Determine if the navigation bar will be on the bottom of the screen, based on logic in
+ * PhoneWindowManager.
+ */
+inline val Context.isNavBarOnBottom: Boolean
+ get() {
+ val cfg = resources.configuration
+ val dm = resources.displayMetrics
+ val canMove = dm.widthPixels != dm.heightPixels && cfg.smallestScreenWidthDp < 600
+ return !canMove || dm.widthPixels < dm.heightPixels
+ }
+
+fun Context.hasPermission(permissions: String) = !buildIsMarshmallowAndUp || ContextCompat.checkSelfPermission(this, permissions) == PackageManager.PERMISSION_GRANTED \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt
new file mode 100644
index 0000000..dab5810
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt
@@ -0,0 +1,32 @@
+package ca.allanwang.kau.utils
+
+/**
+ * Created by Allan Wang on 2017-06-17.
+ *
+ * Courtesy of adelnizamutdinov
+ *
+ * https://github.com/adelnizamutdinov/kotlin-either
+ */
+@Suppress("unused")
+sealed class Either<out L, out R>
+
+data class Left<out T>(val value: T) : Either<T, Nothing>()
+data class Right<out T>(val value: T) : Either<Nothing, T>()
+
+inline fun <L, R, T> Either<L, R>.fold(left: (L) -> T, right: (R) -> T): T =
+ when (this) {
+ is Left -> left(value)
+ is Right -> right(value)
+ }
+
+inline fun <L, R, T> Either<L, R>.flatMap(f: (R) -> Either<L, T>): Either<L, T> =
+ fold({ this as Left }, f)
+
+inline fun <L, R, T> Either<L, R>.map(f: (R) -> T): Either<L, T> =
+ flatMap { Right(f(it)) }
+
+val <T> Either<T, *>.isLeft: Boolean
+ get() = this is Left<T>
+
+val <T> Either<*, T>.isRight: Boolean
+ get() = this is Right<T> \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt
new file mode 100644
index 0000000..3fc509d
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt
@@ -0,0 +1,29 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.graphics.Typeface
+
+/**
+ * Created by Allan Wang on 2017-06-28.
+ */
+object FontUtils {
+
+ val sTypefaceCache: MutableMap<String, Typeface> = mutableMapOf()
+
+ fun get(context: Context, font: String): Typeface {
+ synchronized(sTypefaceCache) {
+ if (!sTypefaceCache.containsKey(font)) {
+ val tf = Typeface.createFromAsset(
+ context.applicationContext.assets, "fonts/$font.ttf")
+ sTypefaceCache.put(font, tf)
+ }
+ return sTypefaceCache.get(font) ?: throw IllegalArgumentException("Font error; typeface does not exist at assets/fonts$font.ttf")
+ }
+ }
+
+ fun getName(typeface: Typeface): String? = sTypefaceCache.entries.firstOrNull { it.value == typeface }?.key
+
+}
+
+fun Context.getFont(font: String) = FontUtils.get(this, font)
+fun Context.getFontName(typeface: Typeface) = FontUtils.getName(typeface) \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt
new file mode 100644
index 0000000..acc71f2
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt
@@ -0,0 +1,12 @@
+package ca.allanwang.kau.utils
+
+import android.support.v4.app.Fragment
+import org.jetbrains.anko.bundleOf
+
+/**
+ * Created by Allan Wang on 2017-07-02.
+ */
+fun <T : Fragment> T.withArguments(vararg params: Pair<String, Any>): T {
+ arguments = bundleOf(*params)
+ return this
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt
new file mode 100644
index 0000000..03a1605
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt
@@ -0,0 +1,20 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.support.annotation.ColorInt
+import com.mikepenz.iconics.IconicsDrawable
+import com.mikepenz.iconics.typeface.IIcon
+
+/**
+ * Created by Allan Wang on 2017-05-29.
+ */
+@KauUtils fun IIcon.toDrawable(c: Context, sizeDp: Int = 24, @ColorInt color: Int = Color.WHITE, builder: IconicsDrawable.() -> Unit = {}): Drawable {
+ val state = ColorStateList.valueOf(color)
+ val icon = IconicsDrawable(c).icon(this).sizeDp(sizeDp)
+ icon.setTintList(state)
+ icon.builder()
+ return icon
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt
new file mode 100644
index 0000000..247bbc7
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt
@@ -0,0 +1,166 @@
+package ca.allanwang.kau.utils
+
+/**
+ * Created by Allan Wang on 2017-05-29.
+ *
+ * Courtesy of Jake Wharton
+ *
+ * https://github.com/JakeWharton/kotterknife/blob/master/src/main/kotlin/kotterknife/ButterKnife.kt
+ */
+import android.app.Activity
+import android.app.Dialog
+import android.app.DialogFragment
+import android.app.Fragment
+import android.support.v7.widget.RecyclerView.ViewHolder
+import android.view.View
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import android.support.v4.app.DialogFragment as SupportDialogFragment
+import android.support.v4.app.Fragment as SupportFragment
+
+fun <V : View> View.bindView(id: Int)
+ : ReadOnlyProperty<View, V> = required(id, viewFinder)
+
+fun <V : View> Activity.bindView(id: Int)
+ : ReadOnlyProperty<Activity, V> = required(id, viewFinder)
+
+fun <V : View> Dialog.bindView(id: Int)
+ : ReadOnlyProperty<Dialog, V> = required(id, viewFinder)
+
+fun <V : View> DialogFragment.bindView(id: Int)
+ : ReadOnlyProperty<DialogFragment, V> = required(id, viewFinder)
+
+fun <V : View> android.support.v4.app.DialogFragment.bindView(id: Int)
+ : ReadOnlyProperty<android.support.v4.app.DialogFragment, V> = required(id, viewFinder)
+
+fun <V : View> Fragment.bindView(id: Int)
+ : ReadOnlyProperty<Fragment, V> = required(id, viewFinder)
+
+fun <V : View> android.support.v4.app.Fragment.bindView(id: Int)
+ : ReadOnlyProperty<android.support.v4.app.Fragment, V> = required(id, viewFinder)
+
+fun <V : View> ViewHolder.bindView(id: Int)
+ : ReadOnlyProperty<ViewHolder, V> = required(id, viewFinder)
+
+fun <V : View> View.bindOptionalView(id: Int)
+ : ReadOnlyProperty<View, V?> = optional(id, viewFinder)
+
+fun <V : View> Activity.bindOptionalView(id: Int)
+ : ReadOnlyProperty<Activity, V?> = optional(id, viewFinder)
+
+fun <V : View> Dialog.bindOptionalView(id: Int)
+ : ReadOnlyProperty<Dialog, V?> = optional(id, viewFinder)
+
+fun <V : View> DialogFragment.bindOptionalView(id: Int)
+ : ReadOnlyProperty<DialogFragment, V?> = optional(id, viewFinder)
+
+fun <V : View> android.support.v4.app.DialogFragment.bindOptionalView(id: Int)
+ : ReadOnlyProperty<android.support.v4.app.DialogFragment, V?> = optional(id, viewFinder)
+
+fun <V : View> Fragment.bindOptionalView(id: Int)
+ : ReadOnlyProperty<Fragment, V?> = optional(id, viewFinder)
+
+fun <V : View> android.support.v4.app.Fragment.bindOptionalView(id: Int)
+ : ReadOnlyProperty<android.support.v4.app.Fragment, V?> = optional(id, viewFinder)
+
+fun <V : View> ViewHolder.bindOptionalView(id: Int)
+ : ReadOnlyProperty<ViewHolder, V?> = optional(id, viewFinder)
+
+fun <V : View> View.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<View, List<V>> = required(ids, viewFinder)
+
+fun <V : View> Activity.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<Activity, List<V>> = required(ids, viewFinder)
+
+fun <V : View> Dialog.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<Dialog, List<V>> = required(ids, viewFinder)
+
+fun <V : View> DialogFragment.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<DialogFragment, List<V>> = required(ids, viewFinder)
+
+fun <V : View> android.support.v4.app.DialogFragment.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<android.support.v4.app.DialogFragment, List<V>> = required(ids, viewFinder)
+
+fun <V : View> Fragment.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<Fragment, List<V>> = required(ids, viewFinder)
+
+fun <V : View> android.support.v4.app.Fragment.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<android.support.v4.app.Fragment, List<V>> = required(ids, viewFinder)
+
+fun <V : View> ViewHolder.bindViews(vararg ids: Int)
+ : ReadOnlyProperty<ViewHolder, List<V>> = required(ids, viewFinder)
+
+fun <V : View> View.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<View, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> Activity.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<Activity, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> Dialog.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<Dialog, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> DialogFragment.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<DialogFragment, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> android.support.v4.app.DialogFragment.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<android.support.v4.app.DialogFragment, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> Fragment.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<Fragment, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> android.support.v4.app.Fragment.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<android.support.v4.app.Fragment, List<V>> = optional(ids, viewFinder)
+
+fun <V : View> ViewHolder.bindOptionalViews(vararg ids: Int)
+ : ReadOnlyProperty<ViewHolder, List<V>> = optional(ids, viewFinder)
+
+private val View.viewFinder: View.(Int) -> View?
+ get() = { findViewById(it) }
+private val Activity.viewFinder: Activity.(Int) -> View?
+ get() = { findViewById(it) }
+private val Dialog.viewFinder: Dialog.(Int) -> View?
+ get() = { findViewById(it) }
+private val DialogFragment.viewFinder: DialogFragment.(Int) -> View?
+ get() = { dialog.findViewById(it) }
+private val android.support.v4.app.DialogFragment.viewFinder: android.support.v4.app.DialogFragment.(Int) -> View?
+ get() = { dialog.findViewById(it) }
+private val Fragment.viewFinder: Fragment.(Int) -> View?
+ get() = { view.findViewById(it) }
+private val android.support.v4.app.Fragment.viewFinder: android.support.v4.app.Fragment.(Int) -> View?
+ get() = { view!!.findViewById(it) }
+private val ViewHolder.viewFinder: ViewHolder.(Int) -> View?
+ get() = { itemView.findViewById(it) }
+
+private fun viewNotFound(id: Int, desc: KProperty<*>): Nothing =
+ throw IllegalStateException("View ID $id for '${desc.name}' not found.")
+
+@Suppress("UNCHECKED_CAST")
+private fun <T, V : View> required(id: Int, finder: T.(Int) -> View?)
+ = Lazy { t: T, desc -> (t.finder(id) as V?)?.apply { } ?: viewNotFound(id, desc) }
+
+@Suppress("UNCHECKED_CAST")
+private fun <T, V : View> optional(id: Int, finder: T.(Int) -> View?)
+ = Lazy { t: T, _ -> t.finder(id) as V? }
+
+@Suppress("UNCHECKED_CAST")
+private fun <T, V : View> required(ids: IntArray, finder: T.(Int) -> View?)
+ = Lazy { t: T, desc -> ids.map { t.finder(it) as V? ?: viewNotFound(it, desc) } }
+
+@Suppress("UNCHECKED_CAST")
+private fun <T, V : View> optional(ids: IntArray, finder: T.(Int) -> View?)
+ = Lazy { t: T, _ -> ids.map { t.finder(it) as V? }.filterNotNull() }
+
+// Like Kotlin's lazy delegate but the initializer gets the target and metadata passed to it
+private class Lazy<T, V>(private val initializer: (T, KProperty<*>) -> V) : ReadOnlyProperty<T, V> {
+ private object EMPTY
+
+ private var value: Any? = EMPTY
+
+ override fun getValue(thisRef: T, property: KProperty<*>): V {
+ if (value == EMPTY) {
+ value = initializer(thisRef, property)
+ }
+ @Suppress("UNCHECKED_CAST")
+ return value as V
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt
new file mode 100644
index 0000000..837c209
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt
@@ -0,0 +1,48 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.support.annotation.RequiresApi
+
+/**
+ * Created by Allan Wang on 2017-06-23.
+ */
+
+/**
+ * Checks if a given package is installed
+ * @param packageName packageId
+ * @return true if installed with activity, false otherwise
+ */
+@KauUtils fun Context.isAppInstalled(packageName: String): Boolean {
+ val pm = packageManager
+ var installed: Boolean
+ try {
+ pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES)
+ installed = true
+ } catch (e: PackageManager.NameNotFoundException) {
+ installed = false
+ }
+ return installed
+}
+
+val buildIsLollipopAndUp: Boolean
+ get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+
+val buildIsMarshmallowAndUp: Boolean
+ get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+
+val buildIsNougatAndUp: Boolean
+ get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
+
+const val INSTALLER_GOOGLE_PLAY_VENDING = "com.android.vending"
+const val INSTALLER_GOOGLE_PLAY_FEEDBACK = "com.google.android.feedback"
+
+val Context.installerPackageName: String?
+ get() = packageManager.getInstallerPackageName(packageName)
+
+val Context.isFromGooglePlay: Boolean
+ get() {
+ val installer = installerPackageName
+ return arrayOf(INSTALLER_GOOGLE_PLAY_FEEDBACK, INSTALLER_GOOGLE_PLAY_VENDING).any { it == installer }
+ } \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt
new file mode 100644
index 0000000..e70a2d1
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt
@@ -0,0 +1,22 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.support.annotation.StringRes
+
+/**
+ * Created by Allan Wang on 2017-06-08.
+ */
+class StringHolder {
+ var text: String? = null
+ var textRes: Int = 0
+
+ constructor(@StringRes textRes: Int) {
+ this.textRes = textRes
+ }
+
+ constructor(text: String) {
+ this.text = text
+ }
+
+ fun getString(context: Context) = context.string(textRes, text)
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt
new file mode 100644
index 0000000..9e668d0
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt
@@ -0,0 +1,19 @@
+package ca.allanwang.kau.utils
+
+import android.support.transition.Transition
+import android.support.transition.TransitionSet
+
+/**
+ * Created by Allan Wang on 2017-06-24.
+ */
+class TransitionEndListener(val onEnd: (transition: Transition) -> Unit) : Transition.TransitionListener {
+ override fun onTransitionEnd(transition: Transition) = onEnd(transition)
+ override fun onTransitionResume(transition: Transition) {}
+ override fun onTransitionPause(transition: Transition) {}
+ override fun onTransitionCancel(transition: Transition) {}
+ override fun onTransitionStart(transition: Transition) {}
+}
+
+@KauUtils fun TransitionSet.addEndListener(onEnd: (transition: Transition) -> Unit) {
+ addListener(TransitionEndListener(onEnd))
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt
new file mode 100644
index 0000000..84794f9
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt
@@ -0,0 +1,111 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.os.Handler
+import android.os.Looper
+import android.support.annotation.IntRange
+import ca.allanwang.kau.R
+import ca.allanwang.kau.logging.KL
+import java.math.RoundingMode
+import java.text.DecimalFormat
+
+
+/**
+ * Created by Allan Wang on 2017-05-28.
+ */
+
+/**
+ * Markers to isolate respective extension @KauUtils functions to their extended class
+ * Avoids having a whole bunch of methods for nested calls
+ */
+@DslMarker
+annotation class KauUtils
+
+@KauUtils val Int.dpToPx: Int
+ get() = (this * Resources.getSystem().displayMetrics.density).toInt()
+
+@KauUtils val Int.pxToDp: Int
+ get() = (this / Resources.getSystem().displayMetrics.density).toInt()
+
+/**
+ * Log whether current state is in the main thread
+ */
+@KauUtils fun checkThread(id: Int) {
+ val status = if (Looper.myLooper() == Looper.getMainLooper()) "is" else "is not"
+ KL.d("$id $status in the main thread")
+}
+
+/**
+ * Converts minute value to string
+ * Whole hours and days will be converted as such, otherwise it will default to x minutes
+ */
+@KauUtils fun Context.minuteToText(minutes: Long): String = with(minutes) {
+ if (this < 0L) string(R.string.kau_none)
+ else if (this == 60L) string(R.string.kau_one_hour)
+ else if (this == 1440L) string(R.string.kau_one_day)
+ else if (this % 1440L == 0L) String.format(string(R.string.kau_x_days), this / 1440L)
+ else if (this % 60L == 0L) String.format(string(R.string.kau_x_hours), this / 60L)
+ else String.format(string(R.string.kau_x_minutes), this)
+}
+
+@KauUtils fun Number.round(@IntRange(from = 1L) decimalCount: Int): String {
+ val expression = StringBuilder().append("#.")
+ (1..decimalCount).forEach { expression.append("#") }
+ val formatter = DecimalFormat(expression.toString())
+ formatter.roundingMode = RoundingMode.HALF_UP
+ return formatter.format(this)
+}
+
+/**
+ * Extracts the bitmap of a drawable, and applies a scale if given
+ * For solid colors, a 1 x 1 pixel will be generated
+ */
+@KauUtils fun Drawable.toBitmap(scaling: Float = 1f, config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap {
+ if (this is BitmapDrawable && bitmap != null) {
+ if (scaling == 1f) return bitmap
+ val width = (bitmap.width * scaling).toInt()
+ val height = (bitmap.height * scaling).toInt()
+ return Bitmap.createScaledBitmap(bitmap, width, height, false)
+ }
+ val bitmap = if (intrinsicWidth <= 0 || intrinsicHeight <= 0)
+ Bitmap.createBitmap(1, 1, config)
+ else
+ Bitmap.createBitmap((intrinsicWidth * scaling).toInt(), (intrinsicHeight * scaling).toInt(), config)
+ val canvas = Canvas(bitmap)
+ setBounds(0, 0, canvas.width, canvas.height)
+ draw(canvas)
+ return bitmap
+}
+
+/**
+ * Use block for autocloseables
+ */
+inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
+ var closed = false
+ try {
+ return block(this)
+ } catch (e: Exception) {
+ closed = true
+ try {
+ close()
+ } catch (closeException: Exception) {
+ e.addSuppressed(closeException)
+ }
+ throw e
+ } finally {
+ if (!closed) {
+ close()
+ }
+ }
+}
+
+fun postDelayed(delay: Long, action: () -> Unit) {
+ Handler().postDelayed(action, delay)
+}
+
+class KauException(message: String) : RuntimeException(message) \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt
new file mode 100644
index 0000000..b4752a5
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt
@@ -0,0 +1,126 @@
+package ca.allanwang.kau.utils
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Outline
+import android.graphics.Rect
+import android.support.annotation.ColorInt
+import android.support.annotation.StringRes
+import android.support.annotation.TransitionRes
+import android.support.design.widget.Snackbar
+import android.support.transition.AutoTransition
+import android.support.transition.Transition
+import android.support.transition.TransitionInflater
+import android.support.transition.TransitionManager
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewOutlineProvider
+import android.view.inputmethod.InputMethodManager
+import android.widget.ImageView
+import android.widget.TextView
+import ca.allanwang.kau.logging.KL
+import ca.allanwang.kau.views.createSimpleRippleDrawable
+import com.mikepenz.iconics.IconicsDrawable
+import com.mikepenz.iconics.typeface.IIcon
+
+
+/**
+ * Created by Allan Wang on 2017-05-31.
+ */
+@KauUtils fun <T : View> T.visible(): T {
+ visibility = View.VISIBLE
+ return this
+}
+
+@KauUtils fun <T : View> T.invisible(): T {
+ visibility = View.INVISIBLE
+ return this
+}
+
+@KauUtils fun <T : View> T.gone(): T {
+ visibility = View.GONE
+ return this
+}
+
+@KauUtils fun View.isVisible(): Boolean = visibility == View.VISIBLE
+@KauUtils fun View.isInvisible(): Boolean = visibility == View.INVISIBLE
+@KauUtils fun View.isGone(): Boolean = visibility == View.GONE
+
+fun View.snackbar(text: String, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {}): Snackbar {
+ val snackbar = Snackbar.make(this, text, duration)
+ snackbar.builder()
+ snackbar.show()
+ return snackbar
+}
+
+fun View.snackbar(@StringRes textId: Int, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {})
+ = snackbar(context.string(textId), duration, builder)
+
+@KauUtils fun TextView.setTextIfValid(@StringRes id: Int) {
+ if (id > 0) text = context.string(id)
+}
+
+@KauUtils fun ImageView.setIcon(icon: IIcon?, sizeDp: Int = 24, @ColorInt color: Int = Color.WHITE, builder: IconicsDrawable.() -> Unit = {}) {
+ if (icon == null) return
+ setImageDrawable(icon.toDrawable(context, sizeDp = sizeDp, color = color, builder = builder))
+}
+
+@KauUtils fun View.hideKeyboard() {
+ clearFocus()
+ (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(windowToken, 0)
+}
+
+@KauUtils fun View.showKeyboard() {
+ requestFocus()
+ (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
+}
+
+@KauUtils fun ViewGroup.transitionAuto(builder: AutoTransition.() -> Unit = {}) {
+ val transition = AutoTransition()
+ transition.builder()
+ TransitionManager.beginDelayedTransition(this, transition)
+}
+
+@KauUtils fun ViewGroup.transitionDelayed(@TransitionRes id: Int, builder: Transition.() -> Unit = {}) {
+ val transition = TransitionInflater.from(context).inflateTransition(id)
+ transition.builder()
+ TransitionManager.beginDelayedTransition(this, transition)
+}
+
+@KauUtils fun View.setRippleBackground(@ColorInt foregroundColor: Int, @ColorInt backgroundColor: Int) {
+ background = createSimpleRippleDrawable(foregroundColor, backgroundColor)
+}
+
+@KauUtils val View.parentViewGroup: ViewGroup
+ get() = parent as ViewGroup
+
+@KauUtils val View.parentVisibleHeight: Int
+ get() {
+ val r = Rect()
+ parentViewGroup.getWindowVisibleDisplayFrame(r)
+ return r.height()
+ }
+
+val CIRCULAR_OUTLINE: ViewOutlineProvider = object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ KL.d("CIRCULAR OUTLINE")
+ outline.setOval(view.paddingLeft,
+ view.paddingTop,
+ view.width - view.paddingRight,
+ view.height - view.paddingBottom)
+ }
+}
+
+/**
+ * Generates a recycler view with match parent and a linearlayoutmanager, since it's so commonly used
+ */
+fun Context.fullLinearRecycler(rvAdapter: RecyclerView.Adapter<*>? = null, configs: RecyclerView.() -> Unit = {}): RecyclerView {
+ return RecyclerView(this).apply {
+ layoutManager = LinearLayoutManager(this@fullLinearRecycler)
+ layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT)
+ if (rvAdapter != null) adapter = rvAdapter
+ configs()
+ }
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt b/core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt
new file mode 100644
index 0000000..0cb65d0
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt
@@ -0,0 +1,50 @@
+package ca.allanwang.kau.views
+
+import android.content.Context
+import android.graphics.Rect
+import android.support.v7.widget.CardView
+import android.util.AttributeSet
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.parentViewGroup
+import ca.allanwang.kau.utils.parentVisibleHeight
+
+
+/**
+ * Created by Allan Wang on 2017-06-26.
+ *
+ * CardView with a limited height
+ * This view should be used with wrap_content as its height
+ * Defaults to at most the parent's visible height
+ */
+class BoundedCardView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : CardView(context, attrs, defStyleAttr) {
+
+ /**
+ * Maximum height possible, defined in dp (will be converted to px)
+ * Defaults to parent's visible height
+ */
+ var maxHeight: Int = -1
+ /**
+ * Percentage of resulting max height to fill
+ * Negative value = fill all of maxHeight
+ */
+ var maxHeightPercent: Float = -1.0f
+
+ init {
+ if (attrs != null) {
+ val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.BoundedCardView)
+ maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.BoundedCardView_maxHeight, -1)
+ maxHeightPercent = styledAttrs.getFloat(R.styleable.BoundedCardView_maxHeightPercent, -1.0f)
+ styledAttrs.recycle()
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ var maxMeasureHeight = if (maxHeight > 0) maxHeight else parentVisibleHeight
+ if (maxHeightPercent > 0f) maxMeasureHeight = (maxMeasureHeight * maxHeightPercent).toInt()
+ val trueHeightMeasureSpec = MeasureSpec.makeMeasureSpec(maxMeasureHeight, MeasureSpec.AT_MOST)
+ super.onMeasure(widthMeasureSpec, trueHeightMeasureSpec)
+ }
+
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt b/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt
new file mode 100644
index 0000000..023bdb4
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2015 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ca.allanwang.kau.views
+
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import android.text.TextPaint
+import android.util.AttributeSet
+import android.util.DisplayMetrics
+import android.util.TypedValue
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.dimenPixelSize
+import ca.allanwang.kau.utils.getFont
+import ca.allanwang.kau.utils.parentVisibleHeight
+import ca.allanwang.kau.utils.toBitmap
+
+/**
+ * A view which punches out some text from an opaque color block, allowing you to see through it.
+ */
+class CutoutView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val PHI = 1.6182f
+ const val TYPE_TEXT = 100
+ const val TYPE_DRAWABLE = 101
+ }
+
+ private val paint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
+ private var bitmapScaling: Float = 1f
+ private var cutout: Bitmap? = null
+ var foregroundColor = Color.MAGENTA
+ var text: String? = "Text"
+ set(value) {
+ field = value
+ if (value != null) cutoutType = TYPE_TEXT
+ else if (drawable != null) cutoutType = TYPE_DRAWABLE
+ }
+ var cutoutType: Int = TYPE_TEXT
+ private var textSize: Float = 0f
+ private var cutoutY: Float = 0f
+ private var cutoutX: Float = 0f
+ var drawable: Drawable? = null
+ set(value) {
+ field = value
+ if (value != null) cutoutType = TYPE_DRAWABLE
+ else if (text != null) cutoutType = TYPE_TEXT
+ }
+ private var heightPercentage: Float = 0f
+ private var minHeight: Float = 0f
+ private val maxTextSize: Float
+
+ init {
+ if (attrs != null) {
+ val a = context.obtainStyledAttributes(attrs, R.styleable.CutoutView, 0, 0)
+ if (a.hasValue(R.styleable.CutoutView_font))
+ paint.typeface = context.getFont(a.getString(R.styleable.CutoutView_font))
+ foregroundColor = a.getColor(R.styleable.CutoutView_foregroundColor, foregroundColor)
+ text = a.getString(R.styleable.CutoutView_android_text) ?: text
+ minHeight = a.getDimension(R.styleable.CutoutView_android_minHeight, minHeight)
+ heightPercentage = a.getFloat(R.styleable.CutoutView_heightPercentageToScreen, heightPercentage)
+ a.recycle()
+ }
+ maxTextSize = context.dimenPixelSize(R.dimen.kau_display_4_text_size).toFloat()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ calculatePosition()
+ createBitmap()
+ }
+
+ private fun calculatePosition() {
+ when (cutoutType) {
+ TYPE_TEXT -> calculateTextPosition()
+ TYPE_DRAWABLE -> calculateImagePosition()
+ }
+ }
+
+ private fun calculateTextPosition() {
+ val targetWidth = width / PHI
+ textSize = getSingleLineTextSize(text!!, paint, targetWidth, 0f, maxTextSize,
+ 0.5f, resources.displayMetrics)
+ paint.textSize = textSize
+
+ // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/
+ cutoutX = (width - paint.measureText(text)) / 2
+ val textBounds = Rect()
+ paint.getTextBounds(text, 0, text!!.length, textBounds)
+ val textHeight = textBounds.height().toFloat()
+ cutoutY = (height + textHeight) / 2
+ }
+
+ private fun calculateImagePosition() {
+ if (drawable!!.intrinsicHeight <= 0 || drawable!!.intrinsicWidth <= 0) throw IllegalArgumentException("Drawable's intrinsic size cannot be less than 0")
+ val targetWidth = width / PHI
+ val targetHeight = height / PHI
+ bitmapScaling = Math.min(targetHeight / drawable!!.intrinsicHeight, targetWidth / drawable!!.intrinsicWidth)
+ cutoutX = (width - drawable!!.intrinsicWidth * bitmapScaling) / 2
+ cutoutY = (height - drawable!!.intrinsicHeight * bitmapScaling) / 2
+ }
+
+ /**
+ * If height percent is specified, ensure it is met
+ */
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val minHeight = Math.max(minHeight, heightPercentage * parentVisibleHeight)
+ val trueHeightMeasureSpec = if (minHeight > 0)
+ MeasureSpec.makeMeasureSpec(Math.max(minHeight.toInt(), measuredHeight), MeasureSpec.EXACTLY)
+ else heightMeasureSpec
+ super.onMeasure(widthMeasureSpec, trueHeightMeasureSpec)
+ }
+
+ /**
+ * Recursive binary search to find the best size for the text.
+
+ * Adapted from https://github.com/grantland/android-autofittextview
+ */
+ fun getSingleLineTextSize(text: String,
+ paint: TextPaint,
+ targetWidth: Float,
+ low: Float,
+ high: Float,
+ precision: Float,
+ metrics: DisplayMetrics): Float {
+ val mid = (low + high) / 2.0f
+
+ paint.textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mid, metrics)
+ val maxLineWidth = paint.measureText(text)
+
+ return if (high - low < precision) low
+ else if (maxLineWidth > targetWidth) getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics)
+ else if (maxLineWidth < targetWidth) getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics)
+ else mid
+ }
+
+ private fun createBitmap() {
+ if (!(cutout?.isRecycled ?: true))
+ cutout?.recycle()
+ if (width == 0 || height == 0) return
+ cutout = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ cutout!!.setHasAlpha(true)
+ val cutoutCanvas = Canvas(cutout!!)
+ cutoutCanvas.drawColor(foregroundColor)
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
+
+ when (cutoutType) {
+ TYPE_TEXT -> {
+ // this is the magic – Clear mode punches out the bitmap
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
+ cutoutCanvas.drawText(text, cutoutX, cutoutY, paint)
+ }
+ TYPE_DRAWABLE -> {
+ cutoutCanvas.drawBitmap(drawable!!.toBitmap(bitmapScaling, Bitmap.Config.ALPHA_8), cutoutX, cutoutY, paint)
+ }
+
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ canvas.drawBitmap(cutout!!, 0f, 0f, null)
+ }
+
+ override fun hasOverlappingRendering(): Boolean = true
+
+}
diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt b/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt
new file mode 100644
index 0000000..805fb21
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt
@@ -0,0 +1,140 @@
+package ca.allanwang.kau.views
+
+import android.animation.ArgbEvaluator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import ca.allanwang.kau.utils.adjustAlpha
+
+/**
+ * Created by Allan Wang on 2016-11-17.
+ *
+ *
+ * Canvas drawn ripples that keep the previous color
+ * Extends to view dimensions
+ * Supports multiple ripples from varying locations
+ */
+class RippleCanvas @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+ private val paint: Paint = Paint()
+ private var baseColor = Color.TRANSPARENT
+ private val ripples: MutableList<Ripple> = mutableListOf()
+
+ init {
+ paint.isAntiAlias = true
+ paint.style = Paint.Style.FILL
+ }
+
+ /**
+ * Drawing the ripples involves having access to the next layer if it exists,
+ * and using its values to decide on the current color.
+ * If the next layer requests a fade, we will adjust the alpha of our current layer before drawing.
+ * Otherwise we will just draw the color as intended
+ */
+ override fun onDraw(canvas: Canvas) {
+ val itr = ripples.listIterator()
+ if (!itr.hasNext()) return canvas.drawColor(baseColor)
+ var next = itr.next()
+ canvas.drawColor(colorToDraw(baseColor, next.fade, next.radius, next.maxRadius))
+ var last = false
+ while (!last) {
+ val current = next
+ if (itr.hasNext()) next = itr.next()
+ else last = true
+ //We may fade any layer except for the last one
+ paint.color = colorToDraw(current.color, next.fade && !last, next.radius, next.maxRadius)
+ canvas.drawCircle(current.x, current.y, current.radius, paint)
+ if (current.radius == current.maxRadius) {
+ if (!last) {
+ itr.previous()
+ itr.remove()
+ itr.next()
+ } else {
+ itr.remove()
+ }
+ baseColor = current.color
+ }
+ }
+ }
+
+ /**
+ * Given our current color and next layer's radius & max,
+ * we will decide on the alpha of our current layer
+ */
+ internal fun colorToDraw(color: Int, fade: Boolean, current: Float, goal: Float): Int {
+ if (!fade || (current / goal <= FADE_PIVOT)) return color
+ val factor = (goal - current) / (goal - FADE_PIVOT * goal)
+ return color.adjustAlpha(factor)
+ }
+
+ /**
+ * Creates a ripple effect from the given starting values
+ * [fade] will gradually transition previous ripples to a transparent color so the resulting background is what we want
+ * this is typically only necessary if the ripple color has transparency
+ */
+ fun ripple(color: Int, startX: Float = 0f, startY: Float = 0f, duration: Long = 600L, fade: Boolean = Color.alpha(color) != 255) {
+ val w = width.toFloat()
+ val h = height.toFloat()
+ val x = when (startX) {
+ MIDDLE -> w / 2
+ END -> w
+ else -> startX
+ }
+ val y = when (startY) {
+ MIDDLE -> h / 2
+ END -> h
+ else -> startY
+ }
+ val maxRadius = Math.hypot(Math.max(x, w - x).toDouble(), Math.max(y, h - y).toDouble()).toFloat()
+ val ripple = Ripple(color, x, y, 0f, maxRadius, fade)
+ ripples.add(ripple)
+ val animator = ValueAnimator.ofFloat(0f, maxRadius)
+ animator.duration = duration
+ animator.addUpdateListener { animation ->
+ ripple.radius = animation.animatedValue as Float
+ invalidate()
+ }
+ animator.start()
+ }
+
+ /**
+ * Sets a color directly; clears ripple queue if it exists
+ */
+ fun set(color: Int) {
+ baseColor = color
+ ripples.clear()
+ invalidate()
+ }
+
+ /**
+ * Sets a color directly but with a transition
+ */
+ fun fade(color: Int, duration: Long = 300L) {
+ ripples.clear()
+ val animator = ValueAnimator.ofObject(ArgbEvaluator(), baseColor, color)
+ animator.duration = duration
+ animator.addUpdateListener { animation ->
+ baseColor = animation.animatedValue as Int
+ invalidate()
+ }
+ animator.start()
+ }
+
+ internal class Ripple(val color: Int,
+ val x: Float,
+ val y: Float,
+ var radius: Float,
+ val maxRadius: Float,
+ val fade: Boolean)
+
+ companion object {
+ const val MIDDLE = -1.0f
+ const val END = -2.0f
+ const val FADE_PIVOT = 0.5f
+ }
+}
diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt b/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt
new file mode 100644
index 0000000..df842f6
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt
@@ -0,0 +1,19 @@
+package ca.allanwang.kau.views
+
+import android.content.res.ColorStateList
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.RippleDrawable
+import android.support.annotation.ColorInt
+import ca.allanwang.kau.utils.adjustAlpha
+
+/**
+ * Created by Allan Wang on 2017-06-24.
+ *
+ * Tries to mimic a standard ripple, given the foreground and background colors
+ */
+fun createSimpleRippleDrawable(@ColorInt foregroundColor: Int, @ColorInt backgroundColor: Int): RippleDrawable {
+ val states = ColorStateList(arrayOf(intArrayOf()), intArrayOf(foregroundColor))
+ val content = ColorDrawable(backgroundColor)
+ val mask = ColorDrawable(foregroundColor.adjustAlpha(0.16f))
+ return RippleDrawable(states, content, mask)
+} \ No newline at end of file
diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt b/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt
new file mode 100644
index 0000000..38c99c3
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2015 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ca.allanwang.kau.widgets
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.view.View
+import android.widget.FrameLayout
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.*
+
+/**
+ * A [FrameLayout] which responds to nested scrolls to create drag-dismissable layouts.
+ * Applies an elasticity factor to reduce movement as you approach the given dismiss distance.
+ * Optionally also scales down content during drag.
+ */
+class ElasticDragDismissFrameLayout @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ // configurable attribs
+ private var dragDismissDistance = Float.MAX_VALUE
+ private var dragDismissFraction = -1f
+ private var dragDismissScale = 1f
+ private var shouldScale = false
+ private var dragElacticity = 0.8f
+
+ // state
+ private var totalDrag: Float = 0f
+ private var draggingDown = false
+ private var draggingUp = false
+
+ private var callbacks: MutableList<ElasticDragDismissCallback> = mutableListOf()
+
+ init {
+ if (attrs != null) {
+ val a = getContext().obtainStyledAttributes(attrs, R.styleable.ElasticDragDismissFrameLayout, 0, 0)
+ dragDismissDistance = a.getDimensionPixelSize(R.styleable.ElasticDragDismissFrameLayout_dragDismissDistance, Int.MAX_VALUE).toFloat()
+ dragDismissFraction = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissFraction, dragDismissFraction)
+ dragDismissScale = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissScale, dragDismissScale)
+ shouldScale = dragDismissScale != 1f
+ dragElacticity = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragElasticity, dragElacticity)
+ a.recycle()
+ }
+ }
+
+ abstract class ElasticDragDismissCallback {
+
+ /**
+ * Called for each drag event.
+
+ * @param elasticOffset Indicating the drag offset with elasticity applied i.e. may
+ * * exceed 1.
+ * *
+ * @param elasticOffsetPixels The elastically scaled drag distance in pixels.
+ * *
+ * @param rawOffset Value from [0, 1] indicating the raw drag offset i.e.
+ * * without elasticity applied. A value of 1 indicates that the
+ * * dismiss distance has been reached.
+ * *
+ * @param rawOffsetPixels The raw distance the user has dragged
+ */
+ internal open fun onDrag(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ }
+
+ /**
+ * Called when dragging is released and has exceeded the threshold dismiss distance.
+ */
+ internal open fun onDragDismissed() {}
+
+ }
+
+ override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
+ return nestedScrollAxes and View.SCROLL_AXIS_VERTICAL != 0
+ }
+
+ override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
+ // if we're in a drag gesture and the user reverses up the we should take those events
+ if (draggingDown && dy > 0 || draggingUp && dy < 0) {
+ dragScale(dy)
+ consumed[1] = dy
+ }
+ }
+
+ override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
+ dxUnconsumed: Int, dyUnconsumed: Int) {
+ dragScale(dyUnconsumed)
+ }
+
+ override fun onStopNestedScroll(child: View) {
+ if (Math.abs(totalDrag) >= dragDismissDistance) {
+ dispatchDismissCallback()
+ } else { // settle back to natural position
+ animate()
+ .translationY(0f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(200L)
+ .setInterpolator(AnimHolder.fastOutSlowInInterpolator(context))
+ .setListener(null)
+ .start()
+ totalDrag = 0f
+ draggingUp = false
+ draggingDown = draggingUp
+ dispatchDragCallback(0f, 0f, 0f, 0f)
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ if (dragDismissFraction > 0f) {
+ dragDismissDistance = h * dragDismissFraction
+ }
+ }
+
+ fun addListener(listener: ElasticDragDismissCallback) {
+ callbacks.add(listener)
+ }
+
+ fun removeListener(listener: ElasticDragDismissCallback) {
+ callbacks.remove(listener)
+ }
+
+ private fun dragScale(scroll: Int) {
+ if (scroll == 0) return
+
+ totalDrag += scroll.toFloat()
+
+ // track the direction & set the pivot point for scaling
+ // don't double track i.e. if start dragging down and then reverse, keep tracking as
+ // dragging down until they reach the 'natural' position
+ if (scroll < 0 && !draggingUp && !draggingDown) {
+ draggingDown = true
+ if (shouldScale) pivotY = height.toFloat()
+ } else if (scroll > 0 && !draggingDown && !draggingUp) {
+ draggingUp = true
+ if (shouldScale) pivotY = 0f
+ }
+ // how far have we dragged relative to the distance to perform a dismiss
+ // (0–1 where 1 = dismiss distance). Decreasing logarithmically as we approach the limit
+ var dragFraction = Math.log10((1 + Math.abs(totalDrag) / dragDismissDistance).toDouble()).toFloat()
+
+ // calculate the desired translation given the drag fraction
+ var dragTo = dragFraction * dragDismissDistance * dragElacticity
+
+ if (draggingUp) {
+ // as we use the absolute magnitude when calculating the drag fraction, need to
+ // re-apply the drag direction
+ dragTo *= -1f
+ }
+ translationY = dragTo
+
+ if (shouldScale) {
+ val scale = 1 - (1 - dragDismissScale) * dragFraction
+ scaleX = scale
+ scaleY = scale
+ }
+
+ // if we've reversed direction and gone past the settle point then clear the flags to
+ // allow the list to get the scroll events & reset any transforms
+ if (draggingDown && totalDrag >= 0 || draggingUp && totalDrag <= 0) {
+ dragFraction = 0f
+ dragTo = dragFraction
+ totalDrag = dragTo
+ draggingUp = false
+ draggingDown = draggingUp
+ translationY = 0f
+ scaleX = 1f
+ scaleY = 1f
+ }
+ dispatchDragCallback(dragFraction, dragTo,
+ Math.min(1f, Math.abs(totalDrag) / dragDismissDistance), totalDrag)
+ }
+
+ private fun dispatchDragCallback(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ callbacks.forEach {
+ it.onDrag(elasticOffset, elasticOffsetPixels,
+ rawOffset, rawOffsetPixels)
+ }
+ }
+
+ private fun dispatchDismissCallback() {
+ callbacks.forEach { it.onDragDismissed() }
+ }
+
+ /**
+ * An [ElasticDragDismissCallback] which fades system chrome (i.e. status bar and
+ * navigation bar) whilst elastic drags are performed and
+ * [finishes][Activity.finishAfterTransition] the activity when drag dismissed.
+ */
+ open class SystemChromeFader(private val activity: Activity) : ElasticDragDismissCallback() {
+ private val statusBarAlpha: Int = Color.alpha(activity.statusBarColor)
+ private val navBarAlpha: Int = Color.alpha(activity.navigationBarColor)
+ private val fadeNavBar: Boolean = activity.isNavBarOnBottom
+
+ public override fun onDrag(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ if (elasticOffsetPixels > 0) {
+ // dragging downward, fade the status bar in proportion
+ activity.statusBarColor = activity.statusBarColor.withAlpha(((1f - rawOffset) * statusBarAlpha).toInt())
+ } else if (elasticOffsetPixels == 0f) {
+ // reset
+ activity.statusBarColor = activity.statusBarColor.withAlpha(statusBarAlpha)
+ activity.navigationBarColor = activity.navigationBarColor.withAlpha(navBarAlpha)
+ } else if (fadeNavBar) {
+ // dragging upward, fade the navigation bar in proportion
+ activity.navigationBarColor = activity.navigationBarColor.withAlpha(((1f - rawOffset) * navBarAlpha).toInt())
+ }
+ }
+
+ public override fun onDragDismissed() {
+ activity.finishAfterTransition()
+ }
+ }
+
+}
diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java b/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java
new file mode 100644
index 0000000..78e915d
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java
@@ -0,0 +1,859 @@
+/*
+ * Copyright 2015 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ca.allanwang.kau.widgets;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.support.annotation.ColorInt;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.animation.Interpolator;
+
+import java.util.Arrays;
+
+import ca.allanwang.kau.R;
+import ca.allanwang.kau.utils.AnimHolder;
+import ca.allanwang.kau.utils.ColorUtilsKt;
+
+/**
+ * An ink inspired widget for indicating pages in a {@link ViewPager}.
+ */
+public class InkPageIndicator extends View implements ViewPager.OnPageChangeListener,
+ View.OnAttachStateChangeListener {
+
+ // defaults
+ private static final int DEFAULT_DOT_SIZE = 8; // dp
+ private static final int DEFAULT_GAP = 12; // dp
+ private static final int DEFAULT_ANIM_DURATION = 400; // ms
+ private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white
+ private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white
+
+ // constants
+ private static final float INVALID_FRACTION = -1f;
+ private static final float MINIMAL_REVEAL = 0.00001f;
+
+ // configurable attributes
+ private int dotDiameter;
+ private int gap;
+ private long animDuration;
+ private int unselectedColour;
+ private int selectedColour;
+
+ public void setColour(@ColorInt int color) {
+ selectedColour = color;
+ unselectedColour = ColorUtilsKt.adjustAlpha(color, 0.5f);
+ selectedPaint.setColor(selectedColour);
+ unselectedPaint.setColor(unselectedColour);
+ }
+
+ // derived from attributes
+ private float dotRadius;
+ private float halfDotRadius;
+ private long animHalfDuration;
+ private float dotTopY;
+ private float dotCenterY;
+ private float dotBottomY;
+
+ // ViewPager
+ private ViewPager viewPager;
+
+ // state
+ private int pageCount;
+ private int currentPage;
+ private int previousPage;
+ private float selectedDotX;
+ private boolean selectedDotInPosition;
+ private float[] dotCenterX;
+ private float[] joiningFractions;
+ private float retreatingJoinX1;
+ private float retreatingJoinX2;
+ private float[] dotRevealFractions;
+ private boolean isAttachedToWindow;
+ private boolean pageChanging;
+
+ // drawing
+ private final Paint unselectedPaint;
+ private final Paint selectedPaint;
+ private final Path combinedUnselectedPath;
+ private final Path unselectedDotPath;
+ private final Path unselectedDotLeftPath;
+ private final Path unselectedDotRightPath;
+ private final RectF rectF;
+
+ // animation
+ private ValueAnimator moveAnimation;
+ private AnimatorSet joiningAnimationSet;
+ private PendingRetreatAnimator retreatAnimation;
+ private PendingRevealAnimator[] revealAnimations;
+ private final Interpolator interpolator;
+
+ // working values for beziers
+ float endX1;
+ float endY1;
+ float endX2;
+ float endY2;
+ float controlX1;
+ float controlY1;
+ float controlX2;
+ float controlY2;
+
+ public InkPageIndicator(Context context) {
+ this(context, null, 0);
+ }
+
+ public InkPageIndicator(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public InkPageIndicator(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ final int density = (int) context.getResources().getDisplayMetrics().density;
+
+ // Load attributes
+ final TypedArray a = getContext().obtainStyledAttributes(
+ attrs, R.styleable.InkPageIndicator, defStyle, 0);
+
+ dotDiameter = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotDiameter,
+ DEFAULT_DOT_SIZE * density);
+ dotRadius = dotDiameter / 2;
+ halfDotRadius = dotRadius / 2;
+ gap = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotGap,
+ DEFAULT_GAP * density);
+ animDuration = (long) a.getInteger(R.styleable.InkPageIndicator_animationDuration,
+ DEFAULT_ANIM_DURATION);
+ animHalfDuration = animDuration / 2;
+ unselectedColour = a.getColor(R.styleable.InkPageIndicator_pageIndicatorColor,
+ DEFAULT_UNSELECTED_COLOUR);
+ selectedColour = a.getColor(R.styleable.InkPageIndicator_currentPageIndicatorColor,
+ DEFAULT_SELECTED_COLOUR);
+
+ a.recycle();
+
+ unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ unselectedPaint.setColor(unselectedColour);
+ selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ selectedPaint.setColor(selectedColour);
+ interpolator = AnimHolder.INSTANCE.getFastOutSlowInInterpolator().invoke(context);
+
+ // create paths & rect now – reuse & rewind later
+ combinedUnselectedPath = new Path();
+ unselectedDotPath = new Path();
+ unselectedDotLeftPath = new Path();
+ unselectedDotRightPath = new Path();
+ rectF = new RectF();
+
+ addOnAttachStateChangeListener(this);
+ }
+
+ public void setViewPager(ViewPager viewPager) {
+ this.viewPager = viewPager;
+ viewPager.addOnPageChangeListener(this);
+ setPageCount(viewPager.getAdapter().getCount());
+ viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() {
+ @Override
+ public void onChanged() {
+ setPageCount(InkPageIndicator.this.viewPager.getAdapter().getCount());
+ }
+ });
+ setCurrentPageImmediate();
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (isAttachedToWindow) {
+ float fraction = positionOffset;
+ int currentPosition = pageChanging ? previousPage : currentPage;
+ int leftDotPosition = position;
+ // when swiping from #2 to #1 ViewPager reports position as 1 and a descending offset
+ // need to convert this into our left-dot-based 'coordinate space'
+ if (currentPosition != position) {
+ fraction = 1f - positionOffset;
+
+ // if user scrolls completely to next page then the position param updates to that
+ // new page but we're not ready to switch our 'current' page yet so adjust for that
+ if (fraction == 1f) {
+ leftDotPosition = Math.min(currentPosition, position);
+ }
+ }
+ setJoiningFraction(leftDotPosition, fraction);
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ if (isAttachedToWindow) {
+ // this is the main event we're interested in!
+ setSelectedPage(position);
+ } else {
+ // when not attached, don't animate the move, just store immediately
+ setCurrentPageImmediate();
+ }
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ // nothing to do
+ }
+
+ private void setPageCount(int pages) {
+ pageCount = pages;
+ resetState();
+ requestLayout();
+ }
+
+ private void calculateDotPositions(int width, int height) {
+ int left = getPaddingLeft();
+ int top = getPaddingTop();
+ int right = width - getPaddingRight();
+ int bottom = height - getPaddingBottom();
+
+ int requiredWidth = getRequiredWidth();
+ float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius;
+
+ dotCenterX = new float[pageCount];
+ for (int i = 0; i < pageCount; i++) {
+ dotCenterX[i] = startLeft + i * (dotDiameter + gap);
+ }
+ // todo just top aligning for now… should make this smarter
+ dotTopY = top;
+ dotCenterY = top + dotRadius;
+ dotBottomY = top + dotDiameter;
+
+ setCurrentPageImmediate();
+ }
+
+ private void setCurrentPageImmediate() {
+ if (viewPager != null) {
+ currentPage = viewPager.getCurrentItem();
+ } else {
+ currentPage = 0;
+ }
+ if (dotCenterX != null) {
+ selectedDotX = dotCenterX[currentPage];
+ }
+ }
+
+ private void resetState() {
+ joiningFractions = new float[pageCount - 1];
+ Arrays.fill(joiningFractions, 0f);
+ dotRevealFractions = new float[pageCount];
+ Arrays.fill(dotRevealFractions, 0f);
+ retreatingJoinX1 = INVALID_FRACTION;
+ retreatingJoinX2 = INVALID_FRACTION;
+ selectedDotInPosition = true;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+
+ int desiredHeight = getDesiredHeight();
+ int height;
+ switch (MeasureSpec.getMode(heightMeasureSpec)) {
+ case MeasureSpec.EXACTLY:
+ height = MeasureSpec.getSize(heightMeasureSpec);
+ break;
+ case MeasureSpec.AT_MOST:
+ height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec));
+ break;
+ case MeasureSpec.UNSPECIFIED:
+ default:
+ height = desiredHeight;
+ break;
+ }
+
+ int desiredWidth = getDesiredWidth();
+ int width;
+ switch (MeasureSpec.getMode(widthMeasureSpec)) {
+ case MeasureSpec.EXACTLY:
+ width = MeasureSpec.getSize(widthMeasureSpec);
+ break;
+ case MeasureSpec.AT_MOST:
+ width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec));
+ break;
+ case MeasureSpec.UNSPECIFIED:
+ default:
+ width = desiredWidth;
+ break;
+ }
+ setMeasuredDimension(width, height);
+ calculateDotPositions(width, height);
+ }
+
+ private int getDesiredHeight() {
+ return getPaddingTop() + dotDiameter + getPaddingBottom();
+ }
+
+ private int getRequiredWidth() {
+ return pageCount * dotDiameter + (pageCount - 1) * gap;
+ }
+
+ private int getDesiredWidth() {
+ return getPaddingLeft() + getRequiredWidth() + getPaddingRight();
+ }
+
+ @Override
+ public void onViewAttachedToWindow(View view) {
+ isAttachedToWindow = true;
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View view) {
+ isAttachedToWindow = false;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (viewPager == null || pageCount == 0) return;
+ drawUnselected(canvas);
+ drawSelected(canvas);
+ }
+
+ private void drawUnselected(Canvas canvas) {
+
+ combinedUnselectedPath.rewind();
+
+ // draw any settled, revealing or joining dots
+ for (int page = 0; page < pageCount; page++) {
+ int nextXIndex = page == pageCount - 1 ? page : page + 1;
+ combinedUnselectedPath.op(getUnselectedPath(page,
+ dotCenterX[page],
+ dotCenterX[nextXIndex],
+ page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page],
+ dotRevealFractions[page]), Path.Op.UNION);
+ }
+ // draw any retreating joins
+ if (retreatingJoinX1 != INVALID_FRACTION) {
+ combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION);
+ }
+ canvas.drawPath(combinedUnselectedPath, unselectedPaint);
+ }
+
+ /**
+ * Unselected dots can be in 6 states:
+ * <p>
+ * #1 At rest
+ * #2 Joining neighbour, still separate
+ * #3 Joining neighbour, combined curved
+ * #4 Joining neighbour, combined straight
+ * #5 Join retreating
+ * #6 Dot re-showing / revealing
+ * <p>
+ * It can also be in a combination of these states e.g. joining one neighbour while
+ * retreating from another. We therefore create a Path so that we can examine each
+ * dot pair separately and later take the union for these cases.
+ * <p>
+ * This function returns a path for the given dot **and any action to it's right** e.g. joining
+ * or retreating from it's neighbour
+ *
+ * @param page
+ * @return
+ */
+ private Path getUnselectedPath(int page,
+ float centerX,
+ float nextCenterX,
+ float joiningFraction,
+ float dotRevealFraction) {
+
+ unselectedDotPath.rewind();
+
+ if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION)
+ && dotRevealFraction == 0f
+ && !(page == currentPage && selectedDotInPosition == true)) {
+
+ // case #1 – At rest
+ unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW);
+ }
+
+ if (joiningFraction > 0f && joiningFraction <= 0.5f
+ && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #2 – Joining neighbour, still separate
+
+ // start with the left dot
+ unselectedDotLeftPath.rewind();
+
+ // start at the bottom center
+ unselectedDotLeftPath.moveTo(centerX, dotBottomY);
+
+ // semi circle to the top center
+ rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY);
+ unselectedDotLeftPath.arcTo(rectF, 90, 180, true);
+
+ // cubic to the right middle
+ endX1 = centerX + dotRadius + (joiningFraction * gap);
+ endY1 = dotCenterY;
+ controlX1 = centerX + halfDotRadius;
+ controlY1 = dotTopY;
+ controlX2 = endX1;
+ controlY2 = endY1 - halfDotRadius;
+ unselectedDotLeftPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // cubic back to the bottom center
+ endX2 = centerX;
+ endY2 = dotBottomY;
+ controlX1 = endX1;
+ controlY1 = endY1 + halfDotRadius;
+ controlX2 = centerX + halfDotRadius;
+ controlY2 = dotBottomY;
+ unselectedDotLeftPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+
+ unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION);
+
+ // now do the next dot to the right
+ unselectedDotRightPath.rewind();
+
+ // start at the bottom center
+ unselectedDotRightPath.moveTo(nextCenterX, dotBottomY);
+
+ // semi circle to the top center
+ rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotRightPath.arcTo(rectF, 90, -180, true);
+
+ // cubic to the left middle
+ endX1 = nextCenterX - dotRadius - (joiningFraction * gap);
+ endY1 = dotCenterY;
+ controlX1 = nextCenterX - halfDotRadius;
+ controlY1 = dotTopY;
+ controlX2 = endX1;
+ controlY2 = endY1 - halfDotRadius;
+ unselectedDotRightPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // cubic back to the bottom center
+ endX2 = nextCenterX;
+ endY2 = dotBottomY;
+ controlX1 = endX1;
+ controlY1 = endY1 + halfDotRadius;
+ controlX2 = endX2 - halfDotRadius;
+ controlY2 = dotBottomY;
+ unselectedDotRightPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+ unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION);
+ }
+
+ if (joiningFraction > 0.5f && joiningFraction < 1f
+ && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #3 – Joining neighbour, combined curved
+
+ // adjust the fraction so that it goes from 0.3 -> 1 to produce a more realistic 'join'
+ float adjustedFraction = (joiningFraction - 0.2f) * 1.25f;
+
+ // start in the bottom left
+ unselectedDotPath.moveTo(centerX, dotBottomY);
+
+ // semi-circle to the top left
+ rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY);
+ unselectedDotPath.arcTo(rectF, 90, 180, true);
+
+ // bezier to the middle top of the join
+ endX1 = centerX + dotRadius + (gap / 2);
+ endY1 = dotCenterY - (adjustedFraction * dotRadius);
+ controlX1 = endX1 - (adjustedFraction * dotRadius);
+ controlY1 = dotTopY;
+ controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius);
+ controlY2 = endY1;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // bezier to the top right of the join
+ endX2 = nextCenterX;
+ endY2 = dotTopY;
+ controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius);
+ controlY1 = endY1;
+ controlX2 = endX1 + (adjustedFraction * dotRadius);
+ controlY2 = dotTopY;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+
+ // semi-circle to the bottom right
+ rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotPath.arcTo(rectF, 270, 180, true);
+
+ // bezier to the middle bottom of the join
+ // endX1 stays the same
+ endY1 = dotCenterY + (adjustedFraction * dotRadius);
+ controlX1 = endX1 + (adjustedFraction * dotRadius);
+ controlY1 = dotBottomY;
+ controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius);
+ controlY2 = endY1;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // bezier back to the start point in the bottom left
+ endX2 = centerX;
+ endY2 = dotBottomY;
+ controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius);
+ controlY1 = endY1;
+ controlX2 = endX1 - (adjustedFraction * dotRadius);
+ controlY2 = endY2;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+ }
+ if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #4 Joining neighbour, combined straight technically we could use case 3 for this
+ // situation as well but assume that this is an optimization rather than faffing around
+ // with beziers just to draw a rounded rect
+ rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW);
+ }
+
+ // case #5 is handled by #getRetreatingJoinPath()
+ // this is done separately so that we can have a single retreating path spanning
+ // multiple dots and therefore animate it's movement smoothly
+
+ if (dotRevealFraction > MINIMAL_REVEAL) {
+
+ // case #6 – previously hidden dot revealing
+ unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius,
+ Path.Direction.CW);
+ }
+
+ return unselectedDotPath;
+ }
+
+ private Path getRetreatingJoinPath() {
+ unselectedDotPath.rewind();
+ rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY);
+ unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW);
+ return unselectedDotPath;
+ }
+
+ private void drawSelected(Canvas canvas) {
+ canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint);
+ }
+
+ private void setSelectedPage(int now) {
+ if (now == currentPage) return;
+
+ pageChanging = true;
+ previousPage = currentPage;
+ currentPage = now;
+ final int steps = Math.abs(now - previousPage);
+
+ if (steps > 1) {
+ if (now > previousPage) {
+ for (int i = 0; i < steps; i++) {
+ setJoiningFraction(previousPage + i, 1f);
+ }
+ } else {
+ for (int i = -1; i > -steps; i--) {
+ setJoiningFraction(previousPage + i, 1f);
+ }
+ }
+ }
+
+ // create the anim to move the selected dot – this animator will kick off
+ // retreat animations when it has moved 75% of the way.
+ // The retreat animation in turn will kick of reveal anims when the
+ // retreat has passed any dots to be revealed
+ moveAnimation = createMoveSelectedAnimator(dotCenterX[now], previousPage, now, steps);
+ moveAnimation.start();
+ }
+
+ private ValueAnimator createMoveSelectedAnimator(
+ final float moveTo, int was, int now, int steps) {
+
+ // create the actual move animator
+ ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo);
+
+ // also set up a pending retreat anim – this starts when the move is 75% complete
+ retreatAnimation = new PendingRetreatAnimator(was, now, steps,
+ now > was ?
+ new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) :
+ new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f)));
+ retreatAnimation.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ resetState();
+ pageChanging = false;
+ }
+ });
+ moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ selectedDotX = (Float) valueAnimator.getAnimatedValue();
+ retreatAnimation.startIfNecessary(selectedDotX);
+ postInvalidateOnAnimation();
+ }
+ });
+ moveSelected.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ // set a flag so that we continue to draw the unselected dot in the target position
+ // until the selected dot has finished moving into place
+ selectedDotInPosition = false;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ // set a flag when anim finishes so that we don't draw both selected & unselected
+ // page dots
+ selectedDotInPosition = true;
+ }
+ });
+ // slightly delay the start to give the joins a chance to run
+ // unless dot isn't in position yet – then don't delay!
+ moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4l : 0l);
+ moveSelected.setDuration(animDuration * 3l / 4l);
+ moveSelected.setInterpolator(interpolator);
+ return moveSelected;
+ }
+
+ private void setJoiningFraction(int leftDot, float fraction) {
+ if (leftDot < joiningFractions.length) {
+
+ if (leftDot == 1) {
+ Log.d("PageIndicator", "dot 1 fraction:\t" + fraction);
+ }
+
+ joiningFractions[leftDot] = fraction;
+ postInvalidateOnAnimation();
+ }
+ }
+
+ private void clearJoiningFractions() {
+ Arrays.fill(joiningFractions, 0f);
+ postInvalidateOnAnimation();
+ }
+
+ private void setDotRevealFraction(int dot, float fraction) {
+ dotRevealFractions[dot] = fraction;
+ postInvalidateOnAnimation();
+ }
+
+ private void cancelJoiningAnimations() {
+ if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) {
+ joiningAnimationSet.cancel();
+ }
+ }
+
+ /**
+ * A {@link ValueAnimator} that starts once a given predicate returns true.
+ */
+ public abstract class PendingStartAnimator extends ValueAnimator {
+
+ protected boolean hasStarted;
+ protected StartPredicate predicate;
+
+ public PendingStartAnimator(StartPredicate predicate) {
+ super();
+ this.predicate = predicate;
+ hasStarted = false;
+ }
+
+ public void startIfNecessary(float currentValue) {
+ if (!hasStarted && predicate.shouldStart(currentValue)) {
+ start();
+ hasStarted = true;
+ }
+ }
+ }
+
+ /**
+ * An Animator that shows and then shrinks a retreating join between the previous and newly
+ * selected pages. This also sets up some pending dot reveals – to be started when the retreat
+ * has passed the dot to be revealed.
+ */
+ public class PendingRetreatAnimator extends PendingStartAnimator {
+
+ public PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) {
+ super(predicate);
+ setDuration(animHalfDuration);
+ setInterpolator(interpolator);
+
+ // work out the start/end values of the retreating join from the direction we're
+ // travelling in. Also look at the current selected dot position, i.e. we're moving on
+ // before a prior anim has finished.
+ final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius
+ : dotCenterX[now] - dotRadius;
+ final float finalX1 = now > was ? dotCenterX[now] - dotRadius
+ : dotCenterX[now] - dotRadius;
+ final float initialX2 = now > was ? dotCenterX[now] + dotRadius
+ : Math.max(dotCenterX[was], selectedDotX) + dotRadius;
+ final float finalX2 = now > was ? dotCenterX[now] + dotRadius
+ : dotCenterX[now] + dotRadius;
+
+ revealAnimations = new PendingRevealAnimator[steps];
+ // hold on to the indexes of the dots that will be hidden by the retreat so that
+ // we can initialize their revealFraction's i.e. make sure they're hidden while the
+ // reveal animation runs
+ final int[] dotsToHide = new int[steps];
+ if (initialX1 != finalX1) { // rightward retreat
+ setFloatValues(initialX1, finalX1);
+ // create the reveal animations that will run when the retreat passes them
+ for (int i = 0; i < steps; i++) {
+ revealAnimations[i] = new PendingRevealAnimator(was + i,
+ new RightwardStartPredicate(dotCenterX[was + i]));
+ dotsToHide[i] = was + i;
+ }
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue();
+ postInvalidateOnAnimation();
+ // start any reveal animations if we've passed them
+ for (PendingRevealAnimator pendingReveal : revealAnimations) {
+ pendingReveal.startIfNecessary(retreatingJoinX1);
+ }
+ }
+ });
+ } else { // (initialX2 != finalX2) leftward retreat
+ setFloatValues(initialX2, finalX2);
+ // create the reveal animations that will run when the retreat passes them
+ for (int i = 0; i < steps; i++) {
+ revealAnimations[i] = new PendingRevealAnimator(was - i,
+ new LeftwardStartPredicate(dotCenterX[was - i]));
+ dotsToHide[i] = was - i;
+ }
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue();
+ postInvalidateOnAnimation();
+ // start any reveal animations if we've passed them
+ for (PendingRevealAnimator pendingReveal : revealAnimations) {
+ pendingReveal.startIfNecessary(retreatingJoinX2);
+ }
+ }
+ });
+ }
+
+ addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ cancelJoiningAnimations();
+ clearJoiningFractions();
+ // we need to set this so that the dots are hidden until the reveal anim runs
+ for (int dot : dotsToHide) {
+ setDotRevealFraction(dot, MINIMAL_REVEAL);
+ }
+ retreatingJoinX1 = initialX1;
+ retreatingJoinX2 = initialX2;
+ postInvalidateOnAnimation();
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ retreatingJoinX1 = INVALID_FRACTION;
+ retreatingJoinX2 = INVALID_FRACTION;
+ postInvalidateOnAnimation();
+ }
+ });
+ }
+ }
+
+ /**
+ * An Animator that animates a given dot's revealFraction i.e. scales it up
+ */
+ public class PendingRevealAnimator extends PendingStartAnimator {
+
+ private int dot;
+
+ public PendingRevealAnimator(int dot, StartPredicate predicate) {
+ super(predicate);
+ setFloatValues(MINIMAL_REVEAL, 1f);
+ this.dot = dot;
+ setDuration(animHalfDuration);
+ setInterpolator(interpolator);
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ setDotRevealFraction(PendingRevealAnimator.this.dot,
+ (Float) valueAnimator.getAnimatedValue());
+ }
+ });
+ addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ setDotRevealFraction(PendingRevealAnimator.this.dot, 0f);
+ postInvalidateOnAnimation();
+ }
+ });
+ }
+ }
+
+ /**
+ * A predicate used to start an animation when a test passes
+ */
+ public abstract class StartPredicate {
+
+ protected float thresholdValue;
+
+ public StartPredicate(float thresholdValue) {
+ this.thresholdValue = thresholdValue;
+ }
+
+ abstract boolean shouldStart(float currentValue);
+
+ }
+
+ /**
+ * A predicate used to start an animation when a given value is greater than a threshold
+ */
+ public class RightwardStartPredicate extends StartPredicate {
+
+ public RightwardStartPredicate(float thresholdValue) {
+ super(thresholdValue);
+ }
+
+ boolean shouldStart(float currentValue) {
+ return currentValue > thresholdValue;
+ }
+ }
+
+ /**
+ * A predicate used to start an animation then a given value is less than a threshold
+ */
+ public class LeftwardStartPredicate extends StartPredicate {
+
+ public LeftwardStartPredicate(float thresholdValue) {
+ super(thresholdValue);
+ }
+
+ boolean shouldStart(float currentValue) {
+ return currentValue < thresholdValue;
+ }
+ }
+}
diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt b/core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt
new file mode 100644
index 0000000..528dabc
--- /dev/null
+++ b/core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt
@@ -0,0 +1,125 @@
+package ca.allanwang.kau.widgets
+
+import android.content.Context
+import android.graphics.Color
+import android.support.v4.widget.TextViewCompat
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.TextSwitcher
+import android.widget.TextView
+import ca.allanwang.kau.R
+import java.util.*
+
+/**
+ * Created by Allan Wang on 2017-06-21.
+ *
+ * Text switcher with global text color and embedded sliding animations
+ * Also has a stack to keep track of title changes
+ */
+class TextSlider @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null
+) : TextSwitcher(context, attrs) {
+
+ val titleStack: Stack<CharSequence?> = Stack()
+
+ /**
+ * Holds a mapping of animation types to their respective animations
+ */
+ val animationMap = mapOf(
+ ANIMATION_NONE to null,
+ ANIMATION_SLIDE_HORIZONTAL to AnimationBundle(
+ R.anim.kau_slide_in_right, R.anim.kau_slide_out_left,
+ R.anim.kau_slide_in_left, R.anim.kau_slide_out_right),
+ ANIMATION_SLIDE_VERTICAL to AnimationBundle(
+ R.anim.kau_slide_in_bottom, R.anim.kau_slide_out_top,
+ R.anim.kau_slide_in_top, R.anim.kau_slide_out_bottom
+ )
+ )
+
+ /**
+ * Holds lazy instances of the animations
+ */
+ inner class AnimationBundle(private val nextIn: Int, private val nextOut: Int, private val prevIn: Int, private val prevOut: Int) {
+ val NEXT_IN: Animation by lazy { AnimationUtils.loadAnimation(context, nextIn) }
+ val NEXT_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, nextOut) }
+ val PREV_IN: Animation by lazy { AnimationUtils.loadAnimation(context, prevIn) }
+ val PREV_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, prevOut) }
+ }
+
+ companion object {
+ const val ANIMATION_NONE = 1000
+ const val ANIMATION_SLIDE_HORIZONTAL = 1001
+ const val ANIMATION_SLIDE_VERTICAL = 1002
+ }
+
+ var animationType: Int = ANIMATION_SLIDE_HORIZONTAL
+
+ var textColor: Int = Color.WHITE
+ get() = field
+ set(value) {
+ field = value
+ (getChildAt(0) as TextView).setTextColor(value)
+ (getChildAt(1) as TextView).setTextColor(value)
+ }
+ val isRoot: Boolean
+ get() = titleStack.size <= 1
+
+ init {
+ if (attrs != null) {
+ val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.TextSlider)
+ animationType = styledAttrs.getInteger(R.styleable.TextSlider_animation_type, ANIMATION_SLIDE_HORIZONTAL)
+ styledAttrs.recycle()
+ }
+ }
+
+ override fun setText(text: CharSequence?) {
+ if ((currentView as TextView).text == text) return
+ super.setText(text)
+ }
+
+ override fun setCurrentText(text: CharSequence?) {
+ if (titleStack.isNotEmpty()) titleStack.pop()
+ titleStack.push(text)
+ super.setCurrentText(text)
+ }
+
+ fun setNextText(text: CharSequence?) {
+ if (titleStack.isEmpty()) {
+ setCurrentText(text)
+ return
+ }
+ titleStack.push(text)
+ val anim = animationMap[animationType]
+ inAnimation = anim?.NEXT_IN
+ outAnimation = anim?.NEXT_OUT
+ setText(text)
+ }
+
+ /**
+ * Sets the text as the previous title
+ * No further checks are done, so be sure to verify with [isRoot]
+ */
+ @Throws(EmptyStackException::class)
+ fun setPrevText() {
+ titleStack.pop()
+ val anim = animationMap[animationType]
+ inAnimation = anim?.PREV_IN
+ outAnimation = anim?.PREV_OUT
+ val text = titleStack.peek()
+ setText(text)
+ }
+
+ init {
+ setFactory {
+ TextView(context).apply {
+ //replica of toolbar title
+ gravity = Gravity.START
+ setSingleLine()
+ ellipsize = TextUtils.TruncateAt.END
+ TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat_Title)
+ }
+ }
+ }
+} \ No newline at end of file