1
0
Fork 0
mirror of https://github.com/MatomoCamp/matomocamp-companion-android.git synced 2024-09-19 16:13:46 +02:00

Migrate from LiveData to Kotlin Flow (#77)

- Replace all usages of LiveData with Flow and StateFlow
- Replace Paging 2 with Flow-based Paging 3
- Inject custom ViewModel arguments directly through custom ViewModel Factories.
This commit is contained in:
Christophe Beyls 2022-02-03 17:30:55 +01:00 committed by GitHub
parent 2b96e266cd
commit 3f6c9d6219
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1041 additions and 748 deletions

View file

@ -118,9 +118,9 @@ dependencies {
implementation 'androidx.browser:browser:1.4.0' implementation 'androidx.browser:browser:1.4.0'
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation 'androidx.paging:paging-runtime:3.1.0'
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-paging:$room_version"
implementation "androidx.datastore:datastore-preferences:1.0.0" implementation "androidx.datastore:datastore-preferences:1.0.0"
kapt "androidx.room:room-compiler:$room_version" kapt "androidx.room:room-compiler:$room_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version"

View file

@ -12,10 +12,12 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.add import androidx.fragment.app.add
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.extractNfcAppData import be.digitalia.fosdem.utils.extractNfcAppData
import be.digitalia.fosdem.utils.hasNfcAppData import be.digitalia.fosdem.utils.hasNfcAppData
import be.digitalia.fosdem.utils.isLightTheme import be.digitalia.fosdem.utils.isLightTheme
@ -29,6 +31,7 @@ import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
import be.digitalia.fosdem.viewmodels.EventViewModel import be.digitalia.fosdem.viewmodels.EventViewModel
import be.digitalia.fosdem.widgets.setupBookmarkStatus import be.digitalia.fosdem.widgets.setupBookmarkStatus
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** /**
* Displays a single event passed either as a complete Parcelable object in extras or as an id in data. * Displays a single event passed either as a complete Parcelable object in extras or as an id in data.
@ -39,7 +42,20 @@ import dagger.hilt.android.AndroidEntryPoint
class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfcAppDataCallback { class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfcAppDataCallback {
private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels() private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels()
private val viewModel: EventViewModel by viewModels() @Inject
lateinit var viewModelFactory: EventViewModel.Factory
private val viewModel: EventViewModel by assistedViewModels {
// Load the event from the DB using its id
val intent = intent
val eventIdString = if (intent.hasNfcAppData()) {
// NFC intent
intent.extractNfcAppData().toEventIdString()
} else {
// Normal in-app intent
intent.dataString!!
}
viewModelFactory.create(eventIdString.toLong())
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -59,23 +75,11 @@ class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfc
} }
} }
} else { } else {
// Load the event from the DB using its id lifecycleScope.launchWhenStarted {
if (!viewModel.isEventIdSet) { val event = viewModel.event.await()
val intent = intent
val eventIdString = if (intent.hasNfcAppData()) {
// NFC intent
intent.extractNfcAppData().toEventIdString()
} else {
// Normal in-app intent
intent.dataString!!
}
viewModel.setEventId(eventIdString.toLong())
}
viewModel.event.observe(this) { event ->
if (event == null) { if (event == null) {
// Event not found, quit // Event not found, quit
Toast.makeText(this, getString(R.string.event_not_found_error), Toast.LENGTH_LONG).show() Toast.makeText(this@EventDetailsActivity, getString(R.string.event_not_found_error), Toast.LENGTH_LONG).show()
finish() finish()
} else { } else {
initEvent(event) initEvent(event)
@ -84,7 +88,7 @@ class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfc
if (fm.findFragmentById(R.id.content) == null) { if (fm.findFragmentById(R.id.content) == null) {
fm.commit(allowStateLoss = true) { fm.commit(allowStateLoss = true) {
add<EventDetailsFragment>(R.id.content, add<EventDetailsFragment>(R.id.content,
args = EventDetailsFragment.createArguments(event)) args = EventDetailsFragment.createArguments(event))
} }
} }
} }

View file

@ -38,6 +38,7 @@ import be.digitalia.fosdem.model.LoadingState
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.awaitCloseDrawer import be.digitalia.fosdem.utils.awaitCloseDrawer
import be.digitalia.fosdem.utils.configureToolbarColors import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
@ -107,48 +108,54 @@ class MainActivity : AppCompatActivity(R.layout.main), CreateNfcAppDataCallback
val progressIndicator: BaseProgressIndicator<*> = findViewById(R.id.progress) val progressIndicator: BaseProgressIndicator<*> = findViewById(R.id.progress)
// Monitor the schedule download // Monitor the schedule download
api.downloadScheduleState.observe(this) { state -> launchAndRepeatOnLifecycle {
when (state) { api.downloadScheduleState.collect { state ->
is LoadingState.Loading -> { when (state) {
with(progressIndicator) { is LoadingState.Loading -> {
when (val progressValue = state.progress) { with(progressIndicator) {
-1 -> if (!isIndeterminate) { when (val progressValue = state.progress) {
isInvisible = true -1 -> if (!isIndeterminate) {
isIndeterminate = true isInvisible = true
} isIndeterminate = true
else -> setProgressCompat(progressValue, true)
}
show()
}
}
is LoadingState.Idle -> {
with(progressIndicator) {
// Fix: stop transitioning to determinate when hiding
isIndeterminate = false
setProgressCompat(100, false)
hide()
}
state.result.consume()?.let { result ->
val snackbar = when (result) {
is DownloadScheduleResult.Error -> {
Snackbar.make(contentView, R.string.schedule_loading_error, ERROR_MESSAGE_DISPLAY_DURATION)
.setAction(R.string.schedule_loading_retry_action) { api.downloadSchedule() }
}
is DownloadScheduleResult.UpToDate -> {
Snackbar.make(contentView, R.string.events_download_up_to_date, Snackbar.LENGTH_LONG)
}
is DownloadScheduleResult.Success -> {
val eventsCount = result.eventsCount
val message = if (eventsCount == 0) {
getString(R.string.events_download_empty)
} else {
resources.getQuantityString(R.plurals.events_download_completed, eventsCount, eventsCount)
} }
Snackbar.make(contentView, message, Snackbar.LENGTH_LONG) else -> setProgressCompat(progressValue, true)
} }
show()
}
}
is LoadingState.Idle -> {
with(progressIndicator) {
// Fix: stop transitioning to determinate when hiding
isIndeterminate = false
setProgressCompat(100, false)
hide()
}
state.result?.let { result ->
val snackbar = when (result) {
is DownloadScheduleResult.Error -> {
Snackbar.make(contentView, R.string.schedule_loading_error, ERROR_MESSAGE_DISPLAY_DURATION)
.setAction(R.string.schedule_loading_retry_action) { api.downloadSchedule() }
}
is DownloadScheduleResult.UpToDate -> {
Snackbar.make(contentView, R.string.events_download_up_to_date, Snackbar.LENGTH_LONG)
}
is DownloadScheduleResult.Success -> {
val eventsCount = result.eventsCount
val message = if (eventsCount == 0) {
getString(R.string.events_download_empty)
} else {
resources.getQuantityString(R.plurals.events_download_completed, eventsCount, eventsCount)
}
Snackbar.make(contentView, message, Snackbar.LENGTH_LONG)
}
}
snackbar.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar, event: Int) {
api.downloadScheduleResultConsumed()
}
}).show()
} }
snackbar.show()
} }
} }
} }

View file

@ -18,6 +18,7 @@ import be.digitalia.fosdem.api.FosdemUrls
import be.digitalia.fosdem.utils.configureToolbarColors import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.invertImageColors import be.digitalia.fosdem.utils.invertImageColors
import be.digitalia.fosdem.utils.isLightTheme import be.digitalia.fosdem.utils.isLightTheme
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.toSlug import be.digitalia.fosdem.utils.toSlug
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -77,14 +78,15 @@ class RoomImageDialogActivity : AppCompatActivity(R.layout.dialog_room_image) {
} }
} }
// Display the room status as subtitle owner.launchAndRepeatOnLifecycle {
api.roomStatuses.observe(owner) { roomStatuses -> // Display the room status as subtitle
val roomStatus = roomStatuses[roomName] api.roomStatuses.collect { statuses ->
toolbar.subtitle = if (roomStatus != null) { toolbar.subtitle = statuses[roomName]?.let { roomStatus ->
SpannableString(context.getString(roomStatus.nameResId)).apply { SpannableString(context.getString(roomStatus.nameResId)).apply {
this[0, length] = ForegroundColorSpan(ContextCompat.getColor(context, roomStatus.colorResId)) this[0, length] = ForegroundColorSpan(ContextCompat.getColor(context, roomStatus.colorResId))
}
} }
} else null }
} }
} }
} }

View file

@ -11,16 +11,17 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.add import androidx.fragment.app.add
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.fragment.app.commitNow
import androidx.fragment.app.replace import androidx.fragment.app.replace
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.fragments.RoomImageDialogFragment import be.digitalia.fosdem.fragments.RoomImageDialogFragment
import be.digitalia.fosdem.fragments.TrackScheduleListFragment import be.digitalia.fosdem.fragments.TrackScheduleListFragment
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.isLightTheme import be.digitalia.fosdem.utils.isLightTheme
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable
import be.digitalia.fosdem.utils.setTaskColorPrimary import be.digitalia.fosdem.utils.setTaskColorPrimary
import be.digitalia.fosdem.utils.statusBarColorCompat import be.digitalia.fosdem.utils.statusBarColorCompat
@ -99,26 +100,27 @@ class TrackScheduleActivity : AppCompatActivity(R.layout.track_schedule), Create
if (isTabletLandscape) { if (isTabletLandscape) {
// Tablet mode: Show event details in the right pane fragment // Tablet mode: Show event details in the right pane fragment
viewModel.selectedEvent.observe(this) { event: Event? -> launchAndRepeatOnLifecycle {
val currentFragment = fm.findFragmentById(R.id.event) as EventDetailsFragment? viewModel.selectedEventFlow.collect { event ->
if (event != null) { val currentFragment = fm.findFragmentById(R.id.event) as EventDetailsFragment?
// Only replace the fragment if the event is different if (event != null) {
if (currentFragment?.event != event) { // Only replace the fragment if the event is different
// Allow state loss since the event fragment will be synchronized with the list selection after activity re-creation if (currentFragment?.event != event) {
fm.commit { fm.commitNow {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
replace<EventDetailsFragment>(R.id.event, replace<EventDetailsFragment>(R.id.event,
args = EventDetailsFragment.createArguments(event)) args = EventDetailsFragment.createArguments(event))
}
}
} else {
// Nothing is selected because the list is empty
if (currentFragment != null) {
fm.commitNow { remove(currentFragment) }
} }
} }
} else {
// Nothing is selected because the list is empty
if (currentFragment != null) {
fm.commit { remove(currentFragment) }
}
}
bookmarkStatusViewModel.event = event bookmarkStatusViewModel.event = event
}
} }
findViewById<ImageButton?>(R.id.fab)?.setupBookmarkStatus(bookmarkStatusViewModel, this) findViewById<ImageButton?>(R.id.fab)?.setupBookmarkStatus(bookmarkStatusViewModel, this)
@ -138,7 +140,7 @@ class TrackScheduleActivity : AppCompatActivity(R.layout.track_schedule), Create
// CreateNfcAppDataCallback // CreateNfcAppDataCallback
override fun createNfcAppData(): NdefRecord? { override fun createNfcAppData(): NdefRecord? {
return viewModel.selectedEvent.value?.toNfcAppData(this) return viewModel.selectedEvent?.toNfcAppData(this)
} }
companion object { companion object {

View file

@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
@ -20,6 +21,7 @@ import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.enforceSingleScrollDirection import be.digitalia.fosdem.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.instantiate import be.digitalia.fosdem.utils.instantiate
import be.digitalia.fosdem.utils.isLightTheme import be.digitalia.fosdem.utils.isLightTheme
@ -34,6 +36,7 @@ import be.digitalia.fosdem.viewmodels.TrackScheduleEventViewModel
import be.digitalia.fosdem.widgets.ContentLoadingViewMediator import be.digitalia.fosdem.widgets.ContentLoadingViewMediator
import be.digitalia.fosdem.widgets.setupBookmarkStatus import be.digitalia.fosdem.widgets.setupBookmarkStatus
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** /**
* Event view of the track schedule; allows to slide between events of the same track using a ViewPager. * Event view of the track schedule; allows to slide between events of the same track using a ViewPager.
@ -44,16 +47,23 @@ import dagger.hilt.android.AndroidEntryPoint
class TrackScheduleEventActivity : AppCompatActivity(R.layout.track_schedule_event), CreateNfcAppDataCallback { class TrackScheduleEventActivity : AppCompatActivity(R.layout.track_schedule_event), CreateNfcAppDataCallback {
private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels() private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels()
private val viewModel: TrackScheduleEventViewModel by viewModels() @Inject
lateinit var viewModelFactory: TrackScheduleEventViewModel.Factory
private val viewModel: TrackScheduleEventViewModel by assistedViewModels {
viewModelFactory.create(day, track)
}
private val day: Day by lazy(LazyThreadSafetyMode.NONE) {
intent.getParcelableExtra(EXTRA_DAY)!!
}
private val track: Track by lazy(LazyThreadSafetyMode.NONE) {
intent.getParcelableExtra(EXTRA_TRACK)!!
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setSupportActionBar(findViewById(R.id.bottom_appbar)) setSupportActionBar(findViewById(R.id.bottom_appbar))
val intent = intent
val day: Day = intent.getParcelableExtra(EXTRA_DAY)!!
val track: Track = intent.getParcelableExtra(EXTRA_TRACK)!!
val progress = ContentLoadingViewMediator(findViewById(R.id.progress)) val progress = ContentLoadingViewMediator(findViewById(R.id.progress))
val pager: ViewPager2 = findViewById(R.id.pager) val pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection() pager.recyclerView.enforceSingleScrollDirection()
@ -92,28 +102,25 @@ class TrackScheduleEventActivity : AppCompatActivity(R.layout.track_schedule_eve
progress.isVisible = true progress.isVisible = true
with(viewModel) { lifecycleScope.launchWhenStarted {
setDayAndTrack(day, track) val events = viewModel.scheduleSnapshot.await()
scheduleSnapshot.observe(this@TrackScheduleEventActivity) { events -> progress.isVisible = false
progress.isVisible = false
pager.isVisible = true pager.isVisible = true
adapter.events = events adapter.events = events
// Delay setting the adapter // Delay setting the adapter to ensure the current position is restored properly
// to ensure the current position is restored properly if (pager.adapter == null) {
if (pager.adapter == null) { pager.adapter = adapter
pager.adapter = adapter
if (initialEventId != -1L) { if (initialEventId != -1L) {
val position = events.indexOfFirst { it.id == initialEventId } val position = events.indexOfFirst { it.id == initialEventId }
if (position != -1) { if (position != -1) {
pager.setCurrentItem(position, false) pager.setCurrentItem(position, false)
}
} }
bookmarkStatusViewModel.event = adapter.events.getOrNull(pager.currentItem)
} }
bookmarkStatusViewModel.event = adapter.events.getOrNull(pager.currentItem)
} }
} }

View file

@ -35,10 +35,12 @@ class BookmarksAdapter(context: Context, private val multiChoiceHelper: MultiCho
private val errorColor: Int private val errorColor: Int
private val observers = SimpleArrayMap<AdapterDataObserver, BookmarksDataObserverWrapper>() private val observers = SimpleArrayMap<AdapterDataObserver, BookmarksDataObserverWrapper>()
var roomStatuses: Map<String, RoomStatus>? = null var roomStatuses: Map<String, RoomStatus> = emptyMap()
set(value) { set(value) {
field = value if (field != value) {
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD) field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
} }
init { init {
@ -56,16 +58,12 @@ class BookmarksAdapter(context: Context, private val multiChoiceHelper: MultiCho
return ViewHolder(view, multiChoiceHelper, timeFormatter, errorColor) return ViewHolder(view, multiChoiceHelper, timeFormatter, errorColor)
} }
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.get(event.roomName)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val event = getItem(position) val event = getItem(position)
holder.bind(event) holder.bind(event)
val previous = if (position > 0) getItem(position - 1) else null val previous = if (position > 0) getItem(position - 1) else null
val next = if (position + 1 < itemCount) getItem(position + 1) else null val next = if (position + 1 < itemCount) getItem(position + 1) else null
holder.bindDetails(event, previous, next, getRoomStatus(event)) holder.bindDetails(event, previous, next, roomStatuses[event.roomName])
holder.bindSelection() holder.bindSelection()
} }
@ -77,7 +75,7 @@ class BookmarksAdapter(context: Context, private val multiChoiceHelper: MultiCho
if (DETAILS_PAYLOAD in payloads) { if (DETAILS_PAYLOAD in payloads) {
val previous = if (position > 0) getItem(position - 1) else null val previous = if (position > 0) getItem(position - 1) else null
val next = if (position + 1 < itemCount) getItem(position + 1) else null val next = if (position + 1 < itemCount) getItem(position + 1) else null
holder.bindDetails(event, previous, next, getRoomStatus(event)) holder.bindDetails(event, previous, next, roomStatuses[event.roomName])
} }
if (MultiChoiceHelper.SELECTION_PAYLOAD in payloads) { if (MultiChoiceHelper.SELECTION_PAYLOAD in payloads) {
holder.bindSelection() holder.bindSelection()

View file

@ -12,7 +12,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.set import androidx.core.text.set
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity import be.digitalia.fosdem.activities.EventDetailsActivity
@ -23,14 +23,16 @@ import be.digitalia.fosdem.utils.DateUtils
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
class EventsAdapter constructor(context: Context, private val showDay: Boolean = true) : class EventsAdapter constructor(context: Context, private val showDay: Boolean = true) :
PagedListAdapter<StatusEvent, EventsAdapter.ViewHolder>(DIFF_CALLBACK) { PagingDataAdapter<StatusEvent, EventsAdapter.ViewHolder>(DIFF_CALLBACK) {
private val timeFormatter = DateUtils.getTimeFormatter(context) private val timeFormatter = DateUtils.getTimeFormatter(context)
var roomStatuses: Map<String, RoomStatus>? = null var roomStatuses: Map<String, RoomStatus> = emptyMap()
set(value) { set(value) {
field = value if (field != value) {
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD) field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
} }
override fun getItemViewType(position: Int) = R.layout.item_event override fun getItemViewType(position: Int) = R.layout.item_event
@ -40,10 +42,6 @@ class EventsAdapter constructor(context: Context, private val showDay: Boolean =
return ViewHolder(view, timeFormatter) return ViewHolder(view, timeFormatter)
} }
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.get(event.roomName)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val statusEvent = getItem(position) val statusEvent = getItem(position)
if (statusEvent == null) { if (statusEvent == null) {
@ -51,7 +49,7 @@ class EventsAdapter constructor(context: Context, private val showDay: Boolean =
} else { } else {
val event = statusEvent.event val event = statusEvent.event
holder.bind(event, statusEvent.isBookmarked) holder.bind(event, statusEvent.isBookmarked)
holder.bindDetails(event, showDay, getRoomStatus(event)) holder.bindDetails(event, showDay, roomStatuses[event.roomName])
} }
} }
@ -63,7 +61,7 @@ class EventsAdapter constructor(context: Context, private val showDay: Boolean =
if (statusEvent != null) { if (statusEvent != null) {
if (DETAILS_PAYLOAD in payloads) { if (DETAILS_PAYLOAD in payloads) {
val event = statusEvent.event val event = statusEvent.event
holder.bindDetails(event, showDay, getRoomStatus(event)) holder.bindDetails(event, showDay, roomStatuses[event.roomName])
} }
} }
} }

View file

@ -2,16 +2,11 @@ package be.digitalia.fosdem.api
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory.scheduler import be.digitalia.fosdem.flow.flowWhileShared
import be.digitalia.fosdem.livedata.SingleEvent import be.digitalia.fosdem.flow.schedulerFlow
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.model.DownloadScheduleResult import be.digitalia.fosdem.model.DownloadScheduleResult
import be.digitalia.fosdem.model.LoadingState import be.digitalia.fosdem.model.LoadingState
import be.digitalia.fosdem.model.RoomStatus import be.digitalia.fosdem.model.RoomStatus
@ -22,9 +17,20 @@ import be.digitalia.fosdem.utils.ByteCountSource
import be.digitalia.fosdem.utils.DateUtils import be.digitalia.fosdem.utils.DateUtils
import be.digitalia.fosdem.utils.network.HttpClient import be.digitalia.fosdem.utils.network.HttpClient
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okio.buffer import okio.buffer
import java.time.LocalTime import java.time.LocalTime
@ -45,12 +51,13 @@ class FosdemApi @Inject constructor(
private val alarmManager: AppAlarmManager private val alarmManager: AppAlarmManager
) { ) {
private var downloadJob: Job? = null private var downloadJob: Job? = null
private val _downloadScheduleState = MutableLiveData<LoadingState<DownloadScheduleResult>>() private val _downloadScheduleState =
MutableStateFlow<LoadingState<DownloadScheduleResult>>(LoadingState.Idle())
/** /**
* Download & store the schedule to the database. * Download & store the schedule to the database.
* Only a single Job will be active at a time. * Only a single Job will be active at a time.
* The result will be sent back through downloadScheduleResult LiveData. * The result will be notified through downloadScheduleState StateFlow.
*/ */
@MainThread @MainThread
fun downloadSchedule(): Job { fun downloadSchedule(): Job {
@ -73,7 +80,7 @@ class FosdemApi @Inject constructor(
ByteCountSource(body.source(), length / 10L) { byteCount -> ByteCountSource(body.source(), length / 10L) { byteCount ->
// Cap percent to 100 // Cap percent to 100
val percent = (byteCount * 100L / length).toInt().coerceAtMost(100) val percent = (byteCount * 100L / length).toInt().coerceAtMost(100)
_downloadScheduleState.postValue(LoadingState.Loading(percent)) _downloadScheduleState.value = LoadingState.Loading(percent)
}.buffer() }.buffer()
} else { } else {
body.source() body.source()
@ -92,56 +99,68 @@ class FosdemApi @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
DownloadScheduleResult.Error DownloadScheduleResult.Error
} }
_downloadScheduleState.value = LoadingState.Idle(SingleEvent(res)) _downloadScheduleState.value = LoadingState.Idle(res)
} }
val downloadScheduleState: LiveData<LoadingState<DownloadScheduleResult>> val downloadScheduleState: StateFlow<LoadingState<DownloadScheduleResult>> =
get() = _downloadScheduleState _downloadScheduleState.asStateFlow()
val roomStatuses: LiveData<Map<String, RoomStatus>> by lazy(LazyThreadSafetyMode.NONE) { fun downloadScheduleResultConsumed() {
// The room statuses will only be loaded when the event is live. _downloadScheduleState.update { state ->
// Use the days from the database to determine it. if (state is LoadingState.Idle) LoadingState.Idle() else state
val scheduler = scheduleDao.days.asLiveData().distinctUntilChanged().switchMap { days -> }
val startEndTimestamps = LongArray(days.size * 2) }
var index = 0
for (day in days) { @OptIn(ExperimentalCoroutinesApi::class)
startEndTimestamps[index++] = day.date.atTime(DAY_START_TIME) val roomStatuses: Flow<Map<String, RoomStatus>> by lazy(LazyThreadSafetyMode.NONE) {
.atZone(DateUtils.conferenceZoneId) stateFlow(BackgroundWorkScope, emptyMap()) { subscriptionCount ->
.toEpochSecond() * 1000L // The room statuses will only be loaded when the event is live.
startEndTimestamps[index++] = day.date.atTime(DAY_END_TIME) // Use the days from the database to determine it.
.atZone(DateUtils.conferenceZoneId) val scheduler = scheduleDao.days.flatMapLatest { days ->
.toEpochSecond() * 1000L val startEndTimestamps = LongArray(days.size * 2)
} var index = 0
scheduler(*startEndTimestamps) for (day in days) {
startEndTimestamps[index++] = day.date.atTime(DAY_START_TIME)
.atZone(DateUtils.conferenceZoneId)
.toEpochSecond() * 1000L
startEndTimestamps[index++] = day.date.atTime(DAY_END_TIME)
.atZone(DateUtils.conferenceZoneId)
.toEpochSecond() * 1000L
}
schedulerFlow(*startEndTimestamps)
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
}
scheduler.distinctUntilChanged().flatMapLatest { isLive ->
if (isLive) {
buildLiveRoomStatusesFlow()
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed(5000L))
}
else flowOf(emptyMap())
}
} }
val liveRoomStatuses = buildLiveRoomStatusesLiveData()
val offlineRoomStatuses = MutableLiveData(emptyMap<String, RoomStatus>())
scheduler.switchMap { isLive -> if (isLive) liveRoomStatuses else offlineRoomStatuses }
// Implementors: replace the above code block with the next line to disable room status support // Implementors: replace the above code block with the next line to disable room status support
// MutableLiveData() // emptyFlow()
} }
/** /**
* Builds a LiveData instance which loads and refreshes the Room statuses during the event. * Builds a stateful cold Flow which loads and refreshes the Room statuses during the event.
*/ */
private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> { private fun buildLiveRoomStatusesFlow(): Flow<Map<String, RoomStatus>> {
var nextRefreshTime = 0L var nextRefreshTime = 0L
var expirationTime = Long.MAX_VALUE var expirationTime = Long.MAX_VALUE
var retryAttempt = 0 var retryAttempt = 0
return liveData { return flow {
var now = SystemClock.elapsedRealtime() var now = SystemClock.elapsedRealtime()
var nextRefreshDelay = nextRefreshTime - now var nextRefreshDelay = nextRefreshTime - now
if (now > expirationTime && latestValue?.isEmpty() == false) { if (now > expirationTime) {
// When the data expires, replace it with an empty value // When the data expires, replace it with an empty value
emit(emptyMap()) emit(emptyMap())
} }
while (true) { while (true) {
if (nextRefreshDelay > 0) { delay(nextRefreshDelay)
delay(nextRefreshDelay)
}
nextRefreshDelay = try { nextRefreshDelay = try {
val response = httpClient.get(FosdemUrls.rooms) { body, _ -> val response = httpClient.get(FosdemUrls.rooms) { body, _ ->
@ -159,7 +178,7 @@ class FosdemApi @Inject constructor(
} }
now = SystemClock.elapsedRealtime() now = SystemClock.elapsedRealtime()
if (now > expirationTime && latestValue?.isEmpty() == false) { if (now > expirationTime) {
emit(emptyMap()) emit(emptyMap())
} }

View file

@ -1,7 +1,5 @@
package be.digitalia.fosdem.db package be.digitalia.fosdem.db
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
@ -10,16 +8,21 @@ import androidx.room.Transaction
import androidx.room.TypeConverters import androidx.room.TypeConverters
import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.db.entities.EventEntity
import be.digitalia.fosdem.model.AlarmInfo import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import kotlinx.coroutines.flow.StateFlow
import java.time.Instant import java.time.Instant
@Dao @Dao
abstract class BookmarksDao(private val appDatabase: AppDatabase) { abstract class BookmarksDao(appDatabase: AppDatabase) {
val version: StateFlow<Int> =
appDatabase.createVersionFlow(EventEntity.TABLE_NAME, Bookmark.TABLE_NAME)
/** /**
* Returns the bookmarks. * Returns the bookmarks.
* *
* @param minStartTime When greater than Instant.EPOCH, only return the events starting after this time. * @param minStartTime Only return the events starting after this time.
*/ */
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, @Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
@ -34,21 +37,7 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
@TypeConverters(NonNullInstantTypeConverters::class) @TypeConverters(NonNullInstantTypeConverters::class)
abstract fun getBookmarks(minStartTime: Instant): LiveData<List<Event>> abstract suspend fun getBookmarks(minStartTime: Instant = Instant.EPOCH): List<Event>
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
FROM bookmarks b
JOIN events e ON b.event_id = e.id
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
GROUP BY e.id
ORDER BY e.start_time ASC""")
@WorkerThread
abstract fun getBookmarks(): List<Event>
@Query("""SELECT b.event_id, e.start_time @Query("""SELECT b.event_id, e.start_time
FROM bookmarks b FROM bookmarks b
@ -59,7 +48,7 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
abstract suspend fun getBookmarksAlarmInfo(minStartTime: Instant): List<AlarmInfo> abstract suspend fun getBookmarksAlarmInfo(minStartTime: Instant): List<AlarmInfo>
@Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event") @Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event")
abstract fun getBookmarkStatus(event: Event): LiveData<Boolean> abstract suspend fun getBookmarkStatus(event: Event): Boolean
suspend fun addBookmark(event: Event): AlarmInfo? { suspend fun addBookmark(event: Event): AlarmInfo? {
val ids = addBookmarksInternal(listOf(Bookmark(event.id))) val ids = addBookmarksInternal(listOf(Bookmark(event.id)))

View file

@ -0,0 +1,18 @@
package be.digitalia.fosdem.db
import androidx.room.InvalidationTracker
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
fun RoomDatabase.createVersionFlow(vararg tables: String): StateFlow<Int> {
val stateFlow = MutableStateFlow(0)
invalidationTracker.addObserver(object : InvalidationTracker.Observer(tables) {
override fun onInvalidated(tables: MutableSet<String>) {
stateFlow.update { it + 1 }
}
})
return stateFlow.asStateFlow()
}

View file

@ -4,9 +4,7 @@ import androidx.annotation.WorkerThread
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.LiveData import androidx.paging.PagingSource
import androidx.lifecycle.liveData
import androidx.paging.DataSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
@ -26,20 +24,27 @@ import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.util.HashSet
@Dao @Dao
abstract class ScheduleDao(private val appDatabase: AppDatabase) { abstract class ScheduleDao(private val appDatabase: AppDatabase) {
val version: StateFlow<Int> =
appDatabase.createVersionFlow(EventEntity.TABLE_NAME)
val bookmarksVersion: StateFlow<Int>
get() = appDatabase.bookmarksDao.version
/** /**
* @return The latest update time, or null if not available. * @return The latest update time, or null if not available.
@ -218,16 +223,17 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
protected abstract fun clearDays() protected abstract fun clearDays()
// Cache days // Cache days
val days: Flow<List<Day>> by lazy { @OptIn(ExperimentalCoroutinesApi::class)
getDaysInternal().shareIn( val days: Flow<List<Day>> = appDatabase.createVersionFlow(Day.TABLE_NAME)
.mapLatest { getDaysInternal() }
.stateIn(
scope = BackgroundWorkScope, scope = BackgroundWorkScope,
started = SharingStarted.Eagerly, started = SharingStarted.Lazily,
replay = 1 initialValue = null
) ).filterNotNull()
}
@Query("SELECT `index`, date FROM days ORDER BY `index` ASC") @Query("SELECT `index`, date FROM days ORDER BY `index` ASC")
protected abstract fun getDaysInternal(): Flow<List<Day>> protected abstract suspend fun getDaysInternal(): List<Day>
suspend fun getYear(): Int { suspend fun getYear(): Int {
// Compute from days if available, fall back to current year // Compute from days if available, fall back to current year
@ -240,7 +246,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE e.day_index = :day WHERE e.day_index = :day
GROUP BY t.id GROUP BY t.id
ORDER BY t.name ASC""") ORDER BY t.name ASC""")
abstract fun getTracks(day: Day): LiveData<List<Track>> abstract suspend fun getTracks(day: Day): List<Track>
/** /**
* Returns the event with the specified id, or null if not found. * Returns the event with the specified id, or null if not found.
@ -273,10 +279,10 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE e.id IN (:ids) WHERE e.id IN (:ids)
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
abstract fun getEvents(ids: LongArray): DataSource.Factory<Int, StatusEvent> abstract fun getEvents(ids: LongArray): PagingSource<Int, StatusEvent>
/** /**
* Returns the events for a specified track. * Returns the events for a specified track, including their bookmark status.
*/ */
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, @Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type, GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
@ -291,10 +297,10 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE e.day_index = :day AND e.track_id = :track WHERE e.day_index = :day AND e.track_id = :track
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
abstract fun getEvents(day: Day, track: Track): LiveData<List<StatusEvent>> abstract suspend fun getEvents(day: Day, track: Track): List<StatusEvent>
/** /**
* Returns a snapshot of the events for a specified track (without the bookmark status). * Returns the events for a specified track, without their bookmark status.
*/ */
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, @Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
@ -307,7 +313,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE e.day_index = :day AND e.track_id = :track WHERE e.day_index = :day AND e.track_id = :track
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
abstract suspend fun getEventsSnapshot(day: Day, track: Track): List<Event> abstract suspend fun getEventsWithoutBookmarkStatus(day: Day, track: Track): List<Event>
/** /**
* Returns events starting in the specified interval, ordered by ascending start time. * Returns events starting in the specified interval, ordered by ascending start time.
@ -326,7 +332,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
@TypeConverters(NonNullInstantTypeConverters::class) @TypeConverters(NonNullInstantTypeConverters::class)
abstract fun getEventsWithStartTime(minStartTime: Instant, maxStartTime: Instant): DataSource.Factory<Int, StatusEvent> abstract fun getEventsWithStartTime(minStartTime: Instant, maxStartTime: Instant): PagingSource<Int, StatusEvent>
/** /**
* Returns events in progress at the specified time, ordered by descending start time. * Returns events in progress at the specified time, ordered by descending start time.
@ -345,7 +351,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time DESC""") ORDER BY e.start_time DESC""")
@TypeConverters(NonNullInstantTypeConverters::class) @TypeConverters(NonNullInstantTypeConverters::class)
abstract fun getEventsInProgress(time: Instant): DataSource.Factory<Int, StatusEvent> abstract fun getEventsInProgress(time: Instant): PagingSource<Int, StatusEvent>
/** /**
* Returns the events presented by the specified person. * Returns the events presented by the specified person.
@ -363,7 +369,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE ep2.person_id = :person WHERE ep2.person_id = :person
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
abstract fun getEvents(person: Person): DataSource.Factory<Int, StatusEvent> abstract fun getEvents(person: Person): PagingSource<Int, StatusEvent>
/** /**
* Search through matching titles, subtitles, track names, person names. * Search through matching titles, subtitles, track names, person names.
@ -397,7 +403,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
) )
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
abstract fun getSearchResults(query: String): DataSource.Factory<Int, StatusEvent> abstract fun getSearchResults(query: String): PagingSource<Int, StatusEvent>
/** /**
* Returns all persons in alphabetical order. * Returns all persons in alphabetical order.
@ -405,16 +411,14 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
@Query("""SELECT `rowid`, name @Query("""SELECT `rowid`, name
FROM persons FROM persons
ORDER BY name COLLATE NOCASE""") ORDER BY name COLLATE NOCASE""")
abstract fun getPersons(): DataSource.Factory<Int, Person> abstract fun getPersons(): PagingSource<Int, Person>
fun getEventDetails(event: Event): LiveData<EventDetails> { suspend fun getEventDetails(event: Event): EventDetails {
return liveData { // Load persons and links in parallel
// Load persons and links in parallel as soon as the LiveData becomes active return coroutineScope {
coroutineScope { val persons = async { getPersons(event) }
val persons = async { getPersons(event) } val links = async { getLinks(event) }
val links = async { getLinks(event) } EventDetails(persons.await(), links.await())
emit(EventDetails(persons.await(), links.await()))
}
} }
} }

View file

@ -0,0 +1,74 @@
package be.digitalia.fosdem.flow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingCommand
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
fun <T> stateFlow(
scope: CoroutineScope,
initialValue: T,
producer: (subscriptionCount: StateFlow<Int>) -> Flow<T>
): StateFlow<T> {
val state = MutableStateFlow(initialValue)
scope.launch {
producer(state.subscriptionCount).collect(state)
}
return state.asStateFlow()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> Flow<T>.flowWhileShared(
subscriptionCount: StateFlow<Int>,
started: SharingStarted
): Flow<T> {
return started.command(subscriptionCount)
.distinctUntilChanged()
.flatMapLatest {
when (it) {
SharingCommand.START -> this
SharingCommand.STOP,
SharingCommand.STOP_AND_RESET_REPLAY_CACHE -> emptyFlow()
}
}
}
inline fun <T> countSubscriptionsFlow(producer: (subscriptionCount: StateFlow<Int>) -> Flow<T>): Flow<T> {
val subscriptionCount = MutableStateFlow(0)
return producer(subscriptionCount.asStateFlow())
.countSubscriptionsTo(subscriptionCount)
}
fun <T> Flow<T>.countSubscriptionsTo(subscriptionCount: MutableStateFlow<Int>): Flow<T> {
return flow {
subscriptionCount.update { it + 1 }
try {
collect(this)
} finally {
subscriptionCount.update { it - 1 }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> versionedResourceFlow(
version: StateFlow<Int>,
subscriptionCount: StateFlow<Int>,
producer: suspend (version: Int) -> T
): Flow<T> {
return version
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
.distinctUntilChanged()
.mapLatest(producer)
}

View file

@ -0,0 +1,60 @@
package be.digitalia.fosdem.flow
import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.util.Arrays
fun tickerFlow(periodInMillis: Long): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(periodInMillis)
}
}
/**
* Creates a ticker Flow which remembers the time of the last emission of the previous collection.
* It only supports one subscriber at a time.
*/
fun rememberTickerFlow(periodInMillis: Long): Flow<Unit> {
var nextEmissionTime = 0L
return flow {
delay(nextEmissionTime - SystemClock.elapsedRealtime())
while (true) {
emit(Unit)
nextEmissionTime = SystemClock.elapsedRealtime() + periodInMillis
delay(periodInMillis)
}
}
}
/**
* Builds a Flow whose value is true during scheduled periods.
*
* @param startEndTimestamps a list of timestamps in milliseconds, sorted in chronological order.
* Odd and even values represent beginnings and ends of periods, respectively.
*/
fun schedulerFlow(vararg startEndTimestamps: Long): Flow<Boolean> {
return flow {
var now = System.currentTimeMillis()
var pos = Arrays.binarySearch(startEndTimestamps, now)
while (true) {
val size = startEndTimestamps.size
if (pos >= 0) {
do {
pos++
} while (pos < size && startEndTimestamps[pos] == now)
} else {
pos = pos.inv()
}
emit(pos % 2 != 0)
if (pos == size) {
break
}
// Readjust current time after suspending emit()
delay(startEndTimestamps[pos] - System.currentTimeMillis())
now = startEndTimestamps[pos]
}
}
}

View file

@ -26,11 +26,14 @@ import be.digitalia.fosdem.adapters.BookmarksAdapter
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.providers.BookmarksExportProvider import be.digitalia.fosdem.providers.BookmarksExportProvider
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.toBookmarksNfcAppData import be.digitalia.fosdem.utils.toBookmarksNfcAppData
import be.digitalia.fosdem.viewmodels.BookmarksViewModel import be.digitalia.fosdem.viewmodels.BookmarksViewModel
import be.digitalia.fosdem.widgets.MultiChoiceHelper import be.digitalia.fosdem.widgets.MultiChoiceHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
@ -116,13 +119,19 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC
isProgressBarVisible = true isProgressBarVisible = true
} }
api.roomStatuses.observe(viewLifecycleOwner) { statuses -> viewLifecycleOwner.launchAndRepeatOnLifecycle {
adapter.roomStatuses = statuses launch {
} api.roomStatuses.collect { statuses ->
viewModel.bookmarks.observe(viewLifecycleOwner) { bookmarks -> adapter.roomStatuses = statuses
adapter.submitList(bookmarks) }
multiChoiceHelper.setAdapter(adapter, viewLifecycleOwner) }
holder.isProgressBarVisible = false launch {
viewModel.bookmarks.filterNotNull().collect { bookmarks ->
adapter.submitList(bookmarks)
multiChoiceHelper.setAdapter(adapter, viewLifecycleOwner)
holder.isProgressBarVisible = false
}
}
} }
} }
@ -196,9 +205,7 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC
override fun createNfcAppData(): NdefRecord? { override fun createNfcAppData(): NdefRecord? {
val context = context ?: return null val context = context ?: return null
val bookmarks = viewModel.bookmarks.value val bookmarks = viewModel.bookmarks.value
return if (bookmarks.isNullOrEmpty()) { return if (bookmarks.isNullOrEmpty()) null else bookmarks.toBookmarksNfcAppData(context)
null
} else bookmarks.toBookmarksNfcAppData(context)
} }
companion object { companion object {

View file

@ -27,7 +27,7 @@ import androidx.core.view.plusAssign
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.add import androidx.fragment.app.add
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.PersonInfoActivity import be.digitalia.fosdem.activities.PersonInfoActivity
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
@ -38,7 +38,9 @@ import be.digitalia.fosdem.model.Link
import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.utils.ClickableArrowKeyMovementMethod import be.digitalia.fosdem.utils.ClickableArrowKeyMovementMethod
import be.digitalia.fosdem.utils.DateUtils import be.digitalia.fosdem.utils.DateUtils
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.configureToolbarColors import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.parseHtml import be.digitalia.fosdem.utils.parseHtml
import be.digitalia.fosdem.utils.roomNameToResourceName import be.digitalia.fosdem.utils.roomNameToResourceName
import be.digitalia.fosdem.utils.stripHtml import be.digitalia.fosdem.utils.stripHtml
@ -59,7 +61,11 @@ class EventDetailsFragment : Fragment(R.layout.fragment_event_details) {
@Inject @Inject
lateinit var api: FosdemApi lateinit var api: FosdemApi
private val viewModel: EventDetailsViewModel by viewModels() @Inject
lateinit var viewModelFactory: EventDetailsViewModel.Factory
private val viewModel: EventDetailsViewModel by assistedViewModels {
viewModelFactory.create(event)
}
val event by lazy<Event>(LazyThreadSafetyMode.NONE) { val event by lazy<Event>(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable(ARG_EVENT)!! requireArguments().getParcelable(ARG_EVENT)!!
@ -157,24 +163,25 @@ class EventDetailsFragment : Fragment(R.layout.fragment_event_details) {
} }
} }
with(viewModel) { viewLifecycleOwner.lifecycleScope.launchWhenStarted {
setEvent(event) showEventDetails(holder, viewModel.eventDetails.await())
eventDetails.observe(viewLifecycleOwner) { eventDetails ->
showEventDetails(holder, eventDetails)
}
} }
// Live room status // Live room status
val roomName = event.roomName val roomName = event.roomName
if (!roomName.isNullOrEmpty()) { if (!roomName.isNullOrEmpty()) {
holder.roomStatusTextView.run { viewLifecycleOwner.launchAndRepeatOnLifecycle {
api.roomStatuses.observe(viewLifecycleOwner) { roomStatuses -> api.roomStatuses.collect { statuses ->
val roomStatus = roomStatuses[roomName] holder.roomStatusTextView.run {
if (roomStatus == null) { val roomStatus = statuses[roomName]
text = null if (roomStatus == null) {
} else { text = null
setText(roomStatus.nameResId) } else {
setTextColor(ContextCompat.getColorStateList(context, roomStatus.colorResId)) setText(roomStatus.nameResId)
setTextColor(
ContextCompat.getColorStateList(context, roomStatus.colorResId)
)
}
} }
} }
} }
@ -219,9 +226,9 @@ class EventDetailsFragment : Fragment(R.layout.fragment_event_details) {
} }
description = description.stripHtml() description = description.stripHtml()
// Add speaker info if available // Add speaker info if available
val personsCount = viewModel.eventDetails.value?.persons?.size ?: 0 val personsSummary = event.personsSummary
if (personsCount > 0) { if (!personsSummary.isNullOrBlank()) {
val personsSummary = event.personsSummary ?: "?" val personsCount = personsSummary.count { it == ',' } + 1
val speakersLabel = resources.getQuantityString(R.plurals.speakers, personsCount) val speakersLabel = resources.getQuantityString(R.plurals.speakers, personsCount)
description = "$speakersLabel: $personsSummary\n\n$description" description = "$speakersLabel: $personsSummary\n\n$description"
} }

View file

@ -6,19 +6,26 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -26,12 +33,15 @@ class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) {
@Inject @Inject
lateinit var api: FosdemApi lateinit var api: FosdemApi
private val viewModel: ExternalBookmarksViewModel by viewModels() @Inject
private var addAllMenuItem: MenuItem? = null lateinit var viewModelFactory: ExternalBookmarksViewModel.Factory
private val viewModel: ExternalBookmarksViewModel by assistedViewModels {
val bookmarkIds = requireArguments().getLongArray(ARG_BOOKMARK_IDS)!!
viewModelFactory.create(bookmarkIds)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
setFragmentResultListener(REQUEST_KEY_CONFIRM_ADD_ALL) { _, _ -> viewModel.addAll() } setFragmentResultListener(REQUEST_KEY_CONFIRM_ADD_ALL) { _, _ -> viewModel.addAll() }
} }
@ -49,41 +59,41 @@ class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true isProgressBarVisible = true
} }
val bookmarkIds = requireArguments().getLongArray(ARG_BOOKMARK_IDS)!! val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.external_bookmarks, menu)
}
api.roomStatuses.observe(viewLifecycleOwner) { statuses -> override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) {
adapter.roomStatuses = statuses R.id.add_all -> {
} ConfirmAddAllDialogFragment().show(parentFragmentManager, "confirmAddAll")
with(viewModel) { true
setBookmarkIds(bookmarkIds) }
bookmarks.observe(viewLifecycleOwner) { bookmarks -> else -> false
adapter.submitList(bookmarks)
addAllMenuItem?.isEnabled = bookmarks.isNotEmpty()
holder.isProgressBarVisible = false
} }
} }
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { viewLifecycleOwner.lifecycleScope.launch {
inflater.inflate(R.menu.external_bookmarks, menu) adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
menu.findItem(R.id.add_all)?.let { item -> holder.isProgressBarVisible = false
val bookmarks = viewModel.bookmarks.value // Only display the menu items if there is at least one item
item.isEnabled = bookmarks != null && bookmarks.isNotEmpty() if (adapter.itemCount > 0) {
addAllMenuItem = item requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
}
} }
}
override fun onDestroyOptionsMenu() { viewLifecycleOwner.launchAndRepeatOnLifecycle {
super.onDestroyOptionsMenu() launch {
addAllMenuItem = null api.roomStatuses.collect { statuses ->
} adapter.roomStatuses = statuses
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { }
R.id.add_all -> { launch {
ConfirmAddAllDialogFragment().show(parentFragmentManager, "confirmAddAll") viewModel.bookmarks.collectLatest { pagingData ->
true adapter.submitData(pagingData)
}
}
} }
else -> false
} }
class ConfirmAddAllDialogFragment : DialogFragment() { class ConfirmAddAllDialogFragment : DialogFragment() {

View file

@ -5,22 +5,30 @@ import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope
import androidx.paging.PagedList import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.LiveViewModel import be.digitalia.fosdem.viewmodels.LiveViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
sealed class LiveListFragment(@StringRes private val emptyTextResId: Int, sealed class LiveListFragment(
private val dataSourceProvider: (LiveViewModel) -> LiveData<PagedList<StatusEvent>>) @StringRes private val emptyTextResId: Int,
: Fragment(R.layout.recyclerview) { private val dataSourceProvider: (LiveViewModel) -> Flow<PagingData<StatusEvent>>
) : Fragment(R.layout.recyclerview) {
@Inject @Inject
lateinit var api: FosdemApi lateinit var api: FosdemApi
@ -45,19 +53,30 @@ sealed class LiveListFragment(@StringRes private val emptyTextResId: Int,
isProgressBarVisible = true isProgressBarVisible = true
} }
api.roomStatuses.observe(viewLifecycleOwner) { statuses -> viewLifecycleOwner.lifecycleScope.launch {
adapter.roomStatuses = statuses adapter.loadStateFlow
} .distinctUntilChangedBy { it.refresh }
dataSourceProvider(viewModel).observe(viewLifecycleOwner) { events -> .filter { it.refresh !is LoadState.Loading }
adapter.submitList(events) { .collect {
// Ensure we stay at scroll position 0 so we can see the insertion animation holder.isProgressBarVisible = false
holder.recyclerView.run { // Ensure we stay at scroll position 0 so we can see the insertion animation
if (scrollY == 0) { with(holder.recyclerView) {
scrollToPosition(0) if (scrollY == 0) scrollToPosition(0)
} }
} }
}
viewLifecycleOwner.launchAndRepeatOnLifecycle {
launch {
api.roomStatuses.collect { statuses ->
adapter.roomStatuses = statuses
}
}
launch {
dataSourceProvider(viewModel).collectLatest { pagingData ->
adapter.submitData(pagingData)
}
} }
holder.isProgressBarVisible = false
} }
} }
} }

View file

@ -5,7 +5,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -13,8 +14,13 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.PersonInfoViewModel import be.digitalia.fosdem.viewmodels.PersonInfoViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -22,14 +28,16 @@ class PersonInfoListFragment : Fragment(R.layout.recyclerview) {
@Inject @Inject
lateinit var api: FosdemApi lateinit var api: FosdemApi
private val viewModel: PersonInfoViewModel by viewModels() @Inject
lateinit var viewModelFactory: PersonInfoViewModel.Factory
private val viewModel: PersonInfoViewModel by assistedViewModels {
val person: Person = requireArguments().getParcelable(ARG_PERSON)!!
viewModelFactory.create(person)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val person: Person = requireArguments().getParcelable(ARG_PERSON)!!
viewModel.setPerson(person)
val adapter = EventsAdapter(view.context) val adapter = EventsAdapter(view.context)
val holder = RecyclerViewViewHolder(view).apply { val holder = RecyclerViewViewHolder(view).apply {
recyclerView.apply { recyclerView.apply {
@ -47,13 +55,23 @@ class PersonInfoListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true isProgressBarVisible = true
} }
api.roomStatuses.observe(viewLifecycleOwner) { statuses -> viewLifecycleOwner.lifecycleScope.launch {
adapter.roomStatuses = statuses adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
}
viewModel.events.observe(viewLifecycleOwner) { events ->
adapter.submitList(events)
holder.isProgressBarVisible = false holder.isProgressBarVisible = false
} }
viewLifecycleOwner.launchAndRepeatOnLifecycle {
launch {
api.roomStatuses.collect { statuses ->
adapter.roomStatuses = statuses
}
}
launch {
viewModel.events.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
} }
private class HeaderAdapter : RecyclerView.Adapter<HeaderAdapter.ViewHolder>() { private class HeaderAdapter : RecyclerView.Adapter<HeaderAdapter.ViewHolder>() {

View file

@ -8,7 +8,9 @@ import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.paging.PagedListAdapter import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -16,8 +18,12 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.PersonInfoActivity import be.digitalia.fosdem.activities.PersonInfoActivity
import be.digitalia.fosdem.adapters.createSimpleItemCallback import be.digitalia.fosdem.adapters.createSimpleItemCallback
import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.PersonsViewModel import be.digitalia.fosdem.viewmodels.PersonsViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class PersonsListFragment : Fragment(R.layout.recyclerview_fastscroll) { class PersonsListFragment : Fragment(R.layout.recyclerview_fastscroll) {
@ -38,13 +44,19 @@ class PersonsListFragment : Fragment(R.layout.recyclerview_fastscroll) {
isProgressBarVisible = true isProgressBarVisible = true
} }
viewModel.persons.observe(viewLifecycleOwner) { persons -> viewLifecycleOwner.lifecycleScope.launch {
adapter.submitList(persons) adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
holder.isProgressBarVisible = false holder.isProgressBarVisible = false
} }
viewLifecycleOwner.launchAndRepeatOnLifecycle {
viewModel.persons.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
} }
private class PersonsAdapter : PagedListAdapter<Person, PersonViewHolder>(DIFF_CALLBACK) { private class PersonsAdapter : PagingDataAdapter<Person, PersonViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PersonViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PersonViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.simple_list_item_1_material, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.simple_list_item_1_material, parent, false)

View file

@ -4,13 +4,19 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.SearchViewModel import be.digitalia.fosdem.viewmodels.SearchViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -34,12 +40,22 @@ class SearchResultListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true isProgressBarVisible = true
} }
api.roomStatuses.observe(viewLifecycleOwner) { statuses -> viewLifecycleOwner.lifecycleScope.launch {
adapter.roomStatuses = statuses adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
}
viewModel.results.observe(viewLifecycleOwner) { result ->
adapter.submitList((result as? SearchViewModel.Result.Success)?.list)
holder.isProgressBarVisible = false holder.isProgressBarVisible = false
} }
viewLifecycleOwner.launchAndRepeatOnLifecycle {
launch {
api.roomStatuses.collect { statuses ->
adapter.roomStatuses = statuses
}
}
launch {
viewModel.results.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
} }
} }

View file

@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -15,14 +14,25 @@ import be.digitalia.fosdem.adapters.TrackScheduleAdapter
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.TrackScheduleListViewModel import be.digitalia.fosdem.viewmodels.TrackScheduleListViewModel
import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackScheduleAdapter.EventClickListener { class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackScheduleAdapter.EventClickListener {
private val viewModel: TrackScheduleListViewModel by viewModels() @Inject
lateinit var viewModelFactory: TrackScheduleListViewModel.Factory
private val viewModel: TrackScheduleListViewModel by assistedViewModels {
val args = requireArguments()
val day: Day = args.getParcelable(ARG_DAY)!!
val track: Track = args.getParcelable(ARG_TRACK)!!
viewModelFactory.create(day, track)
}
private val activityViewModel: TrackScheduleViewModel by activityViewModels() private val activityViewModel: TrackScheduleViewModel by activityViewModels()
private val selectionEnabled: Boolean by lazy(LazyThreadSafetyMode.NONE) { private val selectionEnabled: Boolean by lazy(LazyThreadSafetyMode.NONE) {
resources.getBoolean(R.bool.tablet_landscape) resources.getBoolean(R.bool.tablet_landscape)
@ -32,16 +42,11 @@ class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackSchedule
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val args = requireArguments()
val day: Day = args.getParcelable(ARG_DAY)!!
val track: Track = args.getParcelable(ARG_TRACK)!!
viewModel.setDayAndTrack(day, track)
if (savedInstanceState != null) { if (savedInstanceState != null) {
isListAlreadyShown = savedInstanceState.getBoolean(STATE_IS_LIST_ALREADY_SHOWN) isListAlreadyShown = savedInstanceState.getBoolean(STATE_IS_LIST_ALREADY_SHOWN)
} }
selectedId = savedInstanceState?.getLong(STATE_SELECTED_ID) selectedId = savedInstanceState?.getLong(STATE_SELECTED_ID)
?: args.getLong(ARG_FROM_EVENT_ID, RecyclerView.NO_ID) ?: requireArguments().getLong(ARG_FROM_EVENT_ID, RecyclerView.NO_ID)
} }
private var selectedId: Long = RecyclerView.NO_ID private var selectedId: Long = RecyclerView.NO_ID
@ -66,45 +71,54 @@ class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackSchedule
isProgressBarVisible = true isProgressBarVisible = true
} }
with(viewModel) { viewLifecycleOwner.launchAndRepeatOnLifecycle {
currentTime.observe(viewLifecycleOwner) { now -> launch {
adapter.currentTime = now viewModel.schedule.collect { schedule ->
} adapter.submitList(schedule)
schedule.observe(viewLifecycleOwner) { schedule ->
adapter.submitList(schedule)
var selectedPosition = if (selectedId == -1L) -1 else schedule.indexOfFirst { it.event.id == selectedId } var selectedPosition = if (selectedId == -1L) -1 else schedule.indexOfFirst { it.event.id == selectedId }
if (selectedPosition == -1) { if (selectedPosition == -1) {
// There is no current valid selection, reset to use the first item (if any) // There is no current valid selection, reset to use the first item (if any)
if (schedule.isNotEmpty()) { if (schedule.isNotEmpty()) {
selectedPosition = 0 selectedPosition = 0
selectedId = schedule[0].event.id selectedId = schedule[0].event.id
} else { } else {
selectedId = -1L selectedId = -1L
}
}
activityViewModel.selectedEvent =
if (selectedPosition == -1) null else schedule[selectedPosition].event
// Ensure the selection is visible
if ((selectionEnabled || !isListAlreadyShown) && selectedPosition != -1) {
holder.recyclerView.scrollToPosition(selectedPosition)
}
isListAlreadyShown = true
holder.isProgressBarVisible = false
}
}
launch {
viewModel.currentTime.collect { now ->
adapter.currentTime = now
}
}
if (selectionEnabled) {
launch {
activityViewModel.selectedEventFlow.collect { event ->
adapter.selectedId = event?.id ?: RecyclerView.NO_ID
} }
} }
activityViewModel.setSelectEvent(if (selectedPosition == -1) null else schedule[selectedPosition].event)
// Ensure the selection is visible
if ((selectionEnabled || !isListAlreadyShown) && selectedPosition != -1) {
holder.recyclerView.scrollToPosition(selectedPosition)
}
isListAlreadyShown = true
holder.isProgressBarVisible = false
}
}
if (selectionEnabled) {
activityViewModel.selectedEvent.observe(viewLifecycleOwner) { event ->
adapter.selectedId = event?.id ?: RecyclerView.NO_ID
} }
} }
} }
override fun onEventClick(event: Event) { override fun onEventClick(event: Event) {
selectedId = event.id selectedId = event.id
activityViewModel.setSelectEvent(event) activityViewModel.selectedEvent = event
if (!selectionEnabled) { if (!selectionEnabled) {
// Classic mode: Show event details in a new activity // Classic mode: Show event details in a new activity

View file

@ -16,6 +16,7 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.utils.enforceSingleScrollDirection import be.digitalia.fosdem.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.instantiate import be.digitalia.fosdem.utils.instantiate
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.recyclerView import be.digitalia.fosdem.utils.recyclerView
import be.digitalia.fosdem.utils.viewLifecycleLazy import be.digitalia.fosdem.utils.viewLifecycleLazy
import be.digitalia.fosdem.viewmodels.TracksViewModel import be.digitalia.fosdem.viewmodels.TracksViewModel
@ -57,24 +58,25 @@ class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvi
preferences.getInt(TRACKS_CURRENT_PAGE_PREF_KEY, -1) preferences.getInt(TRACKS_CURRENT_PAGE_PREF_KEY, -1)
} else -1 } else -1
viewModel.days.observe(viewLifecycleOwner) { days -> viewLifecycleOwner.launchAndRepeatOnLifecycle {
holder.run { viewModel.days.collect { days ->
daysAdapter.days = days holder.run {
daysAdapter.days = days
val totalPages = daysAdapter.itemCount if (days.isEmpty()) {
if (totalPages == 0) { contentView.isVisible = false
contentView.isVisible = false emptyView.isVisible = true
emptyView.isVisible = true } else {
} else { contentView.isVisible = true
contentView.isVisible = true emptyView.isVisible = false
emptyView.isVisible = false if (pager.adapter == null) {
if (pager.adapter == null) { pager.adapter = daysAdapter
pager.adapter = daysAdapter TabLayoutMediator(tabs, pager) { tab, position -> tab.text = daysAdapter.getPageTitle(position) }.attach()
TabLayoutMediator(tabs, pager) { tab, position -> tab.text = daysAdapter.getPageTitle(position) }.attach() }
} if (savedCurrentPage != -1) {
if (savedCurrentPage != -1) { pager.setCurrentItem(savedCurrentPage.coerceAtMost(days.size - 1), false)
pager.setCurrentItem(savedCurrentPage.coerceAtMost(totalPages - 1), false) savedCurrentPage = -1
savedCurrentPage = -1 }
} }
} }
} }

View file

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -18,13 +17,21 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.TrackScheduleActivity import be.digitalia.fosdem.activities.TrackScheduleActivity
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.TracksListViewModel import be.digitalia.fosdem.viewmodels.TracksListViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TracksListFragment : Fragment(R.layout.recyclerview) { class TracksListFragment : Fragment(R.layout.recyclerview) {
private val viewModel: TracksListViewModel by viewModels() @Inject
lateinit var viewModelFactory: TracksListViewModel.Factory
private val viewModel: TracksListViewModel by assistedViewModels {
viewModelFactory.create(day)
}
private val day by lazy<Day>(LazyThreadSafetyMode.NONE) { private val day by lazy<Day>(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable(ARG_DAY)!! requireArguments().getParcelable(ARG_DAY)!!
} }
@ -48,9 +55,8 @@ class TracksListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true isProgressBarVisible = true
} }
with(viewModel) { viewLifecycleOwner.launchAndRepeatOnLifecycle {
setDay(day) viewModel.tracks.collect { tracks ->
tracks.observe(viewLifecycleOwner) { tracks ->
adapter.submitList(tracks) adapter.submitList(tracks)
holder.isProgressBarVisible = false holder.isProgressBarVisible = false
} }

View file

@ -1,94 +0,0 @@
package be.digitalia.fosdem.livedata
import android.os.Looper
import android.os.SystemClock
import androidx.core.os.HandlerCompat
import androidx.lifecycle.LiveData
import java.util.Arrays
object LiveDataFactory {
private val handler = HandlerCompat.createAsync(Looper.getMainLooper())
fun interval(periodInMillis: Long): LiveData<Long> {
return IntervalLiveData(periodInMillis)
}
/**
* Builds a LiveData whose value is true during scheduled periods.
*
* @param startEndTimestamps a list of timestamps in milliseconds, sorted in chronological order.
* Odd and even values represent beginnings and ends of periods, respectively.
*/
fun scheduler(vararg startEndTimestamps: Long): LiveData<Boolean> {
return SchedulerLiveData(startEndTimestamps)
}
private class IntervalLiveData(private val periodInMillis: Long) : LiveData<Long>(), Runnable {
private var updateTime = 0L
private var version = 0L
override fun onActive() {
val now = SystemClock.elapsedRealtime()
if (now >= updateTime) {
update(now)
} else {
handler.postDelayed(this, updateTime - now)
}
}
override fun onInactive() {
handler.removeCallbacks(this)
}
private fun update(now: Long) {
value = version++
updateTime = now + periodInMillis
handler.postDelayed(this, periodInMillis)
}
override fun run() {
update(SystemClock.elapsedRealtime())
}
}
private class SchedulerLiveData(private val startEndTimestamps: LongArray) : LiveData<Boolean>(), Runnable {
private var nowPosition = -1
override fun onActive() {
val now = System.currentTimeMillis()
updateState(now, Arrays.binarySearch(startEndTimestamps, now))
}
override fun onInactive() {
handler.removeCallbacks(this)
}
override fun run() {
val position = nowPosition
updateState(startEndTimestamps[position], position)
}
private fun updateState(now: Long, position: Int) {
var pos = position
val size = startEndTimestamps.size
if (pos >= 0) {
do {
pos++
} while (pos < size && startEndTimestamps[pos] == now)
} else {
pos = pos.inv()
}
val isOn = pos % 2 != 0
if (value != isOn) {
value = isOn
}
if (pos < size) {
nowPosition = pos
handler.postDelayed(this, startEndTimestamps[pos] - now)
}
}
}
}

View file

@ -1,20 +0,0 @@
package be.digitalia.fosdem.livedata
/**
* Encapsulates data that can only be consumed once.
*/
class SingleEvent<T :Any>(content: T) {
private var content: T? = content
/**
* @return The content, or null if it has already been consumed.
*/
fun consume(): T? {
val previousContent = content
if (previousContent != null) {
content = null
}
return previousContent
}
}

View file

@ -1,3 +1,3 @@
package be.digitalia.fosdem.model package be.digitalia.fosdem.model
class BookmarkStatus(val isBookmarked: Boolean, val isUpdate: Boolean) data class BookmarkStatus(val eventId: Long, val isBookmarked: Boolean)

View file

@ -1,7 +1,7 @@
package be.digitalia.fosdem.model package be.digitalia.fosdem.model
sealed class DownloadScheduleResult { sealed class DownloadScheduleResult {
class Success(val eventsCount: Int) : DownloadScheduleResult() data class Success(val eventsCount: Int) : DownloadScheduleResult()
object Error : DownloadScheduleResult() object Error : DownloadScheduleResult()
object UpToDate : DownloadScheduleResult() object UpToDate : DownloadScheduleResult()
} }

View file

@ -1,13 +1,11 @@
package be.digitalia.fosdem.model package be.digitalia.fosdem.model
import be.digitalia.fosdem.livedata.SingleEvent
sealed class LoadingState<out T : Any> { sealed class LoadingState<out T : Any> {
/** /**
* The current download progress: * The current download progress:
* -1 : in progress, indeterminate * -1 : in progress, indeterminate
* 0..99: progress value in percents * 0..99: progress value in percents
*/ */
class Loading(val progress: Int = -1) : LoadingState<Nothing>() data class Loading(val progress: Int = -1) : LoadingState<Nothing>()
class Idle<T : Any>(val result: SingleEvent<T>) : LoadingState<T>() data class Idle<T : Any>(val result: T? = null) : LoadingState<T>()
} }

View file

@ -105,7 +105,7 @@ class BookmarksExportProvider : ContentProvider() {
override fun run() { override fun run() {
try { try {
ICalendarWriter(outputStream.sink().buffer()).use { writer -> ICalendarWriter(outputStream.sink().buffer()).use { writer ->
val bookmarks = bookmarksDao.getBookmarks() val bookmarks = runBlocking { bookmarksDao.getBookmarks() }
writer.write("BEGIN", "VCALENDAR") writer.write("BEGIN", "VCALENDAR")
writer.write("VERSION", "2.0") writer.write("VERSION", "2.0")
writer.write("PRODID", "-//${BuildConfig.APPLICATION_ID}//NONSGML ${BuildConfig.VERSION_NAME}//EN") writer.write("PRODID", "-//${BuildConfig.APPLICATION_ID}//NONSGML ${BuildConfig.VERSION_NAME}//EN")

View file

@ -0,0 +1,32 @@
package be.digitalia.fosdem.utils
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
@Suppress("UNCHECKED_CAST")
inline fun simpleViewModelProviderFactory(crossinline viewModelProducer: () -> ViewModel): ViewModelProvider.Factory {
return object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>) = viewModelProducer() as T
}
}
@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.assistedViewModels(
crossinline viewModelProducer: () -> VM
) = viewModels<VM> {
simpleViewModelProviderFactory(viewModelProducer)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.assistedViewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
crossinline viewModelProducer: () -> VM
) = viewModels<VM>(ownerProducer) {
simpleViewModelProviderFactory(viewModelProducer)
}

View file

@ -0,0 +1,21 @@
package be.digitalia.fosdem.utils
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
fun Lifecycle.launchAndRepeatOnLifecycle(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
): Job = coroutineScope.launch {
repeatOnLifecycle(state, block)
}
fun LifecycleOwner.launchAndRepeatOnLifecycle(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
): Job = lifecycle.launchAndRepeatOnLifecycle(state, block)

View file

@ -1,17 +1,20 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.viewModelScope
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.flow.versionedResourceFlow
import be.digitalia.fosdem.model.BookmarkStatus import be.digitalia.fosdem.model.BookmarkStatus
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -21,37 +24,34 @@ class BookmarkStatusViewModel @Inject constructor(
private val alarmManager: AppAlarmManager private val alarmManager: AppAlarmManager
) : ViewModel() { ) : ViewModel() {
private val eventLiveData = MutableLiveData<Event?>() private val eventStateFlow = MutableStateFlow<Event?>(null)
private var firstResultReceived = false
val bookmarkStatus: LiveData<BookmarkStatus?> = eventLiveData.switchMap { event -> @OptIn(ExperimentalCoroutinesApi::class)
if (event == null) { val bookmarkStatus: StateFlow<BookmarkStatus?> =
MutableLiveData(null) stateFlow(viewModelScope, null) { subscriptionCount ->
} else { eventStateFlow.flatMapLatest { event ->
bookmarksDao.getBookmarkStatus(event) if (event == null) {
.distinctUntilChanged() // Prevent updating the UI when a bookmark is added back or removed back flowOf(null)
.map { isBookmarked -> } else {
val isUpdate = firstResultReceived versionedResourceFlow(bookmarksDao.version, subscriptionCount) {
firstResultReceived = true val isBookmarked = bookmarksDao.getBookmarkStatus(event)
BookmarkStatus(isBookmarked, isUpdate) BookmarkStatus(event.id, isBookmarked)
}
} }
}
}
var event: Event?
get() = eventLiveData.value
set(value) {
if (value != eventLiveData.value) {
firstResultReceived = false
eventLiveData.value = value
} }
} }
var event: Event?
get() = eventStateFlow.value
set(value) {
eventStateFlow.value = value
}
fun toggleBookmarkStatus() { fun toggleBookmarkStatus() {
val event = event val event = eventStateFlow.value
val currentStatus = bookmarkStatus.value val currentStatus = bookmarkStatus.value
// Ignore the action if the status for the current event hasn't been received yet // Ignore the action if the status for the current event hasn't been received yet
if (event != null && currentStatus != null && firstResultReceived) { if (event != null && currentStatus != null && event.id == currentStatus.eventId) {
if (currentStatus.isBookmarked) { if (currentStatus.isBookmarked) {
removeBookmark(event) removeBookmark(event)
} else { } else {

View file

@ -2,20 +2,28 @@ package be.digitalia.fosdem.viewmodels
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory import be.digitalia.fosdem.flow.flowWhileShared
import be.digitalia.fosdem.flow.rememberTickerFlow
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.flow.versionedResourceFlow
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.parsers.ExportedBookmarksParser import be.digitalia.fosdem.parsers.ExportedBookmarksParser
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import okio.buffer
@ -33,26 +41,35 @@ class BookmarksViewModel @Inject constructor(
private val application: Application private val application: Application
) : ViewModel() { ) : ViewModel() {
private val upcomingOnlyLiveData = MutableLiveData<Boolean>() private val upcomingOnlyStateFlow = MutableStateFlow<Boolean?>(null)
val bookmarks: LiveData<List<Event>> = upcomingOnlyLiveData.switchMap { upcomingOnly: Boolean -> @OptIn(ExperimentalCoroutinesApi::class)
if (upcomingOnly) { val bookmarks: StateFlow<List<Event>?> = stateFlow(viewModelScope, null) { subscriptionCount ->
// Refresh upcoming bookmarks every 2 minutes upcomingOnlyStateFlow.filterNotNull().flatMapLatest { upcomingOnly ->
LiveDataFactory.interval(REFRESH_PERIOD) if (upcomingOnly) {
.switchMap { // Refresh upcoming bookmarks every 2 minutes
bookmarksDao.getBookmarks(Instant.now() - TIME_OFFSET) rememberTickerFlow(REFRESH_PERIOD)
} .flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
} else { .flatMapLatest {
bookmarksDao.getBookmarks(Instant.EPOCH) getObservableBookmarks(Instant.now() - TIME_OFFSET, subscriptionCount)
}
} else {
getObservableBookmarks(Instant.EPOCH, subscriptionCount)
}
} }
} }
private fun getObservableBookmarks(
minStartTime: Instant,
subscriptionCount: StateFlow<Int>
): Flow<List<Event>> = versionedResourceFlow(bookmarksDao.version, subscriptionCount) {
bookmarksDao.getBookmarks(minStartTime)
}
var upcomingOnly: Boolean var upcomingOnly: Boolean
get() = upcomingOnlyLiveData.value == true get() = upcomingOnlyStateFlow.value == true
set(value) { set(value) {
if (value != upcomingOnlyLiveData.value) { upcomingOnlyStateFlow.value = value
upcomingOnlyLiveData.value = value
}
} }
fun removeBookmarks(eventIds: LongArray) { fun removeBookmarks(eventIds: LongArray) {
@ -73,6 +90,7 @@ class BookmarksViewModel @Inject constructor(
companion object { companion object {
private val REFRESH_PERIOD = TimeUnit.MINUTES.toMillis(2L) private val REFRESH_PERIOD = TimeUnit.MINUTES.toMillis(2L)
// In upcomingOnly mode, events that just started are still shown for 5 minutes // In upcomingOnly mode, events that just started are still shown for 5 minutes
private val TIME_OFFSET = Duration.ofMinutes(5L) private val TIME_OFFSET = Duration.ofMinutes(5L)
} }

View file

@ -1,27 +1,27 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.EventDetails import be.digitalia.fosdem.model.EventDetails
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import javax.inject.Inject import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@HiltViewModel class EventDetailsViewModel @AssistedInject constructor(
class EventDetailsViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted event: Event
) : ViewModel() {
private val eventLiveData = MutableLiveData<Event>() val eventDetails: Deferred<EventDetails> = viewModelScope.async {
val eventDetails: LiveData<EventDetails> = eventLiveData.switchMap { event: Event ->
scheduleDao.getEventDetails(event) scheduleDao.getEventDetails(event)
} }
fun setEvent(event: Event) { @AssistedFactory
if (event != eventLiveData.value) { interface Factory {
eventLiveData.value = event fun create(event: Event): EventDetailsViewModel
}
} }
} }

View file

@ -1,32 +1,26 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import javax.inject.Inject import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@HiltViewModel class EventViewModel @AssistedInject constructor(
class EventViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted eventId: Long
) : ViewModel() {
private val eventIdLiveData = MutableLiveData<Long>() val event: Deferred<Event?> = viewModelScope.async {
scheduleDao.getEvent(eventId)
val event: LiveData<Event?> = eventIdLiveData.switchMap { id ->
liveData {
emit(scheduleDao.getEvent(id))
}
} }
val isEventIdSet @AssistedFactory
get() = eventIdLiveData.value != null interface Factory {
fun create(eventId: Long): EventViewModel
fun setEventId(eventId: Long) {
if (eventId != eventIdLiveData.value) {
eventIdLiveData.value = eventId
}
} }
} }

View file

@ -1,46 +1,44 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import androidx.paging.PagedList import androidx.paging.Pager
import androidx.paging.toLiveData import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel class ExternalBookmarksViewModel @AssistedInject constructor(
class ExternalBookmarksViewModel @Inject constructor(
scheduleDao: ScheduleDao, scheduleDao: ScheduleDao,
private val bookmarksDao: BookmarksDao, private val bookmarksDao: BookmarksDao,
private val alarmManager: AppAlarmManager private val alarmManager: AppAlarmManager,
@Assisted private val bookmarkIds: LongArray
) : ViewModel() { ) : ViewModel() {
private val bookmarkIdsLiveData = MutableLiveData<LongArray>() val bookmarks: Flow<PagingData<StatusEvent>> =
Pager(PagingConfig(20)) {
val bookmarks: LiveData<PagedList<StatusEvent>> = bookmarkIdsLiveData.switchMap { bookmarkIds -> scheduleDao.getEvents(bookmarkIds)
scheduleDao.getEvents(bookmarkIds).toLiveData(20) }.flow.cachedIn(viewModelScope)
}
fun setBookmarkIds(bookmarkIds: LongArray) {
val value = bookmarkIdsLiveData.value
if (value == null || !bookmarkIds.contentEquals(value)) {
bookmarkIdsLiveData.value = bookmarkIds
}
}
fun addAll() { fun addAll() {
val bookmarkIds = bookmarkIdsLiveData.value ?: return
BackgroundWorkScope.launch { BackgroundWorkScope.launch {
bookmarksDao.addBookmarks(bookmarkIds).let { alarmInfos -> bookmarksDao.addBookmarks(bookmarkIds).let { alarmInfos ->
alarmManager.onBookmarksAdded(alarmInfos) alarmManager.onBookmarksAdded(alarmInfos)
} }
} }
} }
@AssistedFactory
interface Factory {
fun create(bookmarkIds: LongArray): ExternalBookmarksViewModel
}
} }

View file

@ -1,14 +1,26 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import androidx.paging.PagedList import androidx.paging.Pager
import androidx.paging.toLiveData import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.cachedIn
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory import be.digitalia.fosdem.flow.countSubscriptionsFlow
import be.digitalia.fosdem.flow.flowWhileShared
import be.digitalia.fosdem.flow.rememberTickerFlow
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -17,15 +29,33 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LiveViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { class LiveViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
private val heartbeat = LiveDataFactory.interval(REFRESH_PERIOD) // Share a single ticker providing the time to ensure both lists are synchronized
private val ticker: Flow<Instant> =
rememberTickerFlow(REFRESH_PERIOD)
.map { Instant.now() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
.filterNotNull()
val nextEvents: LiveData<PagedList<StatusEvent>> = heartbeat.switchMap { @OptIn(ExperimentalCoroutinesApi::class)
val now = Instant.now() private fun createLiveEventsHotFlow(
scheduleDao.getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL).toLiveData(20) pagingSourceFactory: (now: Instant) -> PagingSource<Int, StatusEvent>
): Flow<PagingData<StatusEvent>> {
return countSubscriptionsFlow { subscriptionCount ->
ticker
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
.distinctUntilChanged()
.flatMapLatest { now ->
Pager(PagingConfig(20)) { pagingSourceFactory(now) }.flow
}.cachedIn(viewModelScope)
}
} }
val eventsInProgress: LiveData<PagedList<StatusEvent>> = heartbeat.switchMap { val nextEvents: Flow<PagingData<StatusEvent>> = createLiveEventsHotFlow { now ->
scheduleDao.getEventsInProgress(Instant.now()).toLiveData(20) scheduleDao.getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL)
}
val eventsInProgress: Flow<PagingData<StatusEvent>> = createLiveEventsHotFlow { now ->
scheduleDao.getEventsInProgress(now)
} }
companion object { companion object {

View file

@ -1,29 +1,30 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import androidx.paging.PagedList import androidx.paging.Pager
import androidx.paging.toLiveData import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import javax.inject.Inject import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
@HiltViewModel class PersonInfoViewModel @AssistedInject constructor(
class PersonInfoViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted person: Person
) : ViewModel() {
private val personLiveData = MutableLiveData<Person>() val events: Flow<PagingData<StatusEvent>> = Pager(PagingConfig(20)) {
scheduleDao.getEvents(person)
}.flow.cachedIn(viewModelScope)
val events: LiveData<PagedList<StatusEvent>> = personLiveData.switchMap { person: Person -> @AssistedFactory
scheduleDao.getEvents(person).toLiveData(20) interface Factory {
} fun create(person: Person): PersonInfoViewModel
fun setPerson(person: Person) {
if (person != personLiveData.value) {
personLiveData.value = person
}
} }
} }

View file

@ -1,16 +1,21 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.PagedList import androidx.lifecycle.viewModelScope
import androidx.paging.toLiveData import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Person
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PersonsViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { class PersonsViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
val persons: LiveData<PagedList<Person>> = scheduleDao.getPersons().toLiveData(100) val persons: Flow<PagingData<Person>> = Pager(PagingConfig(20)) {
scheduleDao.getPersons()
}.flow.cachedIn(viewModelScope)
} }

View file

@ -1,44 +1,49 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map import androidx.lifecycle.viewModelScope
import androidx.lifecycle.switchMap import androidx.paging.Pager
import androidx.paging.PagedList import androidx.paging.PagingConfig
import androidx.paging.toLiveData import androidx.paging.PagingData
import androidx.paging.cachedIn
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { class SearchViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
private val queryLiveData = MutableLiveData<String>() sealed class QueryState {
object Idle : QueryState()
sealed class Result { object TooShort : QueryState()
object QueryTooShort : Result() data class Valid(val query: String) : QueryState()
class Success(val list: PagedList<StatusEvent>) : Result()
} }
val results: LiveData<Result> = queryLiveData.switchMap { query -> private val queryState = MutableStateFlow<QueryState>(QueryState.Idle)
if (query.length < SEARCH_QUERY_MIN_LENGTH) {
MutableLiveData(Result.QueryTooShort) @OptIn(ExperimentalCoroutinesApi::class)
val results: Flow<PagingData<StatusEvent>> = queryState.flatMapLatest { queryState ->
if (queryState is QueryState.Valid) {
Pager(PagingConfig(20)) {
scheduleDao.getSearchResults(queryState.query)
}.flow
} else { } else {
scheduleDao.getSearchResults(query) flowOf(PagingData.empty())
.toLiveData(20)
.map { pagedList -> Result.Success(pagedList) }
} }
} }.cachedIn(viewModelScope)
fun setQuery(query: String) { fun setQuery(query: String) {
if (query != queryLiveData.value) { queryState.value = if (query.length < SEARCH_QUERY_MIN_LENGTH) QueryState.TooShort
queryLiveData.value = query else QueryState.Valid(query)
}
} }
companion object { companion object {
private const val SEARCH_QUERY_MIN_LENGTH = 3 const val SEARCH_QUERY_MIN_LENGTH = 3
} }
} }

View file

@ -1,32 +1,29 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import javax.inject.Inject import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@HiltViewModel class TrackScheduleEventViewModel @AssistedInject constructor(
class TrackScheduleEventViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted day: Day,
@Assisted track: Track
) : ViewModel() {
private val dayTrackLiveData = MutableLiveData<Pair<Day, Track>>() val scheduleSnapshot: Deferred<List<Event>> = viewModelScope.async {
scheduleDao.getEventsWithoutBookmarkStatus(day, track)
val scheduleSnapshot: LiveData<List<Event>> = dayTrackLiveData.switchMap { (day, track) ->
liveData {
emit(scheduleDao.getEventsSnapshot(day, track))
}
} }
fun setDayAndTrack(day: Day, track: Track) { @AssistedFactory
val dayTrack = day to track interface Factory {
if (dayTrack != dayTrackLiveData.value) { fun create(day: Day, track: Track): TrackScheduleEventViewModel
dayTrackLiveData.value = dayTrack
}
} }
} }

View file

@ -1,56 +1,63 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map import androidx.lifecycle.viewModelScope
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory import be.digitalia.fosdem.flow.schedulerFlow
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.flow.tickerFlow
import be.digitalia.fosdem.flow.versionedResourceFlow
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.DateUtils import be.digitalia.fosdem.utils.DateUtils
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel class TrackScheduleListViewModel @AssistedInject constructor(
class TrackScheduleListViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted day: Day,
@Assisted track: Track
) : ViewModel() {
private val dayTrackLiveData = MutableLiveData<Pair<Day, Track>>() val schedule: Flow<List<StatusEvent>> = stateFlow(viewModelScope, null) { subscriptionCount ->
versionedResourceFlow(scheduleDao.bookmarksVersion, subscriptionCount) {
val schedule: LiveData<List<StatusEvent>> = dayTrackLiveData.switchMap { (day, track) -> scheduleDao.getEvents(day, track)
scheduleDao.getEvents(day, track) }
} }.filterNotNull()
/** /**
* @return The current time during the target day, or null outside of the target day. * @return The current time during the target day, or null outside of the target day.
*/ */
val currentTime: LiveData<Instant?> = dayTrackLiveData @OptIn(ExperimentalCoroutinesApi::class)
.switchMap { (day, _) -> val currentTime: Flow<Instant?> = run {
// Auto refresh during the day passed as argument // Auto refresh during the day passed as argument
val dayStart = day.date.atStartOfDay(DateUtils.conferenceZoneId).toInstant() val dayStart = day.date.atStartOfDay(DateUtils.conferenceZoneId).toInstant()
LiveDataFactory.scheduler( schedulerFlow(
dayStart.toEpochMilli(), dayStart.toEpochMilli(),
(dayStart + Duration.ofDays(1L)).toEpochMilli() (dayStart + Duration.ofDays(1L)).toEpochMilli()
) )
} }.flatMapLatest { isOn ->
.switchMap { isOn -> if (isOn) {
if (isOn) { tickerFlow(TIME_REFRESH_PERIOD).map { Instant.now() }
LiveDataFactory.interval(TIME_REFRESH_PERIOD).map { Instant.now() } } else {
} else { flowOf(null)
MutableLiveData(null)
}
} }
}
fun setDayAndTrack(day: Day, track: Track) { @AssistedFactory
val dayTrack = day to track interface Factory {
if (dayTrack != dayTrackLiveData.value) { fun create(day: Day, track: Track): TrackScheduleListViewModel
dayTrackLiveData.value = dayTrack
}
} }
companion object { companion object {

View file

@ -1,21 +1,26 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.transform
/** /**
* ViewModel used for communication between TrackScheduleActivity and TrackScheduleListFragment * ViewModel used for communication between TrackScheduleActivity and TrackScheduleListFragment
*/ */
class TrackScheduleViewModel : ViewModel() { class TrackScheduleViewModel : ViewModel() {
private var _selectedEvent = MutableLiveData<Event?>() private val eventSelection = MutableStateFlow<EventSelection?>(null)
val selectedEvent: LiveData<Event?> = _selectedEvent val selectedEventFlow: Flow<Event?> = eventSelection.transform { selection ->
if (selection != null) emit(selection.event)
fun setSelectEvent(event: Event?) {
if (event != _selectedEvent.value) {
_selectedEvent.value = event
}
} }
var selectedEvent: Event?
get() = eventSelection.value?.event
set(value) {
eventSelection.value = EventSelection(value)
}
private data class EventSelection(val event: Event?)
} }

View file

@ -1,27 +1,31 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.flow.versionedResourceFlow
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Track import be.digitalia.fosdem.model.Track
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.assisted.Assisted
import javax.inject.Inject import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
@HiltViewModel class TracksListViewModel @AssistedInject constructor(
class TracksListViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { scheduleDao: ScheduleDao,
@Assisted day: Day
) : ViewModel() {
private val dayLiveData = MutableLiveData<Day>() val tracks: Flow<List<Track>> = stateFlow(viewModelScope, null) { subscriptionCount ->
versionedResourceFlow(scheduleDao.version, subscriptionCount) {
val tracks: LiveData<List<Track>> = dayLiveData.switchMap { day: Day -> scheduleDao.getTracks(day)
scheduleDao.getTracks(day)
}
fun setDay(day: Day) {
if (day != dayLiveData.value) {
dayLiveData.value = day
} }
}.filterNotNull()
@AssistedFactory
interface Factory {
fun create(day: Day): TracksListViewModel
} }
} }

View file

@ -1,19 +1,14 @@
package be.digitalia.fosdem.viewmodels package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Day
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TracksViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { class TracksViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
val days: LiveData<List<Day>> = scheduleDao.days val days: Flow<List<Day>> = scheduleDao.days
.asLiveData(viewModelScope.coroutineContext)
.distinctUntilChanged()
} }

View file

@ -4,6 +4,7 @@ import android.widget.ImageButton
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.model.BookmarkStatus import be.digitalia.fosdem.model.BookmarkStatus
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
/** /**
@ -12,19 +13,22 @@ import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
*/ */
fun ImageButton.setupBookmarkStatus(viewModel: BookmarkStatusViewModel, owner: LifecycleOwner) { fun ImageButton.setupBookmarkStatus(viewModel: BookmarkStatusViewModel, owner: LifecycleOwner) {
setOnClickListener { viewModel.toggleBookmarkStatus() } setOnClickListener { viewModel.toggleBookmarkStatus() }
viewModel.bookmarkStatus.observe(owner) { bookmarkStatus: BookmarkStatus? -> var previousBookmarkStatus: BookmarkStatus? = null
if (bookmarkStatus == null) { owner.launchAndRepeatOnLifecycle {
isEnabled = false viewModel.bookmarkStatus.collect { bookmarkStatus: BookmarkStatus? ->
isSelected = false if (bookmarkStatus == null) {
} else { isEnabled = false
val wasEnabled = isEnabled isSelected = false
isEnabled = true } else {
contentDescription = context.getString(if (bookmarkStatus.isBookmarked) R.string.remove_bookmark else R.string.add_bookmark) isEnabled = true
isSelected = bookmarkStatus.isBookmarked contentDescription = context.getString(if (bookmarkStatus.isBookmarked) R.string.remove_bookmark else R.string.add_bookmark)
// Only animate updates, when the button was already enabled isSelected = bookmarkStatus.isBookmarked
if (!(bookmarkStatus.isUpdate && wasEnabled)) { // Only animate when the button was showing the status of the same event
jumpDrawablesToCurrentState() if (bookmarkStatus.eventId != previousBookmarkStatus?.eventId) {
jumpDrawablesToCurrentState()
}
} }
previousBookmarkStatus = bookmarkStatus
} }
} }
} }