diff --git a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Animators.kt b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Animators.kt new file mode 100644 index 000000000..615d71d1b --- /dev/null +++ b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Animators.kt @@ -0,0 +1,41 @@ +package io.github.droidkaigi.confsched2020.ext + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +// This function is copied from https://medium.com/androiddevelopers/suspending-over-views-19de9ebd7020 + +suspend fun Animator.awaitEnd() = suspendCancellableCoroutine { cont -> + // Add an invokeOnCancellation listener. If the coroutine is + // cancelled, cancel the animation too that will notify + // listener's onAnimationCancel() function + cont.invokeOnCancellation { cancel() } + + addListener(object : AnimatorListenerAdapter() { + private var endedSuccessfully = true + + override fun onAnimationCancel(animation: Animator) { + // Animator has been cancelled, so flip the success flag + endedSuccessfully = false + } + + override fun onAnimationEnd(animation: Animator) { + // Make sure we remove the listener so we don't keep + // leak the coroutine continuation + animation.removeListener(this) + + if (cont.isActive) { + // If the coroutine is still active... + if (endedSuccessfully) { + // ...and the Animator ended successfully, resume the coroutine + cont.resume(Unit) + } else { + // ...and the Animator was cancelled, cancel the coroutine too + cont.cancel() + } + } + } + }) +} diff --git a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/AppErrors.kt b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/AppErrors.kt index 5b398cad4..112575ed0 100644 --- a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/AppErrors.kt +++ b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/AppErrors.kt @@ -31,6 +31,7 @@ fun Throwable?.toAppError(): AppError? { fun AppError.stringRes() = when (this) { is AppError.ApiException.NetworkException -> R.string.error_network is AppError.ApiException.ServerException -> R.string.error_server + is AppError.ApiException.SessionNotFoundException -> R.string.error_unknown is AppError.ApiException.UnknownException -> R.string.error_unknown is AppError.ExternalIntegrationError.NoCalendarIntegrationFoundException -> R.string.error_no_calendar_integration diff --git a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/LiveDatas.kt b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/LiveDatas.kt index be14795fd..2397ab55b 100644 --- a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/LiveDatas.kt +++ b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/LiveDatas.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.hadilq.liveevent.LiveEvent +import io.github.droidkaigi.confsched2020.model.AppError +import io.github.droidkaigi.confsched2020.model.ErrorGettable fun LiveData.requireValue() = requireNotNull(value) @@ -108,6 +110,43 @@ inline fun combine }.distinctUntilChanged() } +inline fun combine( + initialValue: T, + liveData1: LiveData, + liveData2: LiveData, + liveData3: LiveData, + liveData4: LiveData, + liveData5: LiveData, + crossinline block: (T, LIVE1, LIVE2, LIVE3, LIVE4, LIVE5) -> T +): LiveData { + return MediatorLiveData().apply { + value = initialValue + listOf(liveData1, liveData2, liveData3, liveData4, liveData5).forEach { liveData -> + addSource(liveData) { + val currentValue = value + val liveData1Value = liveData1.value + val liveData2Value = liveData2.value + val liveData3Value = liveData3.value + val liveData4Value = liveData4.value + val liveData5Value = liveData5.value + if (currentValue != null && liveData1Value != null && + liveData2Value != null && liveData3Value != null && + liveData4Value != null && liveData5Value != null + ) { + value = block( + currentValue, + liveData1Value, + liveData2Value, + liveData3Value, + liveData4Value, + liveData5Value + ) + } + } + } + }.distinctUntilChanged() +} + fun LiveData.setOnEach(mutableLiveData: MutableLiveData): LiveData { return map { mutableLiveData.value = it @@ -124,3 +163,25 @@ fun LiveData.toNonNullSingleEvent(): LiveData { } return result } + +fun merge(vararg liveDatas: LiveData): LiveData { + return MediatorLiveData().apply { + liveDatas.forEach { liveData -> + addSource(liveData) { value -> + this.value = value + } + } + } +} + +fun LiveData.toAppError(): LiveData { + return map { + it.getErrorIfExists().toAppError() + } +} + +fun > LiveData.fromResultToAppError(): LiveData { + return map { + it.exceptionOrNull()?.toAppError() + } +} diff --git a/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Views.kt b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Views.kt new file mode 100644 index 000000000..9418adea6 --- /dev/null +++ b/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2020/ext/Views.kt @@ -0,0 +1,40 @@ +package io.github.droidkaigi.confsched2020.ext + +import android.view.View +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +// This function is copied from https://medium.com/androiddevelopers/suspending-over-views-19de9ebd7020 + +suspend fun View.awaitNextLayout() = suspendCancellableCoroutine { cont -> + // This lambda is invoked immediately, allowing us to create + // a callback/listener + + val listener = object : View.OnLayoutChangeListener { + override fun onLayoutChange( + v: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int + ) { + // The next layout has happened! + // First remove the listener to not leak the coroutine + v?.removeOnLayoutChangeListener(this) + // Finally resume the continuation, and + // wake the coroutine up + cont.resume(Unit) + } + } + // If the coroutine is cancelled, remove the listener + cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) } + // And finally add the listener to view + addOnLayoutChangeListener(listener) + + // The coroutine will now be suspended. It will only be resumed + // when calling cont.resume() in the listener above +} diff --git a/corecomponent/androidcomponent/src/main/res/values/themes.xml b/corecomponent/androidcomponent/src/main/res/values/themes.xml index 91fe0a534..994f94857 100644 --- a/corecomponent/androidcomponent/src/main/res/values/themes.xml +++ b/corecomponent/androidcomponent/src/main/res/values/themes.xml @@ -10,6 +10,7 @@ @style/TextAppearance.DroidKaigi.Body1 @style/TextAppearance.DroidKaigi.Body2 @style/TextAppearance.DroidKaigi.Caption + @style/TextAppearance.DroidKaigi.Button @style/Widget.DroidKaigi.FilterChip diff --git a/corecomponent/androidcomponent/src/main/res/values/type.xml b/corecomponent/androidcomponent/src/main/res/values/type.xml index 8a443cef3..f91693b70 100644 --- a/corecomponent/androidcomponent/src/main/res/values/type.xml +++ b/corecomponent/androidcomponent/src/main/res/values/type.xml @@ -50,4 +50,10 @@ ?colorOnBackground + + diff --git a/data/firestore/build.gradle b/data/firestore/build.gradle index 6f20567fa..79fbdce3e 100644 --- a/data/firestore/build.gradle +++ b/data/firestore/build.gradle @@ -52,6 +52,14 @@ kotlin { implementation Dep.Test.KotlinMultiPlatform.commonModuleTestAnnotations implementation Dep.MockK.common } + test { + dependsOn commonMain + dependencies { + implementation Dep.Test.KotlinMultiPlatform.jvmModuleTest + implementation Dep.Test.KotlinMultiPlatform.jvmModuleTestJunit + implementation Dep.MockK.jvm + } + } androidTest { dependsOn commonMain dependencies { diff --git a/data/firestore/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/data/firestore/Firestore.kt b/data/firestore/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/data/firestore/Firestore.kt index e6ceccf79..7cbfe5596 100644 --- a/data/firestore/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/data/firestore/Firestore.kt +++ b/data/firestore/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/data/firestore/Firestore.kt @@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.Flow interface Firestore { fun getFavoriteSessionIds(): Flow> suspend fun toggleFavorite(sessionId: SessionId) + fun getThumbsUpCount(sessionId: SessionId): Flow + suspend fun incrementThumbsUpCount(sessionId: SessionId, count: Int) } diff --git a/data/firestore/src/main/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImpl.kt b/data/firestore/src/main/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImpl.kt index 0e97bdc9d..7500b093b 100644 --- a/data/firestore/src/main/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImpl.kt +++ b/data/firestore/src/main/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImpl.kt @@ -1,25 +1,30 @@ package io.github.droidkaigi.confsched2020.data.firestore.internal +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.QuerySnapshot import com.google.firebase.firestore.Source import io.github.droidkaigi.confsched2020.data.firestore.Firestore import io.github.droidkaigi.confsched2020.model.SessionId -import javax.inject.Inject import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.tasks.await import timber.log.Timber import timber.log.debug +import javax.inject.Inject +import kotlin.math.floor internal class FirestoreImpl @Inject constructor() : Firestore { @@ -35,7 +40,7 @@ internal class FirestoreImpl @Inject constructor() : Firestore { emit(favoritesRef) } val favoritesSnapshotFlow = setupFavorites.flatMapLatest { - it.whereEqualTo("favorite", true).toFlow() + it.whereEqualTo(FAVORITE_VALUE_KEY, true).toFlow() } return favoritesSnapshotFlow.mapLatest { favorites -> Timber.debug { "favoritesSnapshotFlow onNext" } @@ -57,12 +62,47 @@ internal class FirestoreImpl @Inject constructor() : Firestore { } else { Timber.debug { "toggleFavorite: $sessionId document not exits" } document.reference - .set(mapOf("favorite" to newFavorite)) + .set(mapOf(FAVORITE_VALUE_KEY to newFavorite)) .await() } Timber.debug { "toggleFavorite: end" } } + override fun getThumbsUpCount(sessionId: SessionId): Flow { + val setupThumbsUp = flow { + signInIfNeeded() + val counterRef = getThumbsUpCounterRef(sessionId) + createShardsIfNeeded(counterRef) + emit(counterRef) + } + + val thumbsUpSnapshot = setupThumbsUp.flatMapLatest { + it.toFlow() + } + + return thumbsUpSnapshot.map { shards -> + var count = 0 + shards.forEach { snap -> + count += snap.get(SHARDS_COUNT_KEY, Int::class.java) ?: 0 + } + count + } + } + + override suspend fun incrementThumbsUpCount( + sessionId: SessionId, + count: Int + ) { + signInIfNeeded() + val counterRef = getThumbsUpCounterRef(sessionId) + createShardsIfNeeded(counterRef) + val shardId = floor(Math.random() * NUM_SHARDS).toInt() + counterRef + .document(shardId.toString()) + .update(SHARDS_COUNT_KEY, FieldValue.increment(count.toLong())) + .await() + } + private fun getFavoritesRef(): CollectionReference { val firebaseAuth = FirebaseAuth.getInstance() val firebaseUserId = firebaseAuth.currentUser?.uid ?: throw RuntimeException( @@ -83,6 +123,42 @@ internal class FirestoreImpl @Inject constructor() : Firestore { firebaseAuth.signInAnonymously().await() Timber.debug { "signInIfNeeded end" } } + + private fun getThumbsUpCounterRef(sessionId: SessionId): CollectionReference { + return FirebaseFirestore + .getInstance() + .collection("confsched/2020/sessions/${sessionId.id}/thumbsup_counters") + } + + private suspend fun createShardsIfNeeded(counterRef: CollectionReference) { + val lastShardId = NUM_SHARDS - 1 + val lastShard = counterRef + .document(lastShardId.toString()) + .get(Source.SERVER) + .await() + + if (lastShard.exists()) { + Timber.debug { "createShardsIfNeeded shards already exist" } + return + } + + val tasks = arrayListOf>() + (0 until NUM_SHARDS).forEach { + val makeShard = counterRef + .document(it.toString()) + .set(mapOf(SHARDS_COUNT_KEY to 0)) + tasks.add(makeShard) + } + + Tasks.whenAll(tasks).await() + Timber.debug { "createShardsIfNeeded creating shards completed" } + } + + companion object { + const val NUM_SHARDS = 5 + const val SHARDS_COUNT_KEY = "shards" + const val FAVORITE_VALUE_KEY = "favorite" + } } private suspend fun DocumentReference.fastGet(): DocumentSnapshot { diff --git a/data/firestore/src/test/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImplTest.kt b/data/firestore/src/test/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImplTest.kt new file mode 100644 index 000000000..df165bb9a --- /dev/null +++ b/data/firestore/src/test/java/io/github/droidkaigi/confsched2020/data/firestore/internal/FirestoreImplTest.kt @@ -0,0 +1,59 @@ +package io.github.droidkaigi.confsched2020.data.firestore.internal + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test + +class FirestoreImplTest { + @Ignore + @Test + fun thumbsUpIncrement() { + val channel = BroadcastChannel(10000) + GlobalScope.launch { + delay(100) + println("send") + channel.send(Unit) + println("send") + channel.send(Unit) + println("send") + channel.send(Unit) + delay(600) + println("send") + channel.send(Unit) + delay(200) + println("send") + channel.send(Unit) + delay(200) + println("send") + channel.send(Unit) + delay(200) + println("send") + channel.send(Unit) + delay(600) + channel.cancel() + } + runBlocking { + var lastIndex = -1 + channel.asFlow() + .withIndex() + .debounce(300) + .map { + val result = minOf(it.index - lastIndex, 50) + lastIndex = it.index + result + } + .collect { + println(it) + } + } + } +} diff --git a/data/repository/src/main/java/io/github/droidkaigi/confsched2020/data/repository/internal/DataSessionRepository.kt b/data/repository/src/main/java/io/github/droidkaigi/confsched2020/data/repository/internal/DataSessionRepository.kt index 3bd6b1b8f..4051cedd3 100644 --- a/data/repository/src/main/java/io/github/droidkaigi/confsched2020/data/repository/internal/DataSessionRepository.kt +++ b/data/repository/src/main/java/io/github/droidkaigi/confsched2020/data/repository/internal/DataSessionRepository.kt @@ -18,7 +18,6 @@ import io.github.droidkaigi.confsched2020.model.SessionId import io.github.droidkaigi.confsched2020.model.SessionList import io.github.droidkaigi.confsched2020.model.SpeechSession import io.github.droidkaigi.confsched2020.model.repository.SessionRepository -import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter @@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import timber.log.Timber import timber.log.debug +import javax.inject.Inject internal class DataSessionRepository @Inject constructor( private val droidKaigiApi: DroidKaigiApi, @@ -34,7 +34,6 @@ internal class DataSessionRepository @Inject constructor( private val firestore: Firestore, private val favoriteToggleWork: FavoriteToggleWork ) : SessionRepository { - override fun sessionContents(): Flow { val sessionsFlow = sessions() .map { sessions -> @@ -129,4 +128,18 @@ internal class DataSessionRepository @Inject constructor( val response = droidKaigiApi.getSessions() sessionDatabase.save(response) } + + override fun thumbsUpCounts(sessionId: SessionId): Flow { + return firestore.getThumbsUpCount(sessionId) + } + + override suspend fun incrementThumbsUpCount( + sessionId: SessionId, + count: Int + ) { + firestore.incrementThumbsUpCount( + sessionId = sessionId, + count = count + ) + } } diff --git a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/SessionDetailFragment.kt b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/SessionDetailFragment.kt index f34c3569c..1b423a7dd 100644 --- a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/SessionDetailFragment.kt +++ b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/SessionDetailFragment.kt @@ -7,6 +7,7 @@ import androidx.core.view.doOnNextLayout import androidx.fragment.app.Fragment import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.observe import androidx.navigation.NavOptions import androidx.navigation.fragment.findNavController @@ -26,6 +27,7 @@ import io.github.droidkaigi.confsched2020.ext.isShow import io.github.droidkaigi.confsched2020.model.Session import io.github.droidkaigi.confsched2020.model.Speaker import io.github.droidkaigi.confsched2020.model.SpeechSession +import io.github.droidkaigi.confsched2020.model.ThumbsUpCount import io.github.droidkaigi.confsched2020.model.defaultLang import io.github.droidkaigi.confsched2020.session.R import io.github.droidkaigi.confsched2020.session.databinding.FragmentSessionDetailBinding @@ -115,7 +117,6 @@ class SessionDetailFragment : Fragment(R.layout.fragment_session_detail), Inject sessionDetailViewModel.uiModel .observe(viewLifecycleOwner) { uiModel: SessionDetailViewModel.UiModel -> - uiModel.error?.let { systemViewModel.onError(it) } binding.progressBar.isShow = uiModel.isLoading uiModel.session ?.let { session -> @@ -124,10 +125,12 @@ class SessionDetailFragment : Fragment(R.layout.fragment_session_detail), Inject adapter, session, uiModel.showEllipsis, - uiModel.searchQuery + uiModel.searchQuery, + uiModel.thumbsUpCount ) } - } + uiModel.error?.let { systemViewModel.onError(it) } + } binding.bottomAppBar.run { doOnNextLayout { @@ -193,13 +196,21 @@ class SessionDetailFragment : Fragment(R.layout.fragment_session_detail), Inject adapter: GroupAdapter>, session: Session, showEllipsis: Boolean, - searchQuery: String? + searchQuery: String?, + thumbsUpCount: ThumbsUpCount ) { binding.sessionDetailRecycler.transitionName = "${session.id}-${navArgs.transitionNameSuffix}" val items = mutableListOf() - items += sessionDetailTitleItemFactory.create(session, searchQuery) + items += sessionDetailTitleItemFactory.create( + session, + searchQuery, + viewLifecycleOwner.lifecycleScope, + thumbsUpCount + ) { + sessionDetailViewModel.thumbsUp(session) + } items += sessionDetailDescriptionItemFactory.create( session, showEllipsis, diff --git a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/animation/Increment.kt b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/animation/Increment.kt new file mode 100644 index 000000000..dd5ea1e13 --- /dev/null +++ b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/animation/Increment.kt @@ -0,0 +1,150 @@ +package io.github.droidkaigi.confsched2020.session.ui.animation + +import android.animation.ObjectAnimator +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleCoroutineScope +import io.github.droidkaigi.confsched2020.ext.awaitEnd +import io.github.droidkaigi.confsched2020.ext.awaitNextLayout +import kotlinx.coroutines.async +import kotlinx.coroutines.launch + +// The view is shown with fade-in and move-up animation. +internal fun View.popUp(lifecycleCoroutineScope: LifecycleCoroutineScope) { + val target = this + + lifecycleCoroutineScope.launch { + target.isVisible = true + target.awaitNextLayout() + val popupHeight = (target.height / 3).toFloat() + target.translationY = popupHeight + + val fadeIn = async { + target.alpha = 0f + ObjectAnimator.ofFloat( + target, + View.ALPHA, + 1f + ).run { + interpolator = DecelerateInterpolator() + start() + awaitEnd() + } + } + + val up = async { + ObjectAnimator.ofFloat( + target, + View.TRANSLATION_Y, + -popupHeight + ).run { + interpolator = DecelerateInterpolator() + duration = UP_DOWN_DURATION + start() + awaitEnd() + } + } + + fadeIn.await() + up.await() + } +} + +// The view is hidden with fade-out and move-down animation. +internal fun View.dropOut(lifecycleCoroutineScope: LifecycleCoroutineScope) { + val target = this + + lifecycleCoroutineScope.launch { + val dropOutHeight = (target.height / 3).toFloat() + + val fadeOut = async { + ObjectAnimator.ofFloat( + target, + View.ALPHA, + 0f + ).run { + interpolator = AccelerateInterpolator() + duration = 100 + start() + awaitEnd() + } + } + + val down = async { + ObjectAnimator.ofFloat( + target, + View.TRANSLATION_Y, + dropOutHeight + ).run { + interpolator = AccelerateInterpolator() + duration = UP_DOWN_DURATION + start() + awaitEnd() + } + } + + fadeOut.await() + down.await() + target.isVisible = false + } +} + +// The view is scale up and down at current position. +internal fun View.pop(lifecycleCoroutineScope: LifecycleCoroutineScope) { + val target = this + target.scaleX + val scaleValue = 0.2f + val scaleXAnimator = { x: Float -> + ObjectAnimator + .ofFloat(target, View.SCALE_X, x) + .also { + it.interpolator = DecelerateInterpolator() + it.duration = SCALE_DURATION + } + } + val scaleYAnimator = { y: Float -> + ObjectAnimator + .ofFloat(target, View.SCALE_Y, y) + .also { + it.interpolator = DecelerateInterpolator() + it.duration = SCALE_DURATION + } + } + + lifecycleCoroutineScope.launch { + val scaleXUp = async { + scaleXAnimator(1f + scaleValue).run { + start() + awaitEnd() + } + } + val scaleYUp = async { + scaleYAnimator(1f + scaleValue).run { + start() + awaitEnd() + } + } + scaleXUp.await() + scaleYUp.await() + + val scaleXDown = async { + scaleXAnimator(1f - scaleValue).run { + start() + awaitEnd() + } + } + val scaleYDown = async { + scaleYAnimator(1f - scaleValue).run { + start() + awaitEnd() + } + } + scaleXDown.await() + scaleYDown.await() + } +} + +private const val UP_DOWN_DURATION: Long = 100 +private const val SCALE_DURATION: Long = 100 diff --git a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/item/SessionDetailTitleItem.kt b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/item/SessionDetailTitleItem.kt index c843e2907..1441ed827 100644 --- a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/item/SessionDetailTitleItem.kt +++ b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/item/SessionDetailTitleItem.kt @@ -1,12 +1,13 @@ package io.github.droidkaigi.confsched2020.session.ui.item -import androidx.appcompat.content.res.AppCompatResources import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.BackgroundColorSpan import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleCoroutineScope import com.google.android.material.chip.Chip import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject @@ -14,14 +15,21 @@ import com.xwray.groupie.databinding.BindableItem import io.github.droidkaigi.confsched2020.ext.getThemeColor import io.github.droidkaigi.confsched2020.model.Session import io.github.droidkaigi.confsched2020.model.SpeechSession +import io.github.droidkaigi.confsched2020.model.ThumbsUpCount import io.github.droidkaigi.confsched2020.model.defaultLang import io.github.droidkaigi.confsched2020.session.R import io.github.droidkaigi.confsched2020.session.databinding.ItemSessionDetailTitleBinding +import io.github.droidkaigi.confsched2020.session.ui.animation.dropOut +import io.github.droidkaigi.confsched2020.session.ui.animation.pop +import io.github.droidkaigi.confsched2020.session.ui.animation.popUp import java.util.regex.Pattern class SessionDetailTitleItem @AssistedInject constructor( @Assisted private val session: Session, - @Assisted private val searchQuery: String? + @Assisted private val searchQuery: String?, + @Assisted private val lifecycleCoroutineScope: LifecycleCoroutineScope, + @Assisted private val thumbsUpCount: ThumbsUpCount, + @Assisted private val thumbsUpListener: () -> Unit ) : BindableItem(session.id.hashCode().toLong()) { override fun getLayout() = R.layout.item_session_detail_title @@ -72,6 +80,31 @@ class SessionDetailTitleItem @AssistedInject constructor( // Test Code // binding.sessionMessage.text = "セッション部屋がRoom1からRoom3に変更になりました(サンプル)" // binding.sessionMessage.isVisible = true + + binding.thumbsUp.setOnClickListener { + thumbsUpListener.invoke() + } + + binding.thumbsUpCount = thumbsUpCount + if (!thumbsUpCount.incrementedUpdated) { + return + } + + when (thumbsUpCount.incremented) { + 0 -> binding.incrementedThumbsUpCount.dropOut(lifecycleCoroutineScope) + 1 -> { + binding.incrementedThumbsUpCount.setIncrementedText( + count = thumbsUpCount.incremented + ) + binding.incrementedThumbsUpCount.popUp(lifecycleCoroutineScope) + } + else -> { + binding.incrementedThumbsUpCount.setIncrementedText( + count = thumbsUpCount.incremented + ) + binding.incrementedThumbsUpCount.pop(lifecycleCoroutineScope) + } + } } } @@ -94,11 +127,21 @@ class SessionDetailTitleItem @AssistedInject constructor( } } + private fun TextView.setIncrementedText(count: Int) { + text = context.getString( + R.string.thumbs_up_increment_label, + count + ) + } + @AssistedInject.Factory interface Factory { fun create( session: Session, - searchQuery: String? = null + searchQuery: String? = null, + lifecycleCoroutineScope: LifecycleCoroutineScope, + thumbsUpCount: ThumbsUpCount, + thumbsUpListener: () -> Unit ): SessionDetailTitleItem } } diff --git a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModel.kt b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModel.kt index 523ceca5e..4a58a713b 100644 --- a/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModel.kt +++ b/feature/session/src/main/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModel.kt @@ -11,15 +11,24 @@ import io.github.droidkaigi.confsched2020.ext.combine import io.github.droidkaigi.confsched2020.ext.toAppError import io.github.droidkaigi.confsched2020.ext.toLoadingState import io.github.droidkaigi.confsched2020.model.AppError -import io.github.droidkaigi.confsched2020.model.TextExpandState import io.github.droidkaigi.confsched2020.model.LoadState import io.github.droidkaigi.confsched2020.model.LoadingState +import io.github.droidkaigi.confsched2020.model.ResultState import io.github.droidkaigi.confsched2020.model.Session import io.github.droidkaigi.confsched2020.model.SessionId +import io.github.droidkaigi.confsched2020.model.TextExpandState +import io.github.droidkaigi.confsched2020.model.ThumbsUpCount +import io.github.droidkaigi.confsched2020.model.firstErrorOrNull import io.github.droidkaigi.confsched2020.model.repository.SessionRepository +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber +import timber.log.debug class SessionDetailViewModel @AssistedInject constructor( @Assisted private val sessionId: SessionId, @@ -32,17 +41,28 @@ class SessionDetailViewModel @AssistedInject constructor( val error: AppError?, val session: Session?, val showEllipsis: Boolean, - val searchQuery: String? + val searchQuery: String?, + val thumbsUpCount: ThumbsUpCount ) { companion object { - val EMPTY = UiModel(false, null, null, true, null) + val EMPTY = UiModel( + isLoading = false, + error = null, + session = null, + showEllipsis = true, + searchQuery = null, + thumbsUpCount = ThumbsUpCount.ZERO + ) } } // LiveDatas private val sessionLoadStateLiveData: LiveData> = liveData { sessionRepository.sessionContents() - .map { it.sessions.first { session -> sessionId == session.id } } + .map { + it.sessions.firstOrNull { session -> sessionId == session.id } + ?: throw AppError.ApiException.SessionNotFoundException(null) + } .toLoadingState() .collect { loadState: LoadState -> emit(loadState) @@ -55,16 +75,34 @@ class SessionDetailViewModel @AssistedInject constructor( private val descriptionTextExpandStateLiveData: MutableLiveData = MutableLiveData(TextExpandState.COLLAPSED) + private val totalThumbsUpCountLoadStateLiveData: LiveData> = liveData { + sessionRepository.thumbsUpCounts(sessionId) + .toLoadingState() + .collect { loadState: LoadState -> + emit(loadState) + } + } + + private val incrementThumbsUpCountResultLiveData: MutableLiveData> = + MutableLiveData(ResultState.Success(0)) + + private val incrementThumbsUpCountEvent: BroadcastChannel> = + BroadcastChannel(Channel.BUFFERED) + // Produce UiModel val uiModel: LiveData = combine( initialValue = UiModel.EMPTY, liveData1 = sessionLoadStateLiveData, liveData2 = favoriteLoadingStateLiveData, - liveData3 = descriptionTextExpandStateLiveData + liveData3 = descriptionTextExpandStateLiveData, + liveData4 = totalThumbsUpCountLoadStateLiveData, + liveData5 = incrementThumbsUpCountResultLiveData ) { current: UiModel, sessionLoadState: LoadState, favoriteState: LoadingState, - descriptionTextExpandState: TextExpandState -> + descriptionTextExpandState: TextExpandState, + totalThumbsUpCountLoadState: LoadState, + incrementThumbsUpCountResult: ResultState -> val isLoading = sessionLoadState.isLoading || favoriteState.isLoading val sessions = when (sessionLoadState) { @@ -76,21 +114,47 @@ class SessionDetailViewModel @AssistedInject constructor( } } val showEllipsis = descriptionTextExpandState == TextExpandState.COLLAPSED + val totalThumbsUpCount = when (totalThumbsUpCountLoadState) { + is LoadState.Loaded -> { + totalThumbsUpCountLoadState.value + } + else -> { + current.thumbsUpCount.total + } + } + + val incrementThumbsUpCount = incrementThumbsUpCountResult.getOrDefault(0) + + val thumbsUpCount = ThumbsUpCount( + total = totalThumbsUpCount, + incremented = incrementThumbsUpCount, + incrementedUpdated = current.thumbsUpCount.incremented != incrementThumbsUpCount + ) + val appError = listOf( + sessionLoadState, + favoriteState, + totalThumbsUpCountLoadState, + incrementThumbsUpCountResult + ) + .firstErrorOrNull() + .toAppError() UiModel( isLoading = isLoading, - error = sessionLoadState - .getErrorIfExists() - .toAppError() - ?: favoriteState - .getErrorIfExists() - .toAppError(), + error = appError, session = sessions, showEllipsis = showEllipsis, - searchQuery = searchQuery + searchQuery = searchQuery, + thumbsUpCount = thumbsUpCount ) } + init { + viewModelScope.launch { + setupIncrementThumbsUpEvent() + } + } + fun favorite(session: Session) { viewModelScope.launch { favoriteLoadingStateLiveData.value = LoadingState.Loading @@ -107,6 +171,42 @@ class SessionDetailViewModel @AssistedInject constructor( descriptionTextExpandStateLiveData.value = TextExpandState.EXPANDED } + fun thumbsUp(session: Session) { + val liveDataValue = incrementThumbsUpCountResultLiveData.value + // ResultState type cannot be use as la left operand of '?.' + val currentIncremented = if (liveDataValue != null) { + liveDataValue.getOrDefault(0) + } else { + 0 + } + val incremented = minOf(currentIncremented + 1, MAX_APPLY_COUNT) + incrementThumbsUpCountResultLiveData.value = ResultState.Success(incremented) + + viewModelScope.launch { + incrementThumbsUpCountEvent.send(session.id to incremented) + } + } + + @Suppress("EXPERIMENTAL_API_USAGE") + private suspend fun setupIncrementThumbsUpEvent() { + incrementThumbsUpCountEvent.asFlow() + .debounce(INCREMENT_DEBOUNCE_MILLIS) + .collect { (sessionId, count) -> + try { + sessionRepository.incrementThumbsUpCount( + sessionId = sessionId, + count = count + ) + Timber.debug { "increment thumbs-up: $count posted" } + // Return initial value + incrementThumbsUpCountResultLiveData.value = ResultState.Success(0) + } catch (e: Exception) { + Timber.debug { "increment thumbs-up error: $e" } + incrementThumbsUpCountResultLiveData.value = ResultState.Error(e) + } + } + } + @AssistedInject.Factory interface Factory { fun create( @@ -114,4 +214,9 @@ class SessionDetailViewModel @AssistedInject constructor( searchQuery: String? = null ): SessionDetailViewModel } + + companion object { + private const val INCREMENT_DEBOUNCE_MILLIS = 1000L + private const val MAX_APPLY_COUNT = 50 + } } diff --git a/feature/session/src/main/res/drawable/ic_thumbs_up_black_24dp.xml b/feature/session/src/main/res/drawable/ic_thumbs_up_black_24dp.xml new file mode 100644 index 000000000..a924030a4 --- /dev/null +++ b/feature/session/src/main/res/drawable/ic_thumbs_up_black_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/session/src/main/res/drawable/shape_incremented_thumbs_up_count_background.xml b/feature/session/src/main/res/drawable/shape_incremented_thumbs_up_count_background.xml new file mode 100644 index 000000000..e1b9de341 --- /dev/null +++ b/feature/session/src/main/res/drawable/shape_incremented_thumbs_up_count_background.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/feature/session/src/main/res/layout/item_session_detail_title.xml b/feature/session/src/main/res/layout/item_session_detail_title.xml index c01f314aa..8db33449e 100644 --- a/feature/session/src/main/res/layout/item_session_detail_title.xml +++ b/feature/session/src/main/res/layout/item_session_detail_title.xml @@ -18,6 +18,11 @@ name="lang" type="io.github.droidkaigi.confsched2020.model.Lang" /> + + + + + + diff --git a/feature/session/src/main/res/values/strings.xml b/feature/session/src/main/res/values/strings.xml index ce3ddcd87..a062ef7e1 100644 --- a/feature/session/src/main/res/values/strings.xml +++ b/feature/session/src/main/res/values/strings.xml @@ -21,6 +21,8 @@ MOVIE SLIDE You choose your favorite sessions, then you can make a plan for you. + + + +%1$d Time Icon Favorite Icon https://droidkaigi.jp/2020/en/timetable/%s diff --git a/feature/session/src/main/res/values/styles.xml b/feature/session/src/main/res/values/styles.xml index fa9d37c67..dd18cc1c1 100644 --- a/feature/session/src/main/res/values/styles.xml +++ b/feature/session/src/main/res/values/styles.xml @@ -7,4 +7,13 @@ + + + + diff --git a/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/Dummies.kt b/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/Dummies.kt index 478ea302b..330b2dc58 100644 --- a/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/Dummies.kt +++ b/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/Dummies.kt @@ -16,6 +16,7 @@ import io.github.droidkaigi.confsched2020.model.SessionType import io.github.droidkaigi.confsched2020.model.Speaker import io.github.droidkaigi.confsched2020.model.SpeakerId import io.github.droidkaigi.confsched2020.model.SpeechSession +import io.github.droidkaigi.confsched2020.model.ThumbsUpCount object Dummies { val hall = Room(1, LocaledString("JA App bar", "EN App bar"), 1) @@ -80,4 +81,10 @@ object Dummies { category = listOf(category), levels = listOf(Level.BEGINNER, Level.INTERMEDIATE, Level.ADVANCED) ) + + val thumbsUpCount = ThumbsUpCount( + total = 10, + incremented = 0, + incrementedUpdated = false + ) } diff --git a/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModelTest.kt b/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModelTest.kt index 82a4961df..e8f9a955f 100644 --- a/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModelTest.kt +++ b/feature/session/src/test/java/io/github/droidkaigi/confsched2020/session/ui/viewmodel/SessionDetailViewModelTest.kt @@ -1,13 +1,15 @@ package io.github.droidkaigi.confsched2020.session.ui.viewmodel import com.jraska.livedata.test +import io.github.droidkaigi.confsched2020.model.AppError import io.github.droidkaigi.confsched2020.model.SessionContents import io.github.droidkaigi.confsched2020.model.SessionId import io.github.droidkaigi.confsched2020.model.repository.SessionRepository import io.github.droidkaigi.confsched2020.widget.component.MockkRule import io.github.droidkaigi.confsched2020.widget.component.ViewModelTestRule +import io.kotlintest.matchers.beOfType +import io.kotlintest.should import io.kotlintest.shouldBe -import io.kotlintest.shouldNotBe import io.mockk.coEvery import io.mockk.impl.annotations.MockK import io.mockk.verify @@ -35,18 +37,19 @@ class SessionDetailViewModelTest { .test() val valueHistory = testObserver.valueHistory() - valueHistory[0] shouldBe SessionDetailViewModel.UiModel.EMPTY.copy(isLoading = true) - valueHistory[1].apply { + // Observer does not receive UiModel.EMPTY, + // because sessionRepository.sessionContents does not always emit + // before UiModel is observed + valueHistory[0].apply { isLoading shouldBe false session shouldBe Dummies.speachSession1 - error shouldBe null showEllipsis shouldBe true searchQuery shouldBe null } } @Test - fun load_NotFoundSpeaker() { + fun load_NotFoundSession() { coEvery { sessionRepository.sessionContents() } returns flowOf(SessionContents.EMPTY) val sessionDetailViewModel = SessionDetailViewModel( sessionId = SessionId("1"), @@ -54,16 +57,15 @@ class SessionDetailViewModelTest { searchQuery = null ) - val testObserver = sessionDetailViewModel + val uiModelTestObserver = sessionDetailViewModel .uiModel .test() - val valueHistory = testObserver.valueHistory() - valueHistory[0] shouldBe SessionDetailViewModel.UiModel.EMPTY.copy(isLoading = true) - valueHistory[1].apply { + val uiModelValueHistory = uiModelTestObserver.valueHistory() + uiModelValueHistory[0].apply { isLoading shouldBe false + error should beOfType() session shouldBe null - error shouldNotBe null showEllipsis shouldBe true searchQuery shouldBe null } @@ -86,25 +88,24 @@ class SessionDetailViewModelTest { verify { sessionDetailViewModel.favorite(Dummies.speachSession1) } val valueHistory = testObserver.valueHistory() - valueHistory[0] shouldBe SessionDetailViewModel.UiModel.EMPTY.copy(isLoading = true) - valueHistory[1].apply { + valueHistory[0].apply { isLoading shouldBe false - session shouldBe Dummies.speachSession1 error shouldBe null + session shouldBe Dummies.speachSession1 showEllipsis shouldBe true searchQuery shouldBe null } - valueHistory[2].apply { + valueHistory[1].apply { isLoading shouldBe true - session shouldBe Dummies.speachSession1 error shouldBe null + session shouldBe Dummies.speachSession1 showEllipsis shouldBe true searchQuery shouldBe null } - valueHistory[3].apply { + valueHistory[2].apply { isLoading shouldBe false - session shouldBe Dummies.speachSession1 error shouldBe null + session shouldBe Dummies.speachSession1 showEllipsis shouldBe true searchQuery shouldBe null } @@ -126,8 +127,8 @@ class SessionDetailViewModelTest { valueHistory[0] shouldBe SessionDetailViewModel.UiModel.EMPTY.copy(isLoading = true) valueHistory[1].apply { isLoading shouldBe true - session shouldBe null error shouldBe null + session shouldBe null showEllipsis shouldBe false searchQuery shouldBe null } @@ -147,17 +148,38 @@ class SessionDetailViewModelTest { .test() val valueHistory = testObserver.valueHistory() - valueHistory[0] shouldBe - SessionDetailViewModel.UiModel.EMPTY.copy( - isLoading = true, - searchQuery = "query" - ) - valueHistory[1].apply { + valueHistory[0].apply { isLoading shouldBe false - session shouldBe Dummies.speachSession1 error shouldBe null + session shouldBe Dummies.speachSession1 showEllipsis shouldBe true searchQuery shouldBe "query" } } + + @Test + fun thumbsUpCount() { + coEvery { + sessionRepository.thumbsUpCounts(Dummies.speachSession1.id) + } returns flowOf(Dummies.thumbsUpCount.total) + val sessionDetailViewModel = SessionDetailViewModel( + sessionId = Dummies.speachSession1.id, + sessionRepository = sessionRepository, + searchQuery = null + ) + val testObserver = sessionDetailViewModel + .uiModel + .test() + + val valueHistory = testObserver.valueHistory() + valueHistory[0] shouldBe SessionDetailViewModel.UiModel.EMPTY.copy(isLoading = true) + valueHistory[1].apply { + isLoading shouldBe true + error shouldBe null + session shouldBe null + showEllipsis shouldBe true + searchQuery shouldBe null + thumbsUpCount.total shouldBe Dummies.thumbsUpCount.total + } + } } diff --git a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/AppError.kt b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/AppError.kt index 40a5e5d6c..cf18fd1a4 100644 --- a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/AppError.kt +++ b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/AppError.kt @@ -10,6 +10,7 @@ sealed class AppError : RuntimeException { sealed class ApiException(cause: Throwable?) : AppError(cause) { class NetworkException(cause: Throwable?) : ApiException(cause) class ServerException(cause: Throwable?) : ApiException(cause) + class SessionNotFoundException(cause: Throwable?) : AppError(cause) class UnknownException(cause: Throwable?) : AppError(cause) } diff --git a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/LoadState.kt b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/LoadState.kt index 6b4625414..e478764ec 100644 --- a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/LoadState.kt +++ b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/LoadState.kt @@ -1,20 +1,39 @@ package io.github.droidkaigi.confsched2020.model -sealed class LoadState { +interface ErrorGettable { + fun getErrorIfExists(): Throwable? +} + +sealed class LoadState : ErrorGettable { object Loading : LoadState() class Loaded(val value: T) : LoadState() class Error(val e: Throwable) : LoadState() val isLoading get() = this is Loading - fun getErrorIfExists() = if (this is Error) e else null + override fun getErrorIfExists() = if (this is Error) e else null fun getValueOrNull(): T? = if (this is Loaded) value else null } -sealed class LoadingState { +sealed class LoadingState : ErrorGettable { object Loading : LoadingState() object Loaded : LoadingState() class Error(val e: Throwable) : LoadingState() val isLoading get() = this is Loading - fun getErrorIfExists() = if (this is Error) e else null + override fun getErrorIfExists() = if (this is Error) e else null +} + +sealed class ResultState : ErrorGettable { + class Success(val value: T) : ResultState() + class Error(val e: Throwable) : ResultState() + + override fun getErrorIfExists() = if (this is Error) e else null + fun getOrDefault(default: T): T { + if (this is Success) return value + return default + } +} + +fun List.firstErrorOrNull(): Throwable? { + return mapNotNull { it.getErrorIfExists() }.firstOrNull() } diff --git a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/ThumbsUpCount.kt b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/ThumbsUpCount.kt new file mode 100644 index 000000000..ca94ea8c9 --- /dev/null +++ b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/ThumbsUpCount.kt @@ -0,0 +1,15 @@ +package io.github.droidkaigi.confsched2020.model + +data class ThumbsUpCount( + val total: Int, + val incremented: Int, + val incrementedUpdated: Boolean +) { + companion object { + val ZERO = ThumbsUpCount( + total = 0, + incremented = 0, + incrementedUpdated = false + ) + } +} diff --git a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/repository/SessionRepository.kt b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/repository/SessionRepository.kt index aa84395ec..dd104f0b8 100644 --- a/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/repository/SessionRepository.kt +++ b/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2020/model/repository/SessionRepository.kt @@ -17,4 +17,6 @@ interface SessionRepository { session: SpeechSession, sessionFeedback: SessionFeedback ) + fun thumbsUpCounts(sessionId: SessionId): Flow + suspend fun incrementThumbsUpCount(sessionId: SessionId, count: Int) }