Commit a2894f06 authored by Joey's avatar Joey

app: glucose list: new headers

Signed-off-by: Joey's avatarJoey <bevilacquajoey@gmail.com>
parent c49e6015
......@@ -25,7 +25,6 @@ import it.diab.core.util.event.Event
import it.diab.holders.GlucoseHolder
import it.diab.holders.GlucoseHolderCallbacks
import it.diab.util.UIUtils
import it.diab.util.extensions.diff
import it.diab.viewmodels.glucose.GlucoseListViewModel
import kotlinx.coroutines.CoroutineScope
import java.text.SimpleDateFormat
......@@ -89,19 +88,6 @@ class GlucoseListAdapter(
_openGlucose.value = Event(uid)
}
override fun shouldInsertHeader(position: Int): Boolean {
if (position == 0) {
return true
}
val item = getItem(position) ?: return false
val previous = getItem(position - 1) ?: return false
val a = previous.date
val b = item.date
return b.diff(Date()) != 0 && a.diff(b) > 0
}
private fun buildIndicator(@ColorRes colorId: Int): Drawable? {
val resources = context.resources
val color = ContextCompat.getColor(context, colorId)
......
......@@ -26,6 +26,9 @@ import it.diab.core.data.repositories.GlucoseRepository
import it.diab.core.data.repositories.InsulinRepository
import it.diab.core.util.event.Event
import it.diab.core.util.event.EventObserver
import it.diab.ui.TimeHeaderDecoration
import it.diab.util.extensions.doOnNextLayout
import it.diab.util.extensions.removeAllItemDecorators
import it.diab.viewmodels.glucose.GlucoseListViewModel
import it.diab.viewmodels.glucose.GlucoseListViewModelFactory
......@@ -75,6 +78,7 @@ class GlucoseListFragment : BaseFragment() {
recyclerView.adapter = adapter
viewModel.pagedList.observe(activity, Observer(this::update))
viewModel.liveList.observe(activity, Observer(this::updateHeaders))
adapter.openGlucose.observe(activity, EventObserver(this::onItemClick))
}
}
......@@ -101,4 +105,15 @@ class GlucoseListFragment : BaseFragment() {
resources.getString(R.string.glucose_header_last)
)
}
private fun updateHeaders(list: List<Glucose>) {
if (list.isEmpty()) {
return
}
recyclerView.doOnNextLayout {
recyclerView.removeAllItemDecorators()
recyclerView.addItemDecoration(TimeHeaderDecoration(requireContext(), list))
}
}
}
\ No newline at end of file
......@@ -20,8 +20,6 @@ class GlucoseHolder(
view: View,
private val callbacks: GlucoseHolderCallbacks
) : RecyclerView.ViewHolder(view) {
private val headerView = view.findViewById<View>(R.id.item_glucose_header)
private val headerTitleView = view.findViewById<TextView>(R.id.item_glucose_header_title)
private val iconView = view.findViewById<ImageView>(R.id.item_glucose_timezone)
private val titleView = view.findViewById<TextView>(R.id.item_glucose_value)
private val summaryView = view.findViewById<TextView>(R.id.item_glucose_insulin)
......@@ -30,7 +28,6 @@ class GlucoseHolder(
fun onBind(glucose: Glucose) {
itemView.visibility = View.VISIBLE
bindHeader(glucose)
bindValue(glucose)
bindInsulin(glucose)
......@@ -42,15 +39,6 @@ class GlucoseHolder(
itemView.visibility = View.INVISIBLE
}
private fun bindHeader(glucose: Glucose) {
val shouldShowHeader = callbacks.shouldInsertHeader(adapterPosition)
headerView.visibility = if (shouldShowHeader) View.VISIBLE else View.GONE
if (shouldShowHeader) {
callbacks.fetchHeaderText(glucose.date, headerTitleView::setPrecomputedText)
}
}
private fun bindValue(glucose: Glucose) {
val title = "${glucose.value} (%1\$s)"
......
......@@ -53,11 +53,4 @@ interface GlucoseHolderCallbacks {
* OnClick event callback
*/
fun onClick(uid: Long)
/**
* Whether the header should be shown
*
* @param position position of the glucose in the list
*/
fun shouldInsertHeader(position: Int): Boolean
}
/*
* Copyright 2018 Google LLC
* Copyright 2019 Bevilacqua Joey
*
* 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
*
* https://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 it.diab.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.text.Layout
import android.text.SpannableStringBuilder
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView
import it.diab.R
import it.diab.core.data.entities.Glucose
import it.diab.core.util.extensions.getCalendar
import it.diab.util.extensions.inSpans
import it.diab.util.extensions.withTranslation
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* A [RecyclerView.ItemDecoration] which draws sticky headers for a given list of sessions.
*/
class TimeHeaderDecoration(
context: Context,
data: List<Glucose>
) : RecyclerView.ItemDecoration() {
private val paint: TextPaint
private val width: Int
private val paddingTop: Int
private val monthTextSize: Int
private val dayFormatter = SimpleDateFormat("dd", Locale.getDefault())
private val monthFormatter = SimpleDateFormat("MMM yyyy", Locale.getDefault())
init {
val attrs = context.obtainStyledAttributes(
R.style.AppTheme_TimeHeaders,
R.styleable.TimeHeader
)
paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = attrs.getColor(R.styleable.TimeHeader_android_textColor, Color.BLACK)
textSize = attrs.getDimension(R.styleable.TimeHeader_dayTextSize, 0f)
}
width = attrs.getDimensionPixelSize(R.styleable.TimeHeader_android_width, 0)
paddingTop = attrs.getDimensionPixelSize(R.styleable.TimeHeader_android_paddingTop, 0)
monthTextSize = attrs.getDimensionPixelSize(R.styleable.TimeHeader_monthTextSize, 0)
attrs.recycle()
}
private val daySlots: Map<Int, StaticLayout> =
data.mapIndexed { index, glucose ->
index to glucose.date
}.distinctBy {
val cal = it.second.getCalendar()
(cal[Calendar.YEAR] shl 3) + cal[Calendar.DAY_OF_YEAR]
}.map {
it.first to createHeader(it.second)
}.toMap()
/**
* Loop over each child and draw any corresponding headers.
* We also look back to see if there are any headers _before_ the first header we
* found i.e. which needs to be sticky.
*/
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (daySlots.isEmpty() || parent.childCount == 0) {
return
}
var earliestFoundHeaderPos = -1
var prevHeaderTop = Int.MAX_VALUE
for (i in parent.childCount - 1 downTo 0) {
val view = parent.getChildAt(i) ?: continue
val viewTop = view.top + view.translationY.toInt()
if (view.bottom > 0 && viewTop < parent.height) {
val position = parent.getChildAdapterPosition(view)
daySlots[position]?.let { layout ->
paint.alpha = (view.alpha * 255).toInt()
val top = (viewTop + paddingTop)
.coerceAtLeast(paddingTop)
.coerceAtMost(prevHeaderTop - layout.height)
c.withTranslation(y = top.toFloat()) {
layout.draw(c)
}
earliestFoundHeaderPos = position
prevHeaderTop = viewTop
}
}
}
// If no headers found, ensure header of the first shown item is drawn.
if (earliestFoundHeaderPos < 0) {
earliestFoundHeaderPos = parent.getChildAdapterPosition(parent.getChildAt(0)) + 1
}
// Look back over headers to see if a prior item should be drawn sticky.
for (headerPos in daySlots.keys.reversed()) {
if (headerPos < earliestFoundHeaderPos) {
daySlots[headerPos]?.let {
val top = (prevHeaderTop - it.height).coerceAtMost(paddingTop)
c.withTranslation(y = top.toFloat()) {
it.draw(c)
}
}
break
}
}
}
/**
* Create a header layout for the given [date].
*/
private fun createHeader(date: Date): StaticLayout {
val text = SpannableStringBuilder().apply {
inSpans(StyleSpan(Typeface.BOLD)) {
append(dayFormatter.format(date))
}
append("\n")
inSpans(AbsoluteSizeSpan(monthTextSize)) {
append(monthFormatter.format(date))
}
}
return StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.setLineSpacing(0f, 1f)
.setIncludePad(false)
.build()
}
}
\ No newline at end of file
/*
* Copyright (C) 2017 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.
*/
package it.diab.util.extensions
import android.graphics.Canvas
/**
* Wrap the specified [block] in calls to [Canvas.save]/[Canvas.translate]
* and [Canvas.restoreToCount].
*/
inline fun Canvas.withTranslation(
x: Float = 0.0f,
y: Float = 0.0f,
block: Canvas.() -> Unit
) {
val checkpoint = save()
translate(x, y)
try {
block()
} finally {
restoreToCount(checkpoint)
}
}
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.util.extensions
import androidx.recyclerview.widget.RecyclerView
fun RecyclerView.removeAllItemDecorators() {
for (i in 0 until itemDecorationCount) {
removeItemDecorationAt(i)
}
}
\ No newline at end of file
/*
* Copyright (C) 2017 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.
*/
package it.diab.util.extensions
import android.text.SpannableStringBuilder
import android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE
/**
* Wrap appended text in [builderAction] in [span].
*
* Note: the span will only have the correct position if the `builderAction` only appends or
* replaces text. Inserting, deleting, or clearing the text will cause the span to be placed at
* an incorrect position.
*/
inline fun SpannableStringBuilder.inSpans(
span: Any,
builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder {
val start = length
builderAction()
setSpan(span, start, length, SPAN_INCLUSIVE_EXCLUSIVE)
return this
}
/*
* Copyright (C) 2017 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.
*/
package it.diab.util.extensions
import android.view.View
/**
* Performs the given action when this view is next laid out.
*
* The action will only be invoked once on the next layout and then removed.
*/
inline fun View.doOnNextLayout(crossinline action: (view: View) -> Unit) {
addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
view.removeOnLayoutChangeListener(this)
action(view)
}
})
}
......@@ -27,6 +27,7 @@ class GlucoseListViewModel internal constructor(
private val insulinRepository: InsulinRepository
) : ScopedViewModel() {
val pagedList = LivePagedListBuilder(glucoseRepository.pagedList, 5).build()
val liveList = glucoseRepository.all
private lateinit var insulins: List<Insulin>
......
......@@ -6,108 +6,68 @@
The text of the license can be found in the LICENSE file
or at https://www.gnu.org/licenses/gpl.txt
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp"
android:orientation="vertical">
android:layout_height="64dp"
android:foreground="?attr/selectableItemBackground"
android:orientation="vertical"
android:paddingStart="72dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item_glucose_header"
android:layout_width="match_parent"
android:layout_height="40dp"
android:clickable="false"
android:focusable="false"
android:paddingStart="80dp"
android:paddingBottom="8dp"
android:visibility="gone"
tools:ignore="RtlSymmetry"
tools:visibility="visible">
<ImageView
android:id="@+id/item_glucose_timezone"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:background="@drawable/bg_circle"
android:contentDescription="@null"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_time_dinner" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/diab_dividerColor"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="64dp"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/item_glucose_status"
app:layout_constraintStart_toEndOf="@id/item_glucose_timezone"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry">
<TextView
android:id="@+id/item_glucose_header_title"
android:id="@+id/item_glucose_value"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:layout_height="wrap_content"
android:maxLines="1"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Today" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="64dp"
android:foreground="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/item_glucose_timezone"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:background="@drawable/bg_circle"
android:contentDescription="@null"
android:padding="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_time_dinner" />
tools:text="123 (19:02)" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="64dp"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/item_glucose_status"
app:layout_constraintStart_toEndOf="@id/item_glucose_timezone"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry">
<TextView
android:id="@+id/item_glucose_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
tools:text="123 (19:02)" />
<TextView
android:id="@+id/item_glucose_insulin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="15sp"
tools:text="6.5 Novorapid, 20 Lantus" />
</LinearLayout>
<TextView
android:id="@+id/item_glucose_insulin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="15sp"
tools:text="6.5 Novorapid, 20 Lantus" />
</LinearLayout>
<ImageView
android:id="@+id/item_glucose_status"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/glucose_indicator_high" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<ImageView
android:id="@+id/item_glucose_status"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/glucose_indicator_high" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!--
Copyright (c) 2019 Bevilacqua Joey
Licensed under the GNU GPLv3 license
The text of the license can be found in the LICENSE file
or at https://www.gnu.org/licenses/gpl.txt
-->
<resources>
<declare-styleable name="TimeHeader">
<attr name="android:width" />
<attr name="android:paddingTop" />
<attr name="android:textColor" />
<attr name="dayTextSize" format="dimension" />
<attr name="monthTextSize" format="dimension" />
</declare-styleable>
</resources>
\ No newline at end of file
......@@ -10,10 +10,19 @@
<dimen name="fab_margin_base">16dp</dimen>
<!-- main_bottom_nav_height + fab_margin_base -->
<dimen name="fab_margin_vertical">64dp</dimen>
<dimen name="main_bottom_nav_height">48dp</dimen>
<dimen name="overview_graph_height">256sp</dimen>
<dimen name="overview_graph_text">6sp</dimen>
<dimen name="overview_graph_line_thickness">1dp</dimen>
<dimen name="overview_graph_offset">8dp</dimen>
<dimen name="item_glucose_indicator">24dp</dimen>
<dimen name="header_keyline">64dp</dimen>
<dimen name="date_header_padding_top">8dp</dimen>
<dimen name="time_header_day_text_size">28sp</dimen>
<dimen name="time_header_month_text_size">12sp</dimen>
</resources>
\ No newline at end of file
......@@ -21,4 +21,12 @@
<item name="android:dropDownVerticalOffset">0dp</item>
<item name="android:dropDownHorizontalOffset">0dp</item>
</style>
<style name="AppTheme.TimeHeaders">
<item name="android:width">@dimen/header_keyline</item>
<item name="android:paddingTop">@dimen/date_header_padding_top</item>
<item name="android:textColor">?attr/colorAccent</item>
<item name="dayTextSize">@dimen/time_header_day_text_size</item>
<item name="monthTextSize">@dimen/time_header_month_text_size</item>
</style>
</resources>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment