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:
parent
2b96e266cd
commit
3f6c9d6219
47 changed files with 1041 additions and 748 deletions
|
@ -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"
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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)))
|
||||
|
|
18
app/src/main/java/be/digitalia/fosdem/db/RoomDatabaseExt.kt
Normal file
18
app/src/main/java/be/digitalia/fosdem/db/RoomDatabaseExt.kt
Normal 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()
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
74
app/src/main/java/be/digitalia/fosdem/flow/FlowExt.kt
Normal file
74
app/src/main/java/be/digitalia/fosdem/flow/FlowExt.kt
Normal 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)
|
||||
}
|
60
app/src/main/java/be/digitalia/fosdem/flow/Timers.kt
Normal file
60
app/src/main/java/be/digitalia/fosdem/flow/Timers.kt
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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()
|
||||
}
|
|
@ -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>()
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
21
app/src/main/java/be/digitalia/fosdem/utils/LifecycleExt.kt
Normal file
21
app/src/main/java/be/digitalia/fosdem/utils/LifecycleExt.kt
Normal 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)
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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?)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue