diff --git a/app-android/src/main/java/org/mtransit/android/data/UISchedule.java b/app-android/src/main/java/org/mtransit/android/data/UISchedule.java index 5feea16c..ee0c6f48 100644 --- a/app-android/src/main/java/org/mtransit/android/data/UISchedule.java +++ b/app-android/src/main/java/org/mtransit/android/data/UISchedule.java @@ -26,10 +26,8 @@ import org.mtransit.android.commons.SpanUtils; import org.mtransit.android.commons.StringUtils; import org.mtransit.android.commons.data.Accessibility; -import org.mtransit.android.commons.data.Direction; import org.mtransit.android.commons.data.POIStatus; import org.mtransit.android.util.UIAccessibilityUtils; -import org.mtransit.android.util.UIDirectionUtils; import org.mtransit.android.util.UISpanUtils; import org.mtransit.android.util.UITimeUtils; import org.mtransit.commons.CollectionUtils; diff --git a/app-android/src/main/java/org/mtransit/android/ui/schedule/HorizontalCalendarAdapter.kt b/app-android/src/main/java/org/mtransit/android/ui/schedule/HorizontalCalendarAdapter.kt new file mode 100644 index 00000000..d64692aa --- /dev/null +++ b/app-android/src/main/java/org/mtransit/android/ui/schedule/HorizontalCalendarAdapter.kt @@ -0,0 +1,191 @@ +package org.mtransit.android.ui.schedule + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.ColorInt +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.mtransit.android.commons.MTLog +import org.mtransit.android.data.UISchedule +import org.mtransit.android.databinding.LayoutScheduleCalendarDayItemBinding +import org.mtransit.android.util.UITimeUtils +import org.mtransit.commons.beginningOfDay +import org.mtransit.commons.isSameDay +import org.mtransit.commons.toCalendar +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +class HorizontalCalendarAdapter : RecyclerView.Adapter(), + MTLog.Loggable { + + companion object { + private val LOG_TAG = HorizontalCalendarAdapter::class.java.simpleName + } + + override fun getLogTag(): String = LOG_TAG + + @ColorInt + var colorInt: Int? = null + @SuppressLint("NotifyDataSetChanged") + set(value) { + if (field == value) return + field = value + notifyDataSetChanged() + } + + private val dayNameFormat = SimpleDateFormat("EEE", Locale.getDefault()) + private val monthNameFormat = SimpleDateFormat("MMM", Locale.getDefault()) + private val days = mutableListOf() // List of day timestamps in milliseconds + private var selectedDayInMs: Long? = null + private var localTimeZone: TimeZone = TimeZone.getDefault() + private var onDaySelected: ((Long) -> Unit)? = null + + var startInMs: Long? = null + @SuppressLint("NotifyDataSetChanged") + set(value) { + if (field == value) return + field = value + updateDays() + } + + var endInMs: Long? = null + @SuppressLint("NotifyDataSetChanged") + set(value) { + if (field == value) return + field = value + updateDays() + } + + fun setTimeZone(timeZone: TimeZone) { + localTimeZone = timeZone + dayNameFormat.timeZone = timeZone + monthNameFormat.timeZone = timeZone + } + + fun setOnDaySelectedListener(listener: (Long) -> Unit) { + onDaySelected = listener + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateDays() { + val startInMs = this.startInMs ?: return + val endInMs = this.endInMs ?: return + + days.clear() + + val calendar = startInMs.toCalendar(localTimeZone).beginningOfDay + val endCalendar = endInMs.toCalendar(localTimeZone).beginningOfDay + + while (calendar.timeInMillis <= endCalendar.timeInMillis) { + days.add(calendar.timeInMillis) + calendar.add(Calendar.DAY_OF_YEAR, 1) + } + + notifyDataSetChanged() + } + + @SuppressLint("NotifyDataSetChanged") + fun selectDay(dayInMs: Long, notifyAdapter: Boolean = true) { + val previousSelectedIndex = getSelectedPosition() + selectedDayInMs = dayInMs + val newSelectedIndex = getSelectedPosition() + if (previousSelectedIndex == newSelectedIndex) return + if (notifyAdapter) { + notifyDataSetChanged() // need to redraw all days to remove selected + } + } + + fun getPositionForDay(dayInMs: Long): Int { + val targetCalendar = dayInMs.toCalendar(localTimeZone).beginningOfDay + return days.indexOfFirst { dayMs -> + val dayCalendar = dayMs.toCalendar(localTimeZone).beginningOfDay + dayCalendar.isSameDay(targetCalendar) + } + } + + fun getSelectedPosition(): Int { + return selectedDayInMs?.let { getPositionForDay(it) } ?: -1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayViewHolder { + val binding = LayoutScheduleCalendarDayItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return DayViewHolder(binding) + } + + override fun onBindViewHolder(holder: DayViewHolder, position: Int) { + val dayInMs = days[position] + holder.bind(dayInMs) + } + + override fun getItemCount(): Int = days.size + + inner class DayViewHolder( + private val binding: LayoutScheduleCalendarDayItemBinding + ) : RecyclerView.ViewHolder(binding.root) { + + @get:ColorInt + private val pastTextColorInt: Int by lazy { UISchedule.getDefaultPastTextColor(binding.root.context) } + + @get:ColorInt + private val nowTextColorInt: Int by lazy { UISchedule.getDefaultNowTextColor(binding.root.context) } + + @get:ColorInt + private val futureTextColorInt: Int by lazy { UISchedule.getDefaultFutureTextColor(binding.root.context) } + + private val calendar = Calendar.getInstance(localTimeZone) + + fun bind(dayInMs: Long) = binding.apply { + calendar.timeInMillis = dayInMs + + val todayBeginning = UITimeUtils.currentTimeMillis().toCalendar(localTimeZone).beginningOfDay + val dayBeginning = dayInMs.toCalendar(localTimeZone).beginningOfDay + + val isSelected = selectedDayInMs?.let { selectedMs -> + val selectedBeginning = selectedMs.toCalendar(localTimeZone).beginningOfDay + dayBeginning.isSameDay(selectedBeginning) + } == true + + val isToday = dayBeginning.isSameDay(todayBeginning) + val isPast = dayInMs < todayBeginning.timeInMillis + + // Set text values + dayName.text = dayNameFormat.format(calendar.time) + dayNumber.text = calendar.get(Calendar.DAY_OF_MONTH).toString() + monthName.text = monthNameFormat.format(calendar.time) + + val typeface = if (isToday) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + // Make today bold + dayName.typeface = typeface + dayNumber.typeface = typeface + monthName.typeface = typeface + + // Update selection state + selectionIndicator.apply { + colorInt?.let { setBackgroundColor(it) } + isVisible = isSelected + } + // Set alpha for past days (75% = 0.75f) + val color = when { + isPast -> pastTextColorInt + isToday -> nowTextColorInt + else -> futureTextColorInt + } + dayName.setTextColor(color) + dayNumber.setTextColor(color) + monthName.setTextColor(color) + + root.setOnClickListener { + selectDay(dayInMs) + onDaySelected?.invoke(dayInMs) + } + } + } +} diff --git a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleAdapter.kt b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleAdapter.kt index 6155e5c6..90a13db4 100644 --- a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleAdapter.kt +++ b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleAdapter.kt @@ -15,12 +15,10 @@ import org.mtransit.android.commons.MTLog import org.mtransit.android.commons.SpanUtils import org.mtransit.android.commons.ThreadSafeDateFormatter import org.mtransit.android.commons.data.Accessibility -import org.mtransit.android.commons.data.Direction import org.mtransit.android.commons.data.RouteDirectionStop import org.mtransit.android.commons.data.Schedule import org.mtransit.android.commons.equalOrAfter import org.mtransit.android.data.UISchedule -import org.mtransit.android.data.decorateDirection import org.mtransit.android.data.makeHeading import org.mtransit.android.databinding.LayoutPoiDetailStatusScheduleDaySeparatorBinding import org.mtransit.android.databinding.LayoutPoiDetailStatusScheduleHourSeparatorBinding @@ -269,6 +267,26 @@ class ScheduleAdapter return null } + fun getScrollToDatePosition(dateInMs: Long): Int? { + if (!isReady()) return null + val localTimeZone = this.localTimeZone ?: return null + val targetCalendar = dateInMs.toCalendar(localTimeZone).beginningOfDay + + var index = 0 + this.dayToHourToTimestamps.forEach { (dayBeginningMs, hourToTimes) -> + val dayCal = dayBeginningMs.toCalendar(localTimeZone).beginningOfDay + if (dayCal.timeInMillis == targetCalendar.timeInMillis) { + return index // Return position of day separator + } + index++ // day separator + hourToTimes.forEach { _, hourTimes -> + index++ // hour separator + index += hourTimes.size // times in this hour + } + } + return null + } + private fun getTodaySelectPosition(): Int { nextTimestamp?.let { nextTimestamp -> val nextTimePosition: Int = getPosition(nextTimestamp) @@ -343,6 +361,16 @@ class ScheduleAdapter 1 // loading } + fun getItemTimestamp(position: Int): Long? { + return when (getItemViewType(position)) { + ITEM_VIEW_TYPE_TIME -> getTimestampItem(position)?.t + ITEM_VIEW_TYPE_DAY_SEPARATORS -> getDayItem(position) + ITEM_VIEW_TYPE_HOUR_SEPARATORS -> getHourItemTimestamp(position) + ITEM_VIEW_TYPE_LOADING -> null + else -> null + } + } + private fun getTimestampItem(position: Int): Schedule.Timestamp? { var index = 0 this.dayToHourToTimestamps.forEach { (_, hourToTimes) -> diff --git a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleFragment.kt b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleFragment.kt index b70311f4..02d6df25 100644 --- a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleFragment.kt +++ b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleFragment.kt @@ -17,10 +17,14 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.OnScrollListener import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.mtransit.android.R import org.mtransit.android.commons.ColorUtils import org.mtransit.android.commons.MTLog @@ -105,6 +109,10 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), private var binding: FragmentScheduleInfiniteBinding? = null + private val horizontalCalendarAdapter: HorizontalCalendarAdapter by lazy { + HorizontalCalendarAdapter() + } + private val listAdapter: ScheduleAdapter by lazy { ScheduleAdapter() } @@ -129,6 +137,47 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), } } } + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + updateCurrentSelectedDayJob?.cancel() + onRecyclerViewScrolling(recyclerView) + return + } + var debounceInMs = 33L + updateCurrentSelectedDayJob?.cancel() + updateCurrentSelectedDayJob = this@ScheduleFragment.lifecycleScope.launch { + while (true) { + onRecyclerViewScrolling(recyclerView) + delay(debounceInMs) // debounce / throttle + } + } + } + + private fun onRecyclerViewScrolling(recyclerView: RecyclerView) { + val scrollPosition = (recyclerView.layoutManager as? LinearLayoutManager) + ?.findFirstCompletelyVisibleItemPosition() + ?: -1 + scrollPosition.takeIf { it >= 0 } ?: return + listAdapter.getItemTimestamp(scrollPosition)?.let { + attachedViewModel?.setSelectedDate(it) + } + } + + private var updateCurrentSelectedDayJob: Job? = null + } + + private val calendarScrollListener = object : OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + (recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager -> + // Load more days when scrolling near the end + val loadingPosition: Int = recyclerView.adapter?.itemCount?.minus(1) ?: -1 + if (linearLayoutManager.findLastCompletelyVisibleItemPosition() == loadingPosition) { + recyclerView.post { + attachedViewModel?.increaseEndTime() + } + } + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -145,6 +194,19 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), setUpListEdgeToEdge() } setupScreenToolbar(screenToolbarLayout) + // Initialize horizontal calendar with RecyclerView + horizontalCalendar.apply { + isVisible = UIFeatureFlags.F_SCHEDULE_HORIZONTAL_CALENDAR + adapter = horizontalCalendarAdapter + addOnScrollListener(calendarScrollListener) + } + horizontalCalendarAdapter.setOnDaySelectedListener { selectedDateInMs -> + viewModel.setSelectedDate(selectedDateInMs) + // Scroll list to the selected date + listAdapter.getScrollToDatePosition(selectedDateInMs)?.let { position -> + list.scrollToPositionWithOffset(position, 0) + } + } if (UIFeatureFlags.F_EDGE_TO_EDGE_NAV_BAR_BELOW) { sourceLabel.applyWindowInsetsEdgeToEdge(WindowInsetsCompat.Type.navigationBars(), consumed = false) { insets -> updateLayoutParams { @@ -155,18 +217,36 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), } } } + viewModel.selectedDateBeginningOfDayInMs.observe(viewLifecycleOwner) { selectedDateBeginningOfDayInMs -> + selectedDateBeginningOfDayInMs ?: return@observe + binding?.apply { + horizontalCalendarAdapter.getPositionForDay(selectedDateBeginningOfDayInMs) + .takeIf { it >= 0 } + ?.let { selectedPosition -> + val childWidth = horizontalCalendar.getChildAt(selectedPosition)?.width?.div(2) ?: 0 + val offset = (horizontalCalendar.width / 2) + childWidth + horizontalCalendar.scrollToPositionWithOffset(selectedPosition, offset) + horizontalCalendarAdapter.selectDay(selectedDateBeginningOfDayInMs, notifyAdapter = true) + } + } + } viewModel.localTimeZone.observe(viewLifecycleOwner) { localTimeZone -> listAdapter.localTimeZone = localTimeZone bindLocaleTime(localTimeZone) + // Update calendar timezone + localTimeZone?.let { tz -> + horizontalCalendarAdapter.setTimeZone(tz) + } } viewModel.startEndAt.observe(viewLifecycleOwner) { (startInMs, endInMs) -> val scrollPosition = (binding?.list?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() ?: -1 listAdapter.startInMs = startInMs listAdapter.endInMs = endInMs - binding?.list?.apply { - if (scrollPosition > 0) { - scrollToPosition(scrollPosition) - } + // Synchronize calendar with schedule list + horizontalCalendarAdapter.startInMs = startInMs + horizontalCalendarAdapter.endInMs = endInMs + binding?.apply { + scrollPosition.takeIf { it > 0 }?.let { list.scrollToPosition(it) } } } viewModel.scrolledToNow.observe(viewLifecycleOwner) { @@ -183,8 +263,11 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), binding?.apply { if (timestamps != null) { if (viewModel.scrolledToNow.value == false) { - listAdapter.getScrollToNowPosition()?.let { - list.scrollToPositionWithOffset(it, 48.dp) + listAdapter.getScrollToNowPosition()?.let { position -> + list.scrollToPositionWithOffset(position, 48.dp) + listAdapter.getItemTimestamp(position)?.let { timestamp -> + viewModel.setSelectedDate(timestamp) + } } viewModel.setScrolledToNow(true) } else if (scrollPosition > 0) { @@ -203,8 +286,9 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), (activity as MainActivity?)?.popFragmentFromStack(this) // close this fragment } }) - viewModel.colorInt.observe(viewLifecycleOwner) { + viewModel.colorInt.observe(viewLifecycleOwner) { colorInt -> abController?.setABBgColor(this, getABBgColor(context), false) + horizontalCalendarAdapter.colorInt = colorInt } viewModel.agency.observe(viewLifecycleOwner) { abController?.setABSubtitle(this, getABSubtitle(context), false) @@ -245,8 +329,11 @@ class ScheduleFragment : ABFragment(R.layout.fragment_schedule_infinite), return when (menuItem.itemId) { R.id.menu_today -> { binding?.apply { - listAdapter.getScrollToNowPosition()?.let { - this.list.scrollToPositionWithOffset(it, 48.dp) + listAdapter.getScrollToNowPosition()?.let { position -> + list.scrollToPositionWithOffset(position, 48.dp) + listAdapter.getItemTimestamp(position)?.let { timestamp -> + viewModel.setSelectedDate(timestamp) + } } viewModel.setScrolledToNow(true) } diff --git a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleViewModel.kt b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleViewModel.kt index 05fef625..21f26506 100644 --- a/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleViewModel.kt +++ b/app-android/src/main/java/org/mtransit/android/ui/schedule/ScheduleViewModel.kt @@ -33,6 +33,7 @@ import org.mtransit.android.ui.view.common.getLiveDataDistinct import org.mtransit.android.util.UITimeUtils import org.mtransit.commons.beginningOfDay import org.mtransit.commons.toCalendar +import java.util.Calendar import java.util.TimeZone import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -66,6 +67,7 @@ class ScheduleViewModel @Inject constructor( private const val EXTRA_START_AT_DAYS_BEFORE = "extra_start_at_days_before" private const val EXTRA_END_AT_DAYS_AFTER = "extra_end_at_days_after" private const val LOCAL_TIME_ZONE_ID = "local_time_zone_id" + private const val EXTRA_SELECTED_DATE_IN_MS = "extra_selected_date_in_ms" } override fun getLogTag(): String = LOG_TAG @@ -143,6 +145,19 @@ class ScheduleViewModel @Inject constructor( savedStateHandle[EXTRA_SCROLLED_TO_NOW] = scrolledToNow } + val selectedDateBeginningOfDayInMs = savedStateHandle.getLiveDataDistinct(EXTRA_SELECTED_DATE_IN_MS) + + fun setSelectedDate(dateInMs: Long?) { + dateInMs ?: return + dateInMs.toCalendar().beginningOfDay.let { beginningOfDay -> + setSelectedDateBeginningOfDay(beginningOfDay) + } + } + + fun setSelectedDateBeginningOfDay(beginningOfDay: Calendar) { + savedStateHandle[EXTRA_SELECTED_DATE_IN_MS] = beginningOfDay.timeInMillis + } + private val _scheduleProviders: LiveData> = this.authority.switchMap { authority -> this.dataSourcesRepository.readingScheduleProviders(authority) } diff --git a/app-android/src/main/java/org/mtransit/android/ui/type/rds/RDSAgencyRoutesAdapter.kt b/app-android/src/main/java/org/mtransit/android/ui/type/rds/RDSAgencyRoutesAdapter.kt index b45f6062..9b605963 100644 --- a/app-android/src/main/java/org/mtransit/android/ui/type/rds/RDSAgencyRoutesAdapter.kt +++ b/app-android/src/main/java/org/mtransit/android/ui/type/rds/RDSAgencyRoutesAdapter.kt @@ -12,7 +12,6 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.mtransit.android.R import org.mtransit.android.commons.MTLog -import org.mtransit.android.commons.data.ServiceUpdate import org.mtransit.android.commons.data.distinctByOriginalId import org.mtransit.android.commons.data.isSeverityWarningInfo import org.mtransit.android.commons.dp diff --git a/app-android/src/main/java/org/mtransit/android/util/UIFeatureFlags.kt b/app-android/src/main/java/org/mtransit/android/util/UIFeatureFlags.kt index 1cdc4b56..ab5db4a8 100644 --- a/app-android/src/main/java/org/mtransit/android/util/UIFeatureFlags.kt +++ b/app-android/src/main/java/org/mtransit/android/util/UIFeatureFlags.kt @@ -38,4 +38,7 @@ object UIFeatureFlags { const val F_NEWS_THUMBNAIL_PLAY_BUTTON = false // const val F_NEWS_THUMBNAIL_PLAY_BUTTON = true // WIP + const val F_SCHEDULE_HORIZONTAL_CALENDAR = false + // const val F_SCHEDULE_HORIZONTAL_CALENDAR = true // WIP + } \ No newline at end of file diff --git a/app-android/src/main/res/layout/fragment_schedule_infinite.xml b/app-android/src/main/res/layout/fragment_schedule_infinite.xml index 0bc20271..272d570e 100644 --- a/app-android/src/main/res/layout/fragment_schedule_infinite.xml +++ b/app-android/src/main/res/layout/fragment_schedule_infinite.xml @@ -11,57 +11,78 @@ android:id="@+id/screen_toolbar_layout" layout="@layout/layout_screen_toolbar" /> - - - - - - - - + android:layout_height="0dp" + android:layout_weight="1"> + + + + + + + + + + + + - + \ No newline at end of file diff --git a/app-android/src/main/res/layout/layout_schedule_calendar_day_item.xml b/app-android/src/main/res/layout/layout_schedule_calendar_day_item.xml new file mode 100644 index 00000000..0e38ad27 --- /dev/null +++ b/app-android/src/main/res/layout/layout_schedule_calendar_day_item.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + +