Skip to content
Permalink
Browse files

Day indicators on schedule

Replacement for the tabs that we used to have. Behaviors implemented in
this change:

- Whenever the schedule list loads, each conference day represented in
  the result set gets an indicator.

- If the result set is empty, instead of showing no indicators, show
  all of them but disabled.

- Tapping an indicator scrolls to the first item on the corresponding
  day.

- Indicators are highlighted with a circular background as long as an
  item that falls on the corresponding day is on screen. Multiple
  indicators can be highlighted at the same time.

- Indicator text changes based on whether the user is in the conference
  timezone ("May: 7 8 9") or another timezone ("Day: 1 2 3").

Bug: 124072759
Change-Id: Ifae9a60fb8c974923f337db094a642ff4e2dc781
  • Loading branch information...
jdkoren authored and thagikura committed Jan 2, 2019
1 parent 8a09119 commit 23295883d0ef74bdc358ae8b5615dda33a6a6637
Showing with 845 additions and 121 deletions.
  1. +17 −0 mobile/sampledata/day_indicator.json
  2. +37 −0 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/DayIndicator.kt
  3. +84 −0 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/DayIndicatorAdapter.kt
  4. +105 −32 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/ScheduleFragment.kt
  5. +5 −12 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/ScheduleItemBindingAdapter.kt
  6. +4 −4 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/ScheduleTimeHeadersDecoration.kt
  7. +63 −24 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/ScheduleViewModel.kt
  8. +2 −1 mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/SessionHeaderIndexer.kt
  9. +1 −0 mobile/src/main/java/com/google/samples/apps/iosched/ui/sessiondetail/SessionDetailAdapter.kt
  10. +185 −0 mobile/src/main/java/com/google/samples/apps/iosched/widget/BubbleDecoration.kt
  11. +57 −0 mobile/src/main/java/com/google/samples/apps/iosched/widget/JumpSmoothScroller.kt
  12. +22 −0 mobile/src/main/res/color/schedule_day_indicator_text.xml
  13. +20 −3 mobile/src/main/res/layout-land/include_schedule_appbar.xml
  14. +2 −2 mobile/src/main/res/layout/fragment_schedule.xml
  15. +23 −2 mobile/src/main/res/layout/include_schedule_appbar.xml
  16. +50 −0 mobile/src/main/res/layout/item_schedule_day_indicator.xml
  17. +2 −5 mobile/src/main/res/layout/item_session.xml
  18. +1 −1 mobile/src/main/res/values-night/colors.xml
  19. +5 −0 mobile/src/main/res/values/attrs.xml
  20. +1 −0 mobile/src/main/res/values/colors.xml
  21. +8 −0 mobile/src/main/res/values/dimens.xml
  22. +2 −0 mobile/src/main/res/values/strings.xml
  23. +5 −1 mobile/src/main/res/values/styles.xml
  24. +6 −6 mobile/src/test/java/com/google/samples/apps/iosched/ui/schedule/ScheduleViewModelTest.kt
  25. +15 −2 model/src/main/java/com/google/samples/apps/iosched/model/ConferenceDay.kt
  26. +15 −6 ...ava/com/google/samples/apps/iosched/shared/data/userevent/DefaultSessionAndUserEventRepository.kt
  27. +62 −0 shared/src/main/java/com/google/samples/apps/iosched/shared/domain/sessions/ConferenceDayIndexer.kt
  28. +21 −2 ...in/java/com/google/samples/apps/iosched/shared/domain/sessions/LoadFilteredUserSessionsUseCase.kt
  29. +5 −4 shared/src/main/java/com/google/samples/apps/iosched/shared/util/Extensions.kt
  30. +9 −6 shared/src/main/java/com/google/samples/apps/iosched/shared/util/TimeUtils.kt
  31. +3 −3 ...src/test/java/com/google/samples/apps/iosched/shared/domain/users/ReservationActionUseCaseTest.kt
  32. +2 −2 shared/src/test/java/com/google/samples/apps/iosched/shared/domain/users/StarEventUseCaseTest.kt
  33. +6 −3 test-shared/src/main/java/com/google/samples/apps/iosched/test/data/TestData.kt
@@ -0,0 +1,17 @@
{
"comment": "Sample day indicators for use with tools:... attributes in layouts",
"indicators": [
{
"label": "7",
"checked": true
},
{
"label": "8",
"checked": false
},
{
"label": "9",
"checked": false
}
]
}
@@ -0,0 +1,37 @@
/*
* Copyright 2019 Google LLC
*
* 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 com.google.samples.apps.iosched.ui.schedule

import com.google.samples.apps.iosched.model.ConferenceDay

/** An indicator for days on the Schedule. */
class DayIndicator(
val day: ConferenceDay,
val checked: Boolean = false,
val enabled: Boolean = true
) {
// Only the day is used for equality
override fun equals(other: Any?): Boolean =
this === other || (other is DayIndicator && day == other.day)

// Only the day is used for equality
override fun hashCode(): Int = day.hashCode()

fun areUiContentsTheSame(other: DayIndicator): Boolean {
return checked == other.checked && enabled == other.enabled
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2019 Google LLC
*
* 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 com.google.samples.apps.iosched.ui.schedule

import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.databinding.BindingAdapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.google.samples.apps.iosched.databinding.ItemScheduleDayIndicatorBinding
import com.google.samples.apps.iosched.util.executeAfter

class DayIndicatorAdapter(
private val scheduleViewModel: ScheduleViewModel,
private val lifecycleOwner: LifecycleOwner
) : ListAdapter<DayIndicator, DayIndicatorViewHolder>(IndicatorDiff) {

init {
setHasStableIds(true)
}

override fun getItemId(position: Int): Long {
return getItem(position).day.ordinal.toLong()
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayIndicatorViewHolder {
val binding = ItemScheduleDayIndicatorBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
return DayIndicatorViewHolder(binding, scheduleViewModel, lifecycleOwner)
}

override fun onBindViewHolder(holder: DayIndicatorViewHolder, position: Int) {
holder.bind(getItem(position))
}
}

class DayIndicatorViewHolder(
private val binding: ItemScheduleDayIndicatorBinding,
private val scheduleViewModel: ScheduleViewModel,
private val lifecycleOwner: LifecycleOwner
) : ViewHolder(binding.root) {

fun bind(item: DayIndicator) {
binding.executeAfter {
viewModel = scheduleViewModel
setLifecycleOwner(lifecycleOwner)
indicator = item
}
}
}

object IndicatorDiff : ItemCallback<DayIndicator>() {
override fun areItemsTheSame(oldItem: DayIndicator, newItem: DayIndicator) =
oldItem == newItem

override fun areContentsTheSame(oldItem: DayIndicator, newItem: DayIndicator) =
oldItem.areUiContentsTheSame(newItem)
}

@BindingAdapter("indicatorText", "inConferenceTimeZone", requireAll = true)
fun setIndicatorText(
view: TextView,
dayIndicator: DayIndicator,
inConferenceTimeZone: Boolean
) {
view.text = dayIndicator.day.getIndicatorLabel(inConferenceTimeZone)
}
@@ -29,13 +29,18 @@ import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import com.google.android.material.floatingactionbutton.FloatingActionButton

import com.google.samples.apps.iosched.R
import com.google.samples.apps.iosched.databinding.FragmentScheduleBinding
import com.google.samples.apps.iosched.model.ConferenceDay
import com.google.samples.apps.iosched.model.SessionId
import com.google.samples.apps.iosched.shared.analytics.AnalyticsHelper
import com.google.samples.apps.iosched.shared.domain.sessions.ConferenceDayIndexer
import com.google.samples.apps.iosched.shared.result.EventObserver
import com.google.samples.apps.iosched.shared.util.TimeUtils
import com.google.samples.apps.iosched.shared.util.viewModelProvider
import com.google.samples.apps.iosched.ui.MainNavigationFragment
import com.google.samples.apps.iosched.ui.messages.SnackbarMessageManager
@@ -54,7 +59,9 @@ import com.google.samples.apps.iosched.widget.BottomSheetBehavior.BottomSheetCal
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_COLLAPSED
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_EXPANDED
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_HIDDEN
import com.google.samples.apps.iosched.widget.BubbleDecoration
import com.google.samples.apps.iosched.widget.FadingSnackbar
import com.google.samples.apps.iosched.widget.JumpSmoothScroller
import javax.inject.Inject
import javax.inject.Named

@@ -82,11 +89,19 @@ class ScheduleFragment : MainNavigationFragment() {
private lateinit var scheduleViewModel: ScheduleViewModel

private lateinit var filtersFab: FloatingActionButton
private lateinit var recyclerView: RecyclerView
private lateinit var bottomSheetBehavior: BottomSheetBehavior<*>
private lateinit var snackbar: FadingSnackbar

private lateinit var adapter: ScheduleAdapter
private lateinit var scheduleRecyclerView: RecyclerView
private lateinit var scheduleAdapter: ScheduleAdapter
private lateinit var scheduleScroller: JumpSmoothScroller

private lateinit var dayIndicatorRecyclerView: RecyclerView
private lateinit var dayIndicatorAdapter: DayIndicatorAdapter
private lateinit var dayIndicatorItemDecoration: BubbleDecoration

private lateinit var dayIndexer: ConferenceDayIndexer
private var cachedBubbleRange: IntRange? = null

private lateinit var binding: FragmentScheduleBinding

@@ -104,7 +119,8 @@ class ScheduleFragment : MainNavigationFragment() {

filtersFab = binding.filterFab
snackbar = binding.snackbar
recyclerView = binding.recyclerview
scheduleRecyclerView = binding.recyclerview
dayIndicatorRecyclerView = binding.includeScheduleAppbar.dayIndicators
return binding.root
}

@@ -119,8 +135,6 @@ class ScheduleFragment : MainNavigationFragment() {
)

// Filters sheet configuration
// This view is not in the Binding class because it's in an included layout.
val appbar: View = view.findViewById(R.id.appbar)
bottomSheetBehavior = BottomSheetBehavior.from(view.findViewById(R.id.filter_sheet))
filtersFab.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
@@ -132,48 +146,64 @@ class ScheduleFragment : MainNavigationFragment() {
} else {
View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
}
recyclerView.importantForAccessibility = a11yState
appbar.importantForAccessibility = a11yState
scheduleRecyclerView.importantForAccessibility = a11yState
binding.includeScheduleAppbar.appbar.importantForAccessibility = a11yState
}
})

// Session list configuration
adapter = ScheduleAdapter(
scheduleAdapter = ScheduleAdapter(
scheduleViewModel,
tagViewPool,
scheduleViewModel.showReservations,
scheduleViewModel.timeZoneId,
this
)
recyclerView.apply {
adapter = this@ScheduleFragment.adapter
(layoutManager as LinearLayoutManager).recycleChildrenOnDetach = true
scheduleRecyclerView.apply {
adapter = scheduleAdapter
(itemAnimator as DefaultItemAnimator).run {
supportsChangeAnimations = false
addDuration = 160L
moveDuration = 160L
changeDuration = 160L
removeDuration = 120L
}

addOnScrollListener(object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
onScheduleScrolled()
}
})
}
scheduleScroller = JumpSmoothScroller(view.context)

dayIndicatorItemDecoration = BubbleDecoration(view.context)
dayIndicatorRecyclerView.addItemDecoration(dayIndicatorItemDecoration)

// TODO(b/124072759) UI affordance to jump to start of each day
dayIndicatorAdapter = DayIndicatorAdapter(scheduleViewModel, viewLifecycleOwner)
dayIndicatorRecyclerView.adapter = dayIndicatorAdapter
scheduleViewModel.dayIndicatorsLabel.observe(this, Observer {
binding.includeScheduleAppbar.dayIndicatorsLabel.setText(it)
})

// Start observing ViewModels
scheduleViewModel.sessionTimeData.observe(this, Observer {
scheduleViewModel.scheduleUiData.observe(this, Observer {
it ?: return@Observer
initializeList(it)
updateScheduleUi(it)
})

// During conference, scroll to current event.
scheduleViewModel.currentEvent.observe(this, Observer { index ->
if (index > -1 && !scheduleViewModel.userHasInteracted) {
recyclerView.run {
scheduleViewModel.scrollToEvent.observe(this, EventObserver { scrollEvent ->
if (scrollEvent.targetPosition != -1) {
scheduleRecyclerView.run {
post {
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
index,
resources.getDimensionPixelSize(R.dimen.margin_normal)
)
val lm = layoutManager as LinearLayoutManager
if (scrollEvent.smoothScroll) {
scheduleScroller.targetPosition = scrollEvent.targetPosition
lm.startSmoothScroll(scheduleScroller)
} else {
lm.scrollToPositionWithOffset(scrollEvent.targetPosition, 0)
}
}
}
}
@@ -217,13 +247,24 @@ class ScheduleFragment : MainNavigationFragment() {
analyticsHelper.sendScreenView("Schedule", requireActivity())
}

private fun initializeList(sessionTimeData: SessionTimeData) {
// Require the list and timeZoneId to be loaded.
val list = sessionTimeData.list ?: return
val timeZoneId = sessionTimeData.timeZoneId ?: return
adapter.submitList(list)
private fun updateScheduleUi(scheduleUiData: ScheduleUiData) {
// Require everything to be loaded.
val list = scheduleUiData.list ?: return
val timeZoneId = scheduleUiData.timeZoneId ?: return
val indexer = scheduleUiData.dayIndexer ?: return

dayIndexer = indexer
// Prevent building new indicators until we get scroll information.
cachedBubbleRange = null
if (indexer.days.isEmpty()) {
// Special case: the results are empty, so we won't get valid scroll information.
// Set a bogus range to and rebuild the day indicators.
cachedBubbleRange = -1..-1
rebuildDayIndicators()
}

binding.recyclerview.run {
scheduleAdapter.submitList(list)
scheduleRecyclerView.run {
// we want this to run after diffing
doOnNextLayout { view ->
// Recreate the decoration used for the sticky time headers
@@ -235,6 +276,8 @@ class ScheduleFragment : MainNavigationFragment() {
)
)
}

onScheduleScrolled()
}
}

@@ -243,6 +286,23 @@ class ScheduleFragment : MainNavigationFragment() {
}
}

private fun rebuildDayIndicators() {
// cachedBubbleRange will get set once we have scroll information, so wait until then.
val bubbleRange = cachedBubbleRange ?: return
val indicators = if (dayIndexer.days.isEmpty()) {
TimeUtils.ConferenceDays.map { day: ConferenceDay ->
DayIndicator(day = day, enabled = false)
}
} else {
dayIndexer.days.mapIndexed { index: Int, day: ConferenceDay ->
DayIndicator(day = day, checked = index in bubbleRange)
}
}

dayIndicatorAdapter.submitList(indicators)
dayIndicatorItemDecoration.bubbleRange = bubbleRange
}

private fun updateFiltersUi(hasAnyFilters: Boolean) {
val showFab = !hasAnyFilters

@@ -264,6 +324,24 @@ class ScheduleFragment : MainNavigationFragment() {
}
}

private fun onScheduleScrolled() {
val layoutManager = (scheduleRecyclerView.layoutManager) as LinearLayoutManager
val first = layoutManager.findFirstVisibleItemPosition()
val last = layoutManager.findLastVisibleItemPosition()
if (first < 0 || last < 0) {
// When the list is empty, we get -1 for the positions.
return
}

val firstDay = dayIndexer.dayForPosition(first)
val lastDay = dayIndexer.dayForPosition(last)
val highlightRange = dayIndexer.days.indexOf(firstDay)..dayIndexer.days.indexOf(lastDay)
if (highlightRange != cachedBubbleRange) {
cachedBubbleRange = highlightRange
rebuildDayIndicators()
}
}

override fun onBackPressed(): Boolean {
if (::bottomSheetBehavior.isInitialized && bottomSheetBehavior.state == STATE_EXPANDED) {
// collapse or hide the sheet
@@ -308,9 +386,4 @@ class ScheduleFragment : MainNavigationFragment() {
val dialog = NotificationsPreferenceDialogFragment()
dialog.show(requireActivity().supportFragmentManager, DIALOG_NOTIFICATIONS_PREFERENCE)
}

override fun onStart() {
super.onStart()
scheduleViewModel.initializeTimeZone()
}
}

0 comments on commit 2329588

Please sign in to comment.
You can’t perform that action at this time.