diff --git a/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt b/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt
new file mode 100644
index 0000000000..ba73475d60
--- /dev/null
+++ b/app/src/main/java/eu/kanade/core/prefs/PreferenceMutableState.kt
@@ -0,0 +1,38 @@
+package eu.kanade.core.prefs
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import com.fredporciuncula.flow.preferences.Preference
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class PreferenceMutableState<T>(
+    private val preference: Preference<T>,
+    scope: CoroutineScope,
+) : MutableState<T> {
+
+    private val state = mutableStateOf(preference.get())
+
+    init {
+        preference.asFlow()
+            .distinctUntilChanged()
+            .onEach { state.value = it }
+            .launchIn(scope)
+    }
+
+    override var value: T
+        get() = state.value
+        set(value) {
+            preference.set(value)
+        }
+
+    override fun component1(): T {
+        return state.value
+    }
+
+    override fun component2(): (T) -> Unit {
+        return { preference.set(it) }
+    }
+}
diff --git a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt
index 74343b4e1a..d0be06dede 100644
--- a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt
+++ b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt
@@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.painter.Painter
 import androidx.compose.ui.unit.dp
+import eu.kanade.core.prefs.PreferenceMutableState
 import eu.kanade.presentation.util.horizontalPadding
 
 @Composable
@@ -29,7 +31,7 @@ fun Divider() {
 @Composable
 fun PreferenceRow(
     title: String,
-    icon: ImageVector? = null,
+    painter: Painter? = null,
     onClick: () -> Unit = {},
     onLongClick: () -> Unit = {},
     subtitle: String? = null,
@@ -50,18 +52,18 @@ fun PreferenceRow(
             .heightIn(min = height)
             .combinedClickable(
                 onLongClick = onLongClick,
-                onClick = onClick
+                onClick = onClick,
             ),
         verticalAlignment = Alignment.CenterVertically
     ) {
-        if (icon != null) {
+        if (painter != null) {
             Icon(
-                imageVector = icon,
+                painter = painter,
                 modifier = Modifier
                     .padding(horizontal = horizontalPadding)
                     .size(24.dp),
                 tint = MaterialTheme.colorScheme.primary,
-                contentDescription = null
+                contentDescription = null,
             )
         }
         Column(
@@ -88,3 +90,23 @@ fun PreferenceRow(
         }
     }
 }
+
+@Composable
+fun SwitchPreference(
+    preference: PreferenceMutableState<Boolean>,
+    title: String,
+    subtitle: String? = null,
+    painter: Painter? = null,
+) {
+    PreferenceRow(
+        title = title,
+        subtitle = subtitle,
+        painter = painter,
+        action = {
+            Switch(checked = preference.value, onCheckedChange = null)
+            // TODO: remove this once switch checked state is fixed: https://issuetracker.google.com/issues/228336571
+            Text(preference.value.toString())
+        },
+        onClick = { preference.value = !preference.value },
+    )
+}
diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt
new file mode 100644
index 0000000000..945af6f141
--- /dev/null
+++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt
@@ -0,0 +1,131 @@
+package eu.kanade.presentation.more
+
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CloudOff
+import androidx.compose.material.icons.outlined.GetApp
+import androidx.compose.material.icons.outlined.HelpOutline
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Label
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.SettingsBackupRestore
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.PreferenceRow
+import eu.kanade.presentation.components.SwitchPreference
+import eu.kanade.presentation.util.quantityStringResource
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.more.DownloadQueueState
+import eu.kanade.tachiyomi.ui.more.MoreController
+import eu.kanade.tachiyomi.ui.more.MorePresenter
+
+@Composable
+fun MoreScreen(
+    nestedScrollInterop: NestedScrollConnection,
+    presenter: MorePresenter,
+    onClickDownloadQueue: () -> Unit,
+    onClickCategories: () -> Unit,
+    onClickBackupAndRestore: () -> Unit,
+    onClickSettings: () -> Unit,
+    onClickAbout: () -> Unit,
+) {
+    val uriHandler = LocalUriHandler.current
+    val downloadQueueState by presenter.downloadQueueState.collectAsState()
+
+    LazyColumn(
+        modifier = Modifier.nestedScroll(nestedScrollInterop),
+    ) {
+        item {
+            LogoHeader()
+        }
+
+        item {
+            SwitchPreference(
+                preference = presenter.downloadedOnly,
+                title = stringResource(R.string.label_downloaded_only),
+                subtitle = stringResource(R.string.downloaded_only_summary),
+                painter = rememberVectorPainter(Icons.Outlined.CloudOff),
+            )
+        }
+        item {
+            SwitchPreference(
+                preference = presenter.incognitoMode,
+                title = stringResource(R.string.pref_incognito_mode),
+                subtitle = stringResource(R.string.pref_incognito_mode_summary),
+                painter = painterResource(R.drawable.ic_glasses_24dp),
+            )
+        }
+
+        item { Divider() }
+
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.label_download_queue),
+                subtitle = when (downloadQueueState) {
+                    DownloadQueueState.Stopped -> null
+                    is DownloadQueueState.Paused -> {
+                        val pending = (downloadQueueState as DownloadQueueState.Paused).pending
+                        if (pending == 0) {
+                            stringResource(R.string.paused)
+                        } else {
+                            "${stringResource(R.string.paused)} • ${quantityStringResource(R.plurals.download_queue_summary, pending, pending)}"
+                        }
+                    }
+                    is DownloadQueueState.Downloading -> {
+                        val pending = (downloadQueueState as DownloadQueueState.Downloading).pending
+                        quantityStringResource(R.plurals.download_queue_summary, pending, pending)
+                    }
+                },
+                painter = rememberVectorPainter(Icons.Outlined.GetApp),
+                onClick = { onClickDownloadQueue() },
+            )
+        }
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.categories),
+                painter = rememberVectorPainter(Icons.Outlined.Label),
+                onClick = { onClickCategories() },
+            )
+        }
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.label_backup),
+                painter = rememberVectorPainter(Icons.Outlined.SettingsBackupRestore),
+                onClick = { onClickBackupAndRestore() },
+            )
+        }
+
+        item { Divider() }
+
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.label_settings),
+                painter = rememberVectorPainter(Icons.Outlined.Settings),
+                onClick = { onClickSettings() },
+            )
+        }
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.pref_category_about),
+                painter = rememberVectorPainter(Icons.Outlined.Info),
+                onClick = { onClickAbout() },
+            )
+        }
+        item {
+            PreferenceRow(
+                title = stringResource(R.string.label_help),
+                painter = rememberVectorPainter(Icons.Outlined.HelpOutline),
+                onClick = { uriHandler.openUri(MoreController.URL_HELP) },
+            )
+        }
+    }
+}
diff --git a/app/src/main/java/eu/kanade/presentation/util/Resources.kt b/app/src/main/java/eu/kanade/presentation/util/Resources.kt
new file mode 100644
index 0000000000..51bdbe5a77
--- /dev/null
+++ b/app/src/main/java/eu/kanade/presentation/util/Resources.kt
@@ -0,0 +1,32 @@
+package eu.kanade.presentation.util
+
+import androidx.annotation.PluralsRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * Load a quantity string resource.
+ *
+ * @param id the resource identifier
+ * @param quantity The number used to get the string for the current language's plural rules.
+ * @return the string data associated with the resource
+ */
+@Composable
+fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String {
+    val context = LocalContext.current
+    return context.resources.getQuantityString(id, quantity, quantity)
+}
+
+/**
+ * Load a quantity string resource with formatting.
+ *
+ * @param id the resource identifier
+ * @param quantity The number used to get the string for the current language's plural rules.
+ * @param formatArgs the format arguments
+ * @return the string data associated with the resource
+ */
+@Composable
+fun quantityStringResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String {
+    val context = LocalContext.current
+    return context.resources.getQuantityString(id, quantity, *formatArgs)
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt
index c69e56024c..3a68f6479b 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt
@@ -1,6 +1,8 @@
 package eu.kanade.tachiyomi.ui.base.presenter
 
 import android.os.Bundle
+import com.fredporciuncula.flow.preferences.Preference
+import eu.kanade.core.prefs.PreferenceMutableState
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.cancel
@@ -10,7 +12,7 @@ import rx.Observable
 
 open class BasePresenter<V> : RxPresenter<V>() {
 
-    lateinit var presenterScope: CoroutineScope
+    var presenterScope: CoroutineScope = MainScope()
 
     /**
      * Query from the view where applicable
@@ -20,7 +22,6 @@ open class BasePresenter<V> : RxPresenter<V>() {
     override fun onCreate(savedState: Bundle?) {
         try {
             super.onCreate(savedState)
-            presenterScope = MainScope()
         } catch (e: NullPointerException) {
             // Swallow this error. This should be fixed in the library but since it's not critical
             // (only used by restartables) it should be enough. It saves me a fork.
@@ -38,6 +39,8 @@ open class BasePresenter<V> : RxPresenter<V>() {
         return super.getView()
     }
 
+    fun <T> Preference<T>.asState() = PreferenceMutableState(this, presenterScope)
+
     /**
      * Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle
      * subscription list.
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt
index 9cc77dcd42..1b09c6be22 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt
@@ -1,187 +1,38 @@
 package eu.kanade.tachiyomi.ui.more
 
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.preference.Preference
-import androidx.preference.PreferenceScreen
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import eu.kanade.presentation.more.MoreScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.download.DownloadManager
-import eu.kanade.tachiyomi.data.download.DownloadService
+import eu.kanade.tachiyomi.ui.base.controller.ComposeController
 import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.category.CategoryController
 import eu.kanade.tachiyomi.ui.download.DownloadController
 import eu.kanade.tachiyomi.ui.setting.SettingsBackupController
-import eu.kanade.tachiyomi.ui.setting.SettingsController
 import eu.kanade.tachiyomi.ui.setting.SettingsMainController
-import eu.kanade.tachiyomi.util.preference.add
-import eu.kanade.tachiyomi.util.preference.bindTo
-import eu.kanade.tachiyomi.util.preference.iconRes
-import eu.kanade.tachiyomi.util.preference.iconTint
-import eu.kanade.tachiyomi.util.preference.onClick
-import eu.kanade.tachiyomi.util.preference.preference
-import eu.kanade.tachiyomi.util.preference.preferenceCategory
-import eu.kanade.tachiyomi.util.preference.summaryRes
-import eu.kanade.tachiyomi.util.preference.switchPreference
-import eu.kanade.tachiyomi.util.preference.titleRes
-import eu.kanade.tachiyomi.util.system.getResourceColor
-import eu.kanade.tachiyomi.util.system.openInBrowser
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import rx.subscriptions.CompositeSubscription
-import uy.kohesive.injekt.injectLazy
 
 class MoreController :
-    SettingsController(),
+    ComposeController<MorePresenter>(),
     RootController,
     NoAppBarElevationController {
 
-    private val downloadManager: DownloadManager by injectLazy()
-    private var isDownloading: Boolean = false
-    private var downloadQueueSize: Int = 0
+    override fun getTitle() = resources?.getString(R.string.label_more)
 
-    private var untilDestroySubscriptions = CompositeSubscription()
-        private set
+    override fun createPresenter() = MorePresenter()
 
-    override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
-        titleRes = R.string.label_more
-
-        val tintColor = context.getResourceColor(R.attr.colorAccent)
-
-        add(MoreHeaderPreference(context))
-
-        switchPreference {
-            bindTo(preferences.downloadedOnly())
-            titleRes = R.string.label_downloaded_only
-            summaryRes = R.string.downloaded_only_summary
-            iconRes = R.drawable.ic_cloud_off_24dp
-            iconTint = tintColor
-        }
-
-        switchPreference {
-            bindTo(preferences.incognitoMode())
-            summaryRes = R.string.pref_incognito_mode_summary
-            titleRes = R.string.pref_incognito_mode
-            iconRes = R.drawable.ic_glasses_24dp
-            iconTint = tintColor
-
-            preferences.incognitoMode().asFlow()
-                .onEach { isChecked = it }
-                .launchIn(viewScope)
-        }
-
-        preferenceCategory {
-            preference {
-                titleRes = R.string.label_download_queue
-
-                if (downloadManager.queue.isNotEmpty()) {
-                    initDownloadQueueSummary(this)
-                }
-
-                iconRes = R.drawable.ic_get_app_24dp
-                iconTint = tintColor
-                onClick {
-                    router.pushController(DownloadController())
-                }
-            }
-            preference {
-                titleRes = R.string.categories
-                iconRes = R.drawable.ic_label_24dp
-                iconTint = tintColor
-                onClick {
-                    router.pushController(CategoryController())
-                }
-            }
-            preference {
-                titleRes = R.string.label_backup
-                iconRes = R.drawable.ic_settings_backup_restore_24dp
-                iconTint = tintColor
-                onClick {
-                    router.pushController(SettingsBackupController())
-                }
-            }
-        }
-
-        preferenceCategory {
-            preference {
-                titleRes = R.string.label_settings
-                iconRes = R.drawable.ic_settings_24dp
-                iconTint = tintColor
-                onClick {
-                    router.pushController(SettingsMainController())
-                }
-            }
-            preference {
-                iconRes = R.drawable.ic_info_24dp
-                iconTint = tintColor
-                titleRes = R.string.pref_category_about
-                onClick {
-                    router.pushController(AboutController())
-                }
-            }
-            preference {
-                titleRes = R.string.label_help
-                iconRes = R.drawable.ic_help_24dp
-                iconTint = tintColor
-                onClick {
-                    activity?.openInBrowser(URL_HELP)
-                }
-            }
-        }
-    }
-
-    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
-        if (untilDestroySubscriptions.isUnsubscribed) {
-            untilDestroySubscriptions = CompositeSubscription()
-        }
-
-        return super.onCreateView(inflater, container, savedInstanceState)
-    }
-
-    override fun onDestroyView(view: View) {
-        super.onDestroyView(view)
-        untilDestroySubscriptions.unsubscribe()
-    }
-
-    private fun initDownloadQueueSummary(preference: Preference) {
-        // Handle running/paused status change
-        DownloadService.runningRelay
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy { isRunning ->
-                isDownloading = isRunning
-                updateDownloadQueueSummary(preference)
-            }
-
-        // Handle queue progress updating
-        downloadManager.queue.getUpdatedObservable()
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeUntilDestroy {
-                downloadQueueSize = it.size
-                updateDownloadQueueSummary(preference)
-            }
-    }
-
-    private fun updateDownloadQueueSummary(preference: Preference) {
-        var pendingDownloadExists = downloadQueueSize != 0
-        var pauseMessage = resources?.getString(R.string.paused)
-        var numberOfPendingDownloads = resources?.getQuantityString(R.plurals.download_queue_summary, downloadQueueSize, downloadQueueSize)
-
-        preference.summary = when {
-            !pendingDownloadExists -> null
-            !isDownloading && !pendingDownloadExists -> pauseMessage
-            !isDownloading && pendingDownloadExists -> "$pauseMessage • $numberOfPendingDownloads"
-            else -> numberOfPendingDownloads
-        }
-    }
-
-    private fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription {
-        return subscribe(onNext).also { untilDestroySubscriptions.add(it) }
+    @Composable
+    override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
+        MoreScreen(
+            nestedScrollInterop = nestedScrollInterop,
+            presenter = presenter,
+            onClickDownloadQueue = { router.pushController(DownloadController()) },
+            onClickCategories = { router.pushController(CategoryController()) },
+            onClickBackupAndRestore = { router.pushController(SettingsBackupController()) },
+            onClickSettings = { router.pushController(SettingsMainController()) },
+            onClickAbout = { router.pushController(AboutController()) },
+        )
     }
 
     companion object {
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt
new file mode 100644
index 0000000000..9ceef98567
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MorePresenter.kt
@@ -0,0 +1,89 @@
+package eu.kanade.tachiyomi.ui.more
+
+import android.os.Bundle
+import eu.kanade.tachiyomi.data.download.DownloadManager
+import eu.kanade.tachiyomi.data.download.DownloadService
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.lang.launchIO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import rx.Observable
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+import rx.subscriptions.CompositeSubscription
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MorePresenter(
+    private val downloadManager: DownloadManager = Injekt.get(),
+    preferencesHelper: PreferencesHelper = Injekt.get(),
+) : BasePresenter<MoreController>() {
+
+    val downloadedOnly = preferencesHelper.downloadedOnly().asState()
+    val incognitoMode = preferencesHelper.incognitoMode().asState()
+
+    private var _state: MutableStateFlow<DownloadQueueState> = MutableStateFlow(DownloadQueueState.Stopped)
+    val downloadQueueState: StateFlow<DownloadQueueState> = _state
+
+    private var isDownloading: Boolean = false
+    private var downloadQueueSize: Int = 0
+    private var untilDestroySubscriptions = CompositeSubscription()
+
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+
+        if (untilDestroySubscriptions.isUnsubscribed) {
+            untilDestroySubscriptions = CompositeSubscription()
+        }
+
+        initDownloadQueueSummary()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        untilDestroySubscriptions.unsubscribe()
+    }
+
+    private fun initDownloadQueueSummary() {
+        // Handle running/paused status change
+        DownloadService.runningRelay
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeUntilDestroy { isRunning ->
+                isDownloading = isRunning
+                updateDownloadQueueState()
+            }
+
+        // Handle queue progress updating
+        downloadManager.queue.getUpdatedObservable()
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeUntilDestroy {
+                downloadQueueSize = it.size
+                updateDownloadQueueState()
+            }
+    }
+
+    private fun updateDownloadQueueState() {
+        presenterScope.launchIO {
+            val pendingDownloadExists = downloadQueueSize != 0
+            _state.emit(
+                when {
+                    !pendingDownloadExists -> DownloadQueueState.Stopped
+                    !isDownloading && !pendingDownloadExists -> DownloadQueueState.Paused(0)
+                    !isDownloading && pendingDownloadExists -> DownloadQueueState.Paused(downloadQueueSize)
+                    else -> DownloadQueueState.Downloading(downloadQueueSize)
+                }
+            )
+        }
+    }
+
+    private fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription {
+        return subscribe(onNext).also { untilDestroySubscriptions.add(it) }
+    }
+}
+
+sealed class DownloadQueueState {
+    object Stopped : DownloadQueueState()
+    data class Paused(val pending: Int) : DownloadQueueState()
+    data class Downloading(val pending: Int) : DownloadQueueState()
+}