diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 70260d7f46..07a019373f 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -199,6 +199,12 @@ dependencies { /* Kotlin */ implementation Libs.KOTLIN_STD_LIB + /* Mobius */ + implementation Libs.MOBIUS_CORE + testImplementation Libs.MOBIUS_TEST + implementation Libs.MOBIUS_ANDROID + implementation Libs.MOBIUS_EXTRAS + /* Crashlytics */ implementation Libs.FIREBASE_CORE implementation(Libs.CRASHLYTICS) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ConsumerQueueWrapper.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ConsumerQueueWrapper.kt new file mode 100644 index 0000000000..475362d8ac --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ConsumerQueueWrapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common + +import com.spotify.mobius.functions.Consumer +import java.util.* + +class ConsumerQueueWrapper : Consumer { + + private val queuedEvents = LinkedList() + + private var wrappedConsumer: Consumer? = null + + fun attach(consumer: Consumer) { + wrappedConsumer = consumer + while (queuedEvents.isNotEmpty()) consumer.accept(queuedEvents.poll()) + } + + fun detach() { + wrappedConsumer = null + } + + override fun accept(event: T) { + if (wrappedConsumer == null) { + queuedEvents.add(event) + } else { + wrappedConsumer?.accept(event) + } + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/CoroutineConnection.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/CoroutineConnection.kt new file mode 100644 index 0000000000..d39a986fd7 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/CoroutineConnection.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common + +import com.spotify.mobius.Connection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlin.coroutines.CoroutineContext + +abstract class CoroutineConnection : Connection, CoroutineScope { + + private val job = SupervisorJob() + + override val coroutineContext: CoroutineContext + get() = job + Dispatchers.Main + + fun cancelCoroutine() { + coroutineContext.cancelChildren() + } +} diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/GlobalEvents.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/GlobalEvents.kt new file mode 100644 index 0000000000..9b4bb49cb7 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/GlobalEvents.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common + +import com.spotify.mobius.EventSource +import com.spotify.mobius.disposables.Disposable +import com.spotify.mobius.functions.Consumer +import java.util.* + + +sealed class GlobalEvent { + data class TestEvent(val testValue: Int) : GlobalEvent() +} + +object GlobalEvents { + + val subscribers: MutableSet> = Collections.newSetFromMap(WeakHashMap, Boolean>()) + + fun post(event: GlobalEvent) { + publish(event) + } + + private fun publish(event: GlobalEvent) { + synchronized(subscribers) { + subscribers.forEach { it.postEvent(event) } + } + } + + fun subscribe(eventSource: GlobalEventSource<*>) { + synchronized(subscribers) { subscribers += eventSource } + } + + fun unsubscribe(eventSource: GlobalEventSource<*>) { + synchronized(subscribers) { subscribers -= eventSource } + } +} + +interface GlobalEventMapper { + fun mapGlobalEvent(event: GlobalEvent): E? = null +} + +class GlobalEventSource(private val mapper: GlobalEventMapper) : EventSource { + + private val queuedEvents = LinkedList() + + private var consumer: Consumer? = null + + override fun subscribe(eventConsumer: Consumer): Disposable { + consumer = eventConsumer + GlobalEvents.subscribe(this) + while (queuedEvents.isNotEmpty()) { + consumer?.accept(queuedEvents.poll()) + } + return Disposable { consumer = null } + } + + fun dispose() { + consumer = null + GlobalEvents.unsubscribe(this) + } + + fun postEvent(event: GlobalEvent) { + mapper.mapGlobalEvent(event)?.let { localEvent -> + if (consumer == null) { + queuedEvents.add(localEvent) + } else { + consumer?.accept(localEvent) + } + } + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/LateInit.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/LateInit.kt new file mode 100644 index 0000000000..6f02cb9cfd --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/LateInit.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common + +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class LateInit(val onInit: (T) -> T) : ReadWriteProperty { + private var value: T? = null + + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + return value ?: throw UninitializedPropertyAccessException() + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + this.value = if (this.value == null) onInit(value) else value + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/MobiusKotlinUtils.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/MobiusKotlinUtils.kt new file mode 100644 index 0000000000..be9715e524 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/MobiusKotlinUtils.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common + +import android.content.Context +import com.spotify.mobius.Connectable +import com.spotify.mobius.Connection +import kotlin.reflect.KFunction2 + +fun Connectable.contraMap( + mapper: KFunction2, + context: Context +): Connectable { + return Connectable { output -> + val delegateConnection = connect(output) + object : Connection { + var lastValue: I? = null + + override fun accept(value: J) { + val mappedValue: I = mapper(value, context) + // Only push value if it has changed (prevents duplicate renders) + if (mappedValue != lastValue) { + lastValue = mappedValue + delegateConnection.accept(mappedValue) + } + } + + override fun dispose() = delegateConnection.dispose() + } + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/modules/ModulesListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/modules/ModulesListModels.kt new file mode 100644 index 0000000000..f7b8a3316e --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/modules/ModulesListModels.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common.modules + +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.utils.DataResult + +sealed class ModulesListEvent { + object PullToRefresh : ModulesListEvent() + data class ModuleClicked(val moduleItem: ModuleItem) : ModulesListEvent() + data class DataLoaded(val modulesResult: DataResult) : ModulesListEvent() +} + +sealed class ModulesListEffect { + data class ShowModuleDetailView(val moduleItem: ModuleItem) : ModulesListEffect() + data class LoadData(val canvasContext: CanvasContext, val forceNetwork: Boolean) : ModulesListEffect() +} + +data class ModulesListModel( + val course: CanvasContext, + val isLoading: Boolean = false, + val modulesResult: DataResult? = null +) \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt new file mode 100644 index 0000000000..03791e9085 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.teacher.mobius.common.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.instructure.interactions.FragmentInteractions +import com.instructure.interactions.Navigation +import com.instructure.teacher.mobius.common.* +import com.spotify.mobius.* +import com.spotify.mobius.android.MobiusAndroid +import com.spotify.mobius.android.runners.MainThreadWorkRunner +import com.spotify.mobius.functions.Consumer +import kotlinx.android.extensions.LayoutContainer + + +abstract class MobiusFragment, VIEW_STATE> : Fragment() { + var overrideInitModel: MODEL? = null + + var loopMod: ((MobiusLoop.Builder) -> MobiusLoop.Builder)? = null + + var loop: MobiusLoop.Builder by LateInit { + loopMod?.invoke(it) ?: it + } + + lateinit var controller: MobiusLoop.Controller + + protected lateinit var view: VIEW + + private lateinit var effectHandler: EffectHandler + + private lateinit var globalEventSource: GlobalEventSource + + private lateinit var update: UpdateInit + + abstract fun makeEffectHandler(): EffectHandler + + abstract fun makeUpdate(): UpdateInit + + abstract fun makeView(inflater: LayoutInflater, parent: ViewGroup): VIEW + + abstract fun makePresenter(): Presenter + + abstract fun makeInitModel(): MODEL + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + retainInstance = true + + update = makeUpdate().apply { initialized = overrideInitModel != null } + globalEventSource = GlobalEventSource(update) + effectHandler = makeEffectHandler() + loop = Mobius.loop(update, effectHandler) + .effectRunner { MainThreadWorkRunner.create() } + .eventSource(globalEventSource) + .init(update::init) + controller = MobiusAndroid.controller(loop, overrideInitModel ?: makeInitModel()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + view = makeView(inflater, container!!) + effectHandler.view = view + val presenter = makePresenter() + controller.connect(view.contraMap(presenter::present, requireContext())) + if (update.initialized) { + view.connection?.accept(presenter.present(controller.model, requireContext())) + } + return view.containerView + } + + override fun onStart() { + super.onStart() + controller.start() + effectHandler.view = view + } + + override fun onStop() { + super.onStop() + controller.stop() + } + + override fun onDestroyView() { + controller.disconnect() + super.onDestroyView() + } + + override fun onDestroy() { + globalEventSource.dispose() + effectHandler.cancel() + super.onDestroy() + } +} + +abstract class UpdateInit : Update, Init, + GlobalEventMapper { + + var initialized = false + + override fun init(model: MODEL): First { + return if (initialized) { + First.first(model) + } else { + initialized = true + performInit(model) + } + } + + abstract fun performInit(model: MODEL): First +} + +abstract class MobiusView(layoutId: Int, inflater: LayoutInflater, val parent: ViewGroup) : + Connectable, LayoutContainer { + val rootView: View? = inflater.inflate(layoutId, parent, false) + + override val containerView: View? + get() = rootView + + var connection: Connection? = null + + protected val context: Context + get() = parent.context + + abstract fun onConnect(output: Consumer) + + abstract fun render(state: VIEW_STATE) + + abstract fun onDispose() + + override fun connect(output: Consumer): Connection { + onConnect(output) + connection = object : Connection { + override fun accept(value: VIEW_STATE) { + render(value) + } + + override fun dispose() { + onDispose() + connection = null + } + } + return connection!! + } +} + +interface Presenter { + fun present(model: MODEL, context: Context): VIEW_STATE +} + +abstract class EffectHandler : CoroutineConnection(), Connectable { + var view: VIEW? = null + + protected var consumer = ConsumerQueueWrapper() + + override fun connect(output: Consumer): Connection { + consumer.attach(output) + return this + } + + override fun dispose() { + view = null + consumer.detach() + } + + open fun cancel() { + cancelCoroutine() + dispose() + } + + fun logEvent(eventName: String) { + // TODO + } +} +