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
+ }
+}
+