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.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'androidx.paging:paging-runtime:3.1.0'
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-paging:$room_version"
implementation "androidx.datastore:datastore-preferences:1.0.0"
kapt "androidx.room:room-compiler:$room_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.fragment.app.add
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.extractNfcAppData
import be.digitalia.fosdem.utils.hasNfcAppData
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.widgets.setupBookmarkStatus
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.
@ -39,7 +42,20 @@ import dagger.hilt.android.AndroidEntryPoint
class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfcAppDataCallback {
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?) {
super.onCreate(savedInstanceState)
@ -59,23 +75,11 @@ class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfc
}
}
} else {
// Load the event from the DB using its id
if (!viewModel.isEventIdSet) {
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 ->
lifecycleScope.launchWhenStarted {
val event = viewModel.event.await()
if (event == null) {
// 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()
} else {
initEvent(event)
@ -84,7 +88,7 @@ class EventDetailsActivity : AppCompatActivity(R.layout.single_event), CreateNfc
if (fm.findFragmentById(R.id.content) == null) {
fm.commit(allowStateLoss = true) {
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.awaitCloseDrawer
import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable
import com.google.android.material.navigation.NavigationView
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)
// Monitor the schedule download
api.downloadScheduleState.observe(this) { state ->
when (state) {
is LoadingState.Loading -> {
with(progressIndicator) {
when (val progressValue = state.progress) {
-1 -> if (!isIndeterminate) {
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)
launchAndRepeatOnLifecycle {
api.downloadScheduleState.collect { state ->
when (state) {
is LoadingState.Loading -> {
with(progressIndicator) {
when (val progressValue = state.progress) {
-1 -> if (!isIndeterminate) {
isInvisible = true
isIndeterminate = true
}
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.invertImageColors
import be.digitalia.fosdem.utils.isLightTheme
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.toSlug
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -77,14 +78,15 @@ class RoomImageDialogActivity : AppCompatActivity(R.layout.dialog_room_image) {
}
}
// Display the room status as subtitle
api.roomStatuses.observe(owner) { roomStatuses ->
val roomStatus = roomStatuses[roomName]
toolbar.subtitle = if (roomStatus != null) {
SpannableString(context.getString(roomStatus.nameResId)).apply {
this[0, length] = ForegroundColorSpan(ContextCompat.getColor(context, roomStatus.colorResId))
owner.launchAndRepeatOnLifecycle {
// Display the room status as subtitle
api.roomStatuses.collect { statuses ->
toolbar.subtitle = statuses[roomName]?.let { roomStatus ->
SpannableString(context.getString(roomStatus.nameResId)).apply {
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.add
import androidx.fragment.app.commit
import androidx.fragment.app.commitNow
import androidx.fragment.app.replace
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.fragments.RoomImageDialogFragment
import be.digitalia.fosdem.fragments.TrackScheduleListFragment
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.isLightTheme
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable
import be.digitalia.fosdem.utils.setTaskColorPrimary
import be.digitalia.fosdem.utils.statusBarColorCompat
@ -99,26 +100,27 @@ class TrackScheduleActivity : AppCompatActivity(R.layout.track_schedule), Create
if (isTabletLandscape) {
// Tablet mode: Show event details in the right pane fragment
viewModel.selectedEvent.observe(this) { event: Event? ->
val currentFragment = fm.findFragmentById(R.id.event) as EventDetailsFragment?
if (event != null) {
// Only replace the fragment if the event is different
if (currentFragment?.event != event) {
// Allow state loss since the event fragment will be synchronized with the list selection after activity re-creation
fm.commit {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
replace<EventDetailsFragment>(R.id.event,
launchAndRepeatOnLifecycle {
viewModel.selectedEventFlow.collect { event ->
val currentFragment = fm.findFragmentById(R.id.event) as EventDetailsFragment?
if (event != null) {
// Only replace the fragment if the event is different
if (currentFragment?.event != event) {
fm.commitNow {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
replace<EventDetailsFragment>(R.id.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)
@ -138,7 +140,7 @@ class TrackScheduleActivity : AppCompatActivity(R.layout.track_schedule), Create
// CreateNfcAppDataCallback
override fun createNfcAppData(): NdefRecord? {
return viewModel.selectedEvent.value?.toNfcAppData(this)
return viewModel.selectedEvent?.toNfcAppData(this)
}
companion object {

View file

@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
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.Track
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.assistedViewModels
import be.digitalia.fosdem.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.instantiate
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.setupBookmarkStatus
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.
@ -44,16 +47,23 @@ import dagger.hilt.android.AndroidEntryPoint
class TrackScheduleEventActivity : AppCompatActivity(R.layout.track_schedule_event), CreateNfcAppDataCallback {
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?) {
super.onCreate(savedInstanceState)
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 pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection()
@ -92,28 +102,25 @@ class TrackScheduleEventActivity : AppCompatActivity(R.layout.track_schedule_eve
progress.isVisible = true
with(viewModel) {
setDayAndTrack(day, track)
scheduleSnapshot.observe(this@TrackScheduleEventActivity) { events ->
progress.isVisible = false
lifecycleScope.launchWhenStarted {
val events = viewModel.scheduleSnapshot.await()
progress.isVisible = false
pager.isVisible = true
adapter.events = events
pager.isVisible = true
adapter.events = events
// Delay setting the adapter
// to ensure the current position is restored properly
if (pager.adapter == null) {
pager.adapter = adapter
// Delay setting the adapter to ensure the current position is restored properly
if (pager.adapter == null) {
pager.adapter = adapter
if (initialEventId != -1L) {
val position = events.indexOfFirst { it.id == initialEventId }
if (position != -1) {
pager.setCurrentItem(position, false)
}
if (initialEventId != -1L) {
val position = events.indexOfFirst { it.id == initialEventId }
if (position != -1) {
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 observers = SimpleArrayMap<AdapterDataObserver, BookmarksDataObserverWrapper>()
var roomStatuses: Map<String, RoomStatus>? = null
var roomStatuses: Map<String, RoomStatus> = emptyMap()
set(value) {
field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
if (field != value) {
field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
}
init {
@ -56,16 +58,12 @@ class BookmarksAdapter(context: Context, private val multiChoiceHelper: MultiCho
return ViewHolder(view, multiChoiceHelper, timeFormatter, errorColor)
}
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.get(event.roomName)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val event = getItem(position)
holder.bind(event)
val previous = if (position > 0) 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()
}
@ -77,7 +75,7 @@ class BookmarksAdapter(context: Context, private val multiChoiceHelper: MultiCho
if (DETAILS_PAYLOAD in payloads) {
val previous = if (position > 0) 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) {
holder.bindSelection()

View file

@ -12,7 +12,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.text.set
import androidx.core.view.isGone
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity
@ -23,14 +23,16 @@ import be.digitalia.fosdem.utils.DateUtils
import java.time.format.DateTimeFormatter
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)
var roomStatuses: Map<String, RoomStatus>? = null
var roomStatuses: Map<String, RoomStatus> = emptyMap()
set(value) {
field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
if (field != value) {
field = value
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
}
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)
}
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.get(event.roomName)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val statusEvent = getItem(position)
if (statusEvent == null) {
@ -51,7 +49,7 @@ class EventsAdapter constructor(context: Context, private val showDay: Boolean =
} else {
val event = statusEvent.event
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 (DETAILS_PAYLOAD in payloads) {
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 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.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory.scheduler
import be.digitalia.fosdem.livedata.SingleEvent
import be.digitalia.fosdem.flow.flowWhileShared
import be.digitalia.fosdem.flow.schedulerFlow
import be.digitalia.fosdem.flow.stateFlow
import be.digitalia.fosdem.model.DownloadScheduleResult
import be.digitalia.fosdem.model.LoadingState
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.network.HttpClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
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.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okio.buffer
import java.time.LocalTime
@ -45,12 +51,13 @@ class FosdemApi @Inject constructor(
private val alarmManager: AppAlarmManager
) {
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.
* 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
fun downloadSchedule(): Job {
@ -73,7 +80,7 @@ class FosdemApi @Inject constructor(
ByteCountSource(body.source(), length / 10L) { byteCount ->
// Cap percent to 100
val percent = (byteCount * 100L / length).toInt().coerceAtMost(100)
_downloadScheduleState.postValue(LoadingState.Loading(percent))
_downloadScheduleState.value = LoadingState.Loading(percent)
}.buffer()
} else {
body.source()
@ -92,56 +99,68 @@ class FosdemApi @Inject constructor(
} catch (e: Exception) {
DownloadScheduleResult.Error
}
_downloadScheduleState.value = LoadingState.Idle(SingleEvent(res))
_downloadScheduleState.value = LoadingState.Idle(res)
}
val downloadScheduleState: LiveData<LoadingState<DownloadScheduleResult>>
get() = _downloadScheduleState
val downloadScheduleState: StateFlow<LoadingState<DownloadScheduleResult>> =
_downloadScheduleState.asStateFlow()
val roomStatuses: LiveData<Map<String, RoomStatus>> by lazy(LazyThreadSafetyMode.NONE) {
// The room statuses will only be loaded when the event is live.
// Use the days from the database to determine it.
val scheduler = scheduleDao.days.asLiveData().distinctUntilChanged().switchMap { days ->
val startEndTimestamps = LongArray(days.size * 2)
var index = 0
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
}
scheduler(*startEndTimestamps)
fun downloadScheduleResultConsumed() {
_downloadScheduleState.update { state ->
if (state is LoadingState.Idle) LoadingState.Idle() else state
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val roomStatuses: Flow<Map<String, RoomStatus>> by lazy(LazyThreadSafetyMode.NONE) {
stateFlow(BackgroundWorkScope, emptyMap()) { subscriptionCount ->
// The room statuses will only be loaded when the event is live.
// Use the days from the database to determine it.
val scheduler = scheduleDao.days.flatMapLatest { days ->
val startEndTimestamps = LongArray(days.size * 2)
var index = 0
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
// 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 expirationTime = Long.MAX_VALUE
var retryAttempt = 0
return liveData {
return flow {
var now = SystemClock.elapsedRealtime()
var nextRefreshDelay = nextRefreshTime - now
if (now > expirationTime && latestValue?.isEmpty() == false) {
if (now > expirationTime) {
// When the data expires, replace it with an empty value
emit(emptyMap())
}
while (true) {
if (nextRefreshDelay > 0) {
delay(nextRefreshDelay)
}
delay(nextRefreshDelay)
nextRefreshDelay = try {
val response = httpClient.get(FosdemUrls.rooms) { body, _ ->
@ -159,7 +178,7 @@ class FosdemApi @Inject constructor(
}
now = SystemClock.elapsedRealtime()
if (now > expirationTime && latestValue?.isEmpty() == false) {
if (now > expirationTime) {
emit(emptyMap())
}

View file

@ -1,7 +1,5 @@
package be.digitalia.fosdem.db
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@ -10,16 +8,21 @@ import androidx.room.Transaction
import androidx.room.TypeConverters
import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters
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.Event
import kotlinx.coroutines.flow.StateFlow
import java.time.Instant
@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.
*
* @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,
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
ORDER BY e.start_time ASC""")
@TypeConverters(NonNullInstantTypeConverters::class)
abstract fun getBookmarks(minStartTime: Instant): LiveData<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>
abstract suspend fun getBookmarks(minStartTime: Instant = Instant.EPOCH): List<Event>
@Query("""SELECT b.event_id, e.start_time
FROM bookmarks b
@ -59,7 +48,7 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
abstract suspend fun getBookmarksAlarmInfo(minStartTime: Instant): List<AlarmInfo>
@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? {
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.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
import androidx.paging.DataSource
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
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.Track
import be.digitalia.fosdem.utils.BackgroundWorkScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
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 java.time.Instant
import java.time.LocalDate
import java.util.HashSet
@Dao
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.
@ -218,16 +223,17 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
protected abstract fun clearDays()
// Cache days
val days: Flow<List<Day>> by lazy {
getDaysInternal().shareIn(
@OptIn(ExperimentalCoroutinesApi::class)
val days: Flow<List<Day>> = appDatabase.createVersionFlow(Day.TABLE_NAME)
.mapLatest { getDaysInternal() }
.stateIn(
scope = BackgroundWorkScope,
started = SharingStarted.Eagerly,
replay = 1
)
}
started = SharingStarted.Lazily,
initialValue = null
).filterNotNull()
@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 {
// 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
GROUP BY t.id
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.
@ -273,10 +279,10 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE e.id IN (:ids)
GROUP BY e.id
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,
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
GROUP BY e.id
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,
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
GROUP BY e.id
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.
@ -326,7 +332,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
GROUP BY e.id
ORDER BY e.start_time ASC""")
@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.
@ -345,7 +351,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
GROUP BY e.id
ORDER BY e.start_time DESC""")
@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.
@ -363,7 +369,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
WHERE ep2.person_id = :person
GROUP BY e.id
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.
@ -397,7 +403,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
)
GROUP BY e.id
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.
@ -405,16 +411,14 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
@Query("""SELECT `rowid`, name
FROM persons
ORDER BY name COLLATE NOCASE""")
abstract fun getPersons(): DataSource.Factory<Int, Person>
abstract fun getPersons(): PagingSource<Int, Person>
fun getEventDetails(event: Event): LiveData<EventDetails> {
return liveData {
// Load persons and links in parallel as soon as the LiveData becomes active
coroutineScope {
val persons = async { getPersons(event) }
val links = async { getLinks(event) }
emit(EventDetails(persons.await(), links.await()))
}
suspend fun getEventDetails(event: Event): EventDetails {
// Load persons and links in parallel
return coroutineScope {
val persons = async { getPersons(event) }
val links = async { getLinks(event) }
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.providers.BookmarksExportProvider
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.toBookmarksNfcAppData
import be.digitalia.fosdem.viewmodels.BookmarksViewModel
import be.digitalia.fosdem.widgets.MultiChoiceHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import java.util.concurrent.CancellationException
import javax.inject.Inject
import javax.inject.Named
@ -116,13 +119,19 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC
isProgressBarVisible = true
}
api.roomStatuses.observe(viewLifecycleOwner) { statuses ->
adapter.roomStatuses = statuses
}
viewModel.bookmarks.observe(viewLifecycleOwner) { bookmarks ->
adapter.submitList(bookmarks)
multiChoiceHelper.setAdapter(adapter, viewLifecycleOwner)
holder.isProgressBarVisible = false
viewLifecycleOwner.launchAndRepeatOnLifecycle {
launch {
api.roomStatuses.collect { statuses ->
adapter.roomStatuses = statuses
}
}
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? {
val context = context ?: return null
val bookmarks = viewModel.bookmarks.value
return if (bookmarks.isNullOrEmpty()) {
null
} else bookmarks.toBookmarksNfcAppData(context)
return if (bookmarks.isNullOrEmpty()) null else bookmarks.toBookmarksNfcAppData(context)
}
companion object {

View file

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

View file

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

View file

@ -5,22 +5,30 @@ import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.LiveViewModel
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
@AndroidEntryPoint
sealed class LiveListFragment(@StringRes private val emptyTextResId: Int,
private val dataSourceProvider: (LiveViewModel) -> LiveData<PagedList<StatusEvent>>)
: Fragment(R.layout.recyclerview) {
sealed class LiveListFragment(
@StringRes private val emptyTextResId: Int,
private val dataSourceProvider: (LiveViewModel) -> Flow<PagingData<StatusEvent>>
) : Fragment(R.layout.recyclerview) {
@Inject
lateinit var api: FosdemApi
@ -45,19 +53,30 @@ sealed class LiveListFragment(@StringRes private val emptyTextResId: Int,
isProgressBarVisible = true
}
api.roomStatuses.observe(viewLifecycleOwner) { statuses ->
adapter.roomStatuses = statuses
}
dataSourceProvider(viewModel).observe(viewLifecycleOwner) { events ->
adapter.submitList(events) {
// Ensure we stay at scroll position 0 so we can see the insertion animation
holder.recyclerView.run {
if (scrollY == 0) {
scrollToPosition(0)
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow
.distinctUntilChangedBy { it.refresh }
.filter { it.refresh !is LoadState.Loading }
.collect {
holder.isProgressBarVisible = false
// Ensure we stay at scroll position 0 so we can see the insertion animation
with(holder.recyclerView) {
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.ViewGroup
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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -13,8 +14,13 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi
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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@ -22,14 +28,16 @@ class PersonInfoListFragment : Fragment(R.layout.recyclerview) {
@Inject
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?) {
super.onViewCreated(view, savedInstanceState)
val person: Person = requireArguments().getParcelable(ARG_PERSON)!!
viewModel.setPerson(person)
val adapter = EventsAdapter(view.context)
val holder = RecyclerViewViewHolder(view).apply {
recyclerView.apply {
@ -47,13 +55,23 @@ class PersonInfoListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true
}
api.roomStatuses.observe(viewLifecycleOwner) { statuses ->
adapter.roomStatuses = statuses
}
viewModel.events.observe(viewLifecycleOwner) { events ->
adapter.submitList(events)
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
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>() {

View file

@ -8,7 +8,9 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -16,8 +18,12 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.PersonInfoActivity
import be.digitalia.fosdem.adapters.createSimpleItemCallback
import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.PersonsViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@AndroidEntryPoint
class PersonsListFragment : Fragment(R.layout.recyclerview_fastscroll) {
@ -38,13 +44,19 @@ class PersonsListFragment : Fragment(R.layout.recyclerview_fastscroll) {
isProgressBarVisible = true
}
viewModel.persons.observe(viewLifecycleOwner) { persons ->
adapter.submitList(persons)
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
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 {
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 androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.viewmodels.SearchViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@ -34,12 +40,22 @@ class SearchResultListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true
}
api.roomStatuses.observe(viewLifecycleOwner) { statuses ->
adapter.roomStatuses = statuses
}
viewModel.results.observe(viewLifecycleOwner) { result ->
adapter.submitList((result as? SearchViewModel.Result.Success)?.list)
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.first { it.refresh !is LoadState.Loading }
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 androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
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.Event
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.TrackScheduleViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
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 selectionEnabled: Boolean by lazy(LazyThreadSafetyMode.NONE) {
resources.getBoolean(R.bool.tablet_landscape)
@ -32,16 +42,11 @@ class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackSchedule
override fun onCreate(savedInstanceState: Bundle?) {
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) {
isListAlreadyShown = savedInstanceState.getBoolean(STATE_IS_LIST_ALREADY_SHOWN)
}
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
@ -66,45 +71,54 @@ class TrackScheduleListFragment : Fragment(R.layout.recyclerview), TrackSchedule
isProgressBarVisible = true
}
with(viewModel) {
currentTime.observe(viewLifecycleOwner) { now ->
adapter.currentTime = now
}
schedule.observe(viewLifecycleOwner) { schedule ->
adapter.submitList(schedule)
viewLifecycleOwner.launchAndRepeatOnLifecycle {
launch {
viewModel.schedule.collect { schedule ->
adapter.submitList(schedule)
var selectedPosition = if (selectedId == -1L) -1 else schedule.indexOfFirst { it.event.id == selectedId }
if (selectedPosition == -1) {
// There is no current valid selection, reset to use the first item (if any)
if (schedule.isNotEmpty()) {
selectedPosition = 0
selectedId = schedule[0].event.id
} else {
selectedId = -1L
var selectedPosition = if (selectedId == -1L) -1 else schedule.indexOfFirst { it.event.id == selectedId }
if (selectedPosition == -1) {
// There is no current valid selection, reset to use the first item (if any)
if (schedule.isNotEmpty()) {
selectedPosition = 0
selectedId = schedule[0].event.id
} else {
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) {
selectedId = event.id
activityViewModel.setSelectEvent(event)
activityViewModel.selectedEvent = event
if (!selectionEnabled) {
// 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.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.instantiate
import be.digitalia.fosdem.utils.launchAndRepeatOnLifecycle
import be.digitalia.fosdem.utils.recyclerView
import be.digitalia.fosdem.utils.viewLifecycleLazy
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)
} else -1
viewModel.days.observe(viewLifecycleOwner) { days ->
holder.run {
daysAdapter.days = days
viewLifecycleOwner.launchAndRepeatOnLifecycle {
viewModel.days.collect { days ->
holder.run {
daysAdapter.days = days
val totalPages = daysAdapter.itemCount
if (totalPages == 0) {
contentView.isVisible = false
emptyView.isVisible = true
} else {
contentView.isVisible = true
emptyView.isVisible = false
if (pager.adapter == null) {
pager.adapter = daysAdapter
TabLayoutMediator(tabs, pager) { tab, position -> tab.text = daysAdapter.getPageTitle(position) }.attach()
}
if (savedCurrentPage != -1) {
pager.setCurrentItem(savedCurrentPage.coerceAtMost(totalPages - 1), false)
savedCurrentPage = -1
if (days.isEmpty()) {
contentView.isVisible = false
emptyView.isVisible = true
} else {
contentView.isVisible = true
emptyView.isVisible = false
if (pager.adapter == null) {
pager.adapter = daysAdapter
TabLayoutMediator(tabs, pager) { tab, position -> tab.text = daysAdapter.getPageTitle(position) }.attach()
}
if (savedCurrentPage != -1) {
pager.setCurrentItem(savedCurrentPage.coerceAtMost(days.size - 1), false)
savedCurrentPage = -1
}
}
}
}

View file

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -18,13 +17,21 @@ import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.TrackScheduleActivity
import be.digitalia.fosdem.model.Day
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 dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
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) {
requireArguments().getParcelable(ARG_DAY)!!
}
@ -48,9 +55,8 @@ class TracksListFragment : Fragment(R.layout.recyclerview) {
isProgressBarVisible = true
}
with(viewModel) {
setDay(day)
tracks.observe(viewLifecycleOwner) { tracks ->
viewLifecycleOwner.launchAndRepeatOnLifecycle {
viewModel.tracks.collect { tracks ->
adapter.submitList(tracks)
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
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
sealed class DownloadScheduleResult {
class Success(val eventsCount: Int) : DownloadScheduleResult()
data class Success(val eventsCount: Int) : DownloadScheduleResult()
object Error : DownloadScheduleResult()
object UpToDate : DownloadScheduleResult()
}

View file

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

View file

@ -105,7 +105,7 @@ class BookmarksExportProvider : ContentProvider() {
override fun run() {
try {
ICalendarWriter(outputStream.sink().buffer()).use { writer ->
val bookmarks = bookmarksDao.getBookmarks()
val bookmarks = runBlocking { bookmarksDao.getBookmarks() }
writer.write("BEGIN", "VCALENDAR")
writer.write("VERSION", "2.0")
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
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.alarms.AppAlarmManager
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.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope
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 javax.inject.Inject
@ -21,37 +24,34 @@ class BookmarkStatusViewModel @Inject constructor(
private val alarmManager: AppAlarmManager
) : ViewModel() {
private val eventLiveData = MutableLiveData<Event?>()
private var firstResultReceived = false
private val eventStateFlow = MutableStateFlow<Event?>(null)
val bookmarkStatus: LiveData<BookmarkStatus?> = eventLiveData.switchMap { event ->
if (event == null) {
MutableLiveData(null)
} else {
bookmarksDao.getBookmarkStatus(event)
.distinctUntilChanged() // Prevent updating the UI when a bookmark is added back or removed back
.map { isBookmarked ->
val isUpdate = firstResultReceived
firstResultReceived = true
BookmarkStatus(isBookmarked, isUpdate)
@OptIn(ExperimentalCoroutinesApi::class)
val bookmarkStatus: StateFlow<BookmarkStatus?> =
stateFlow(viewModelScope, null) { subscriptionCount ->
eventStateFlow.flatMapLatest { event ->
if (event == null) {
flowOf(null)
} else {
versionedResourceFlow(bookmarksDao.version, subscriptionCount) {
val isBookmarked = bookmarksDao.getBookmarkStatus(event)
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() {
val event = event
val event = eventStateFlow.value
val currentStatus = bookmarkStatus.value
// 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) {
removeBookmark(event)
} else {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,26 @@
package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap
import androidx.paging.PagedList
import androidx.paging.toLiveData
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
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.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 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.Instant
import java.util.concurrent.TimeUnit
@ -17,15 +29,33 @@ import javax.inject.Inject
@HiltViewModel
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 {
val now = Instant.now()
scheduleDao.getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL).toLiveData(20)
@OptIn(ExperimentalCoroutinesApi::class)
private fun createLiveEventsHotFlow(
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 {
scheduleDao.getEventsInProgress(Instant.now()).toLiveData(20)
val nextEvents: Flow<PagingData<StatusEvent>> = createLiveEventsHotFlow { now ->
scheduleDao.getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL)
}
val eventsInProgress: Flow<PagingData<StatusEvent>> = createLiveEventsHotFlow { now ->
scheduleDao.getEventsInProgress(now)
}
companion object {

View file

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

View file

@ -1,16 +1,21 @@
package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import androidx.paging.toLiveData
import androidx.lifecycle.viewModelScope
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.model.Person
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@HiltViewModel
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
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.paging.PagedList
import androidx.paging.toLiveData
import androidx.lifecycle.viewModelScope
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.model.StatusEvent
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
@HiltViewModel
class SearchViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
private val queryLiveData = MutableLiveData<String>()
sealed class Result {
object QueryTooShort : Result()
class Success(val list: PagedList<StatusEvent>) : Result()
sealed class QueryState {
object Idle : QueryState()
object TooShort : QueryState()
data class Valid(val query: String) : QueryState()
}
val results: LiveData<Result> = queryLiveData.switchMap { query ->
if (query.length < SEARCH_QUERY_MIN_LENGTH) {
MutableLiveData(Result.QueryTooShort)
private val queryState = MutableStateFlow<QueryState>(QueryState.Idle)
@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 {
scheduleDao.getSearchResults(query)
.toLiveData(20)
.map { pagedList -> Result.Success(pagedList) }
flowOf(PagingData.empty())
}
}
}.cachedIn(viewModelScope)
fun setQuery(query: String) {
if (query != queryLiveData.value) {
queryLiveData.value = query
}
queryState.value = if (query.length < SEARCH_QUERY_MIN_LENGTH) QueryState.TooShort
else QueryState.Valid(query)
}
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
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@HiltViewModel
class TrackScheduleEventViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
class TrackScheduleEventViewModel @AssistedInject constructor(
scheduleDao: ScheduleDao,
@Assisted day: Day,
@Assisted track: Track
) : ViewModel() {
private val dayTrackLiveData = MutableLiveData<Pair<Day, Track>>()
val scheduleSnapshot: LiveData<List<Event>> = dayTrackLiveData.switchMap { (day, track) ->
liveData {
emit(scheduleDao.getEventsSnapshot(day, track))
}
val scheduleSnapshot: Deferred<List<Event>> = viewModelScope.async {
scheduleDao.getEventsWithoutBookmarkStatus(day, track)
}
fun setDayAndTrack(day: Day, track: Track) {
val dayTrack = day to track
if (dayTrack != dayTrackLiveData.value) {
dayTrackLiveData.value = dayTrack
}
@AssistedFactory
interface Factory {
fun create(day: Day, track: Track): TrackScheduleEventViewModel
}
}

View file

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

View file

@ -1,21 +1,26 @@
package be.digitalia.fosdem.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
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
*/
class TrackScheduleViewModel : ViewModel() {
private var _selectedEvent = MutableLiveData<Event?>()
val selectedEvent: LiveData<Event?> = _selectedEvent
fun setSelectEvent(event: Event?) {
if (event != _selectedEvent.value) {
_selectedEvent.value = event
}
private val eventSelection = MutableStateFlow<EventSelection?>(null)
val selectedEventFlow: Flow<Event?> = eventSelection.transform { selection ->
if (selection != null) emit(selection.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
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
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.Track
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
@HiltViewModel
class TracksListViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() {
class TracksListViewModel @AssistedInject constructor(
scheduleDao: ScheduleDao,
@Assisted day: Day
) : ViewModel() {
private val dayLiveData = MutableLiveData<Day>()
val tracks: LiveData<List<Track>> = dayLiveData.switchMap { day: Day ->
scheduleDao.getTracks(day)
}
fun setDay(day: Day) {
if (day != dayLiveData.value) {
dayLiveData.value = day
val tracks: Flow<List<Track>> = stateFlow(viewModelScope, null) { subscriptionCount ->
versionedResourceFlow(scheduleDao.version, subscriptionCount) {
scheduleDao.getTracks(day)
}
}.filterNotNull()
@AssistedFactory
interface Factory {
fun create(day: Day): TracksListViewModel
}
}

View file

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

View file

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