Skip to content

Commit

Permalink
Merge pull request #626 from mkeeda/thumb-up
Browse files Browse the repository at this point in the history
Thumbs-up function 👍
  • Loading branch information
takahirom authored Feb 16, 2020
2 parents be1df47 + 808f2b2 commit 413143f
Show file tree
Hide file tree
Showing 26 changed files with 819 additions and 57 deletions.
Original file line number Diff line number Diff line change
@@ -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<Unit> { 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()
}
}
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : Any> LiveData<T>.requireValue() = requireNotNull(value)

Expand Down Expand Up @@ -108,6 +110,43 @@ inline fun <T : Any, LIVE1 : Any, LIVE2 : Any, LIVE3 : Any, LIVE4 : Any> combine
}.distinctUntilChanged()
}

inline fun <T : Any, LIVE1 : Any, LIVE2 : Any, LIVE3 : Any, LIVE4 : Any, LIVE5 : Any> combine(
initialValue: T,
liveData1: LiveData<LIVE1>,
liveData2: LiveData<LIVE2>,
liveData3: LiveData<LIVE3>,
liveData4: LiveData<LIVE4>,
liveData5: LiveData<LIVE5>,
crossinline block: (T, LIVE1, LIVE2, LIVE3, LIVE4, LIVE5) -> T
): LiveData<T> {
return MediatorLiveData<T>().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 <T> LiveData<T>.setOnEach(mutableLiveData: MutableLiveData<T>): LiveData<T> {
return map {
mutableLiveData.value = it
Expand All @@ -124,3 +163,25 @@ fun <T : Any> LiveData<T?>.toNonNullSingleEvent(): LiveData<T> {
}
return result
}

fun <T> merge(vararg liveDatas: LiveData<T>): LiveData<T> {
return MediatorLiveData<T>().apply {
liveDatas.forEach { liveData ->
addSource(liveData) { value ->
this.value = value
}
}
}
}

fun <T : ErrorGettable> LiveData<T>.toAppError(): LiveData<AppError?> {
return map {
it.getErrorIfExists().toAppError()
}
}

fun <T, R : Result<T>> LiveData<R>.fromResultToAppError(): LiveData<AppError?> {
return map {
it.exceptionOrNull()?.toAppError()
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit> { 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<item name="textAppearanceBody1">@style/TextAppearance.DroidKaigi.Body1</item>
<item name="textAppearanceBody2">@style/TextAppearance.DroidKaigi.Body2</item>
<item name="textAppearanceCaption">@style/TextAppearance.DroidKaigi.Caption</item>
<item name="textAppearanceButton">@style/TextAppearance.DroidKaigi.Button</item>

<!-- Widget styles -->
<item name="filterChipStyle">@style/Widget.DroidKaigi.FilterChip</item>
Expand Down
6 changes: 6 additions & 0 deletions corecomponent/androidcomponent/src/main/res/values/type.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,10 @@
<item name="android:textColor">?colorOnBackground</item>
</style>

<!-- Notosans Medium 14sp -->
<style name="TextAppearance.DroidKaigi.Button" parent="TextAppearance.MaterialComponents.Button">
<item name="android:fontFamily">@font/notosans_medium</item>
<item name="fontFamily">@font/notosans_medium</item>
<item name="android:textColor">?colorOnBackground</item>
</style>
</resources>
8 changes: 8 additions & 0 deletions data/firestore/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.Flow
interface Firestore {
fun getFavoriteSessionIds(): Flow<List<String>>
suspend fun toggleFavorite(sessionId: SessionId)
fun getThumbsUpCount(sessionId: SessionId): Flow<Int>
suspend fun incrementThumbsUpCount(sessionId: SessionId, count: Int)
}
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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" }
Expand All @@ -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<Int> {
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(
Expand All @@ -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<Task<Void>>()
(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 {
Expand Down
Loading

0 comments on commit 413143f

Please sign in to comment.