diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt
index b83ffec438..837105ffbe 100644
--- a/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt
+++ b/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt
@@ -1,97 +1,37 @@
 package com.github.kr328.clash.profile
 
-import com.github.kr328.clash.R
+import android.os.Bundle
+import androidx.activity.compose.setContent
 import com.github.kr328.clash.common.util.intent
 import com.github.kr328.clash.common.util.setUUID
 import com.github.kr328.clash.common.util.uuid
 import com.github.kr328.clash.files.FilesActivity
-import com.github.kr328.clash.profile.ui.PropertiesDesign
-import com.github.kr328.clash.service.model.Profile
-import com.github.kr328.clash.ui.DesignActivity
-import com.github.kr328.clash.util.showExceptionSnackbar
-import com.github.kr328.clash.util.withProfile
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.selects.select
+import com.github.kr328.clash.profile.ui.PropertiesScreen
+import com.github.kr328.clash.ui.BaseActivity
+import com.github.kr328.clash.ui.theme.MihomoTheme
 
-class PropertiesActivity : DesignActivity<PropertiesDesign>() {
-  private var canceled: Boolean = false
-  private lateinit var original: Profile
+class PropertiesActivity : BaseActivity() {
+  override fun onCreate(savedInstanceState: Bundle?) {
+    super.onCreate(savedInstanceState)
 
-  override suspend fun main() {
     setResult(RESULT_CANCELED)
 
-    val uuid = intent.uuid ?: return finish()
-    val design = PropertiesDesign(this)
-
-    original = withProfile { queryByUUID(uuid) } ?: return finish()
-
-    design.profile = original
-
-    setContentDesign(design)
-
-    defer {
-      canceled = true
-
-      withProfile { release(uuid) }
-    }
-
-    while (isActive) {
-      select {
-        events.onReceive {
-          when (it) {
-            Event.ActivityStop -> {
-              val profile = design.profile
-
-              if (!canceled && profile != original) {
-                withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval) }
-              }
-            }
-            Event.ServiceRecreated -> {
-              finish()
-            }
-            else -> Unit
-          }
-        }
-        design.requests.onReceive {
-          when (it) {
-            PropertiesDesign.Request.BrowseFiles -> {
-              startActivity(FilesActivity::class.intent.setUUID(uuid))
-            }
-            PropertiesDesign.Request.Commit -> {
-              design.verifyAndCommit()
+    val uuid = checkNotNull(intent.uuid) { "The uuid must not be null." }
+
+    setContent {
+      MihomoTheme {
+        PropertiesScreen(
+          uuid = uuid,
+          onBrowseFiles = { profileUuid ->
+            startActivity(FilesActivity::class.intent.setUUID(profileUuid))
+          },
+          onFinish = { success ->
+            if (success) {
+              setResult(RESULT_OK)
             }
-          }
-        }
-      }
-    }
-  }
-
-  private suspend fun PropertiesDesign.verifyAndCommit() {
-    when {
-      profile.name.isBlank() -> {
-        snackbar(R.string.empty_name)
-      }
-      profile.type != Profile.Type.File && profile.source.isBlank() -> {
-        snackbar(R.string.invalid_url)
-      }
-      else -> {
-        try {
-          withProcessing { updateStatus ->
-            withProfile {
-              patch(profile.uuid, profile.name, profile.source, profile.interval)
-
-              coroutineScope { commit(profile.uuid) { launch { updateStatus(it) } } }
-            }
-          }
-
-          setResult(RESULT_OK)
-
-          finish()
-        } catch (e: Exception) {
-          showExceptionSnackbar(e)
-        }
+            finish()
+          },
+        )
       }
     }
   }
diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt
similarity index 75%
rename from app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt
rename to app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt
index 8cd1326ff5..03324d39d9 100644
--- a/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt
+++ b/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt
@@ -1,7 +1,5 @@
 package com.github.kr328.clash.profile.ui
 
-import android.app.Activity
-import android.content.Context
 import androidx.activity.compose.BackHandler
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
@@ -25,22 +23,28 @@ import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.fromHtml
 import androidx.compose.ui.unit.Dp
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
 import com.github.kr328.clash.R
-import com.github.kr328.clash.core.model.FetchStatus
+import com.github.kr328.clash.profile.vm.PropertiesViewModel
 import com.github.kr328.clash.service.model.Profile
-import com.github.kr328.clash.ui.Design
 import com.github.kr328.clash.ui.component.MihomoScaffold
 import com.github.kr328.clash.ui.component.ModelProgressBarDialog
 import com.github.kr328.clash.ui.component.ModelProgressBarState
@@ -52,108 +56,72 @@ import com.github.kr328.clash.ui.theme.mihomoDimens
 import com.github.kr328.clash.util.ValidatorAutoUpdateInterval
 import com.github.kr328.clash.util.ValidatorHttpUrl
 import com.github.kr328.clash.util.ValidatorNotBlank
+import com.github.kr328.clash.util.toast
 import java.util.UUID
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.minutes
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
 
-class PropertiesDesign(context: Context) : Design<PropertiesDesign.Request>(context) {
-  sealed interface Request {
-    data object Commit : Request
-
-    data object BrowseFiles : Request
-  }
+@Composable
+fun PropertiesScreen(
+  uuid: UUID,
+  modifier: Modifier = Modifier,
+  viewModel: PropertiesViewModel = viewModel(),
+  onBrowseFiles: (UUID) -> Unit,
+  onFinish: (Boolean) -> Unit,
+) {
+  val lifecycleOwner = LocalLifecycleOwner.current
+  val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+  val eventState by viewModel.eventState.collectAsStateWithLifecycle()
+  val context = LocalContext.current
 
-  private var profileState by mutableStateOf<Profile?>(null)
-  private var originalProfileState by mutableStateOf<Profile?>(null)
-  private var processingState by mutableStateOf(false)
-  private val progressBarState = ModelProgressBarState()
+  LaunchedEffect(uuid) { viewModel.init(uuid = uuid) }
 
-  @Composable
-  override fun Content() = MihomoTheme {
-    profileState?.let { profile ->
-      PropertiesScreen(
-        profile = profile,
-        processing = processingState,
-        progressBarState = progressBarState,
-        hasUnsavedChanges =
-          originalProfileState?.let { original -> hasUnsavedChanges(profile, original) } == true,
-        onBrowseFiles = { requests.trySend(Request.BrowseFiles) },
-        onCommit = { requests.trySend(Request.Commit) },
-        onRequestClose = { (context as? Activity)?.finish() },
-        onNameChanged = { name -> this@PropertiesDesign.profile = profile.copy(name = name) },
-        onUrlChanged = { url -> this@PropertiesDesign.profile = profile.copy(source = url) },
-        onIntervalChanged = { interval ->
-          this@PropertiesDesign.profile = profile.copy(interval = interval)
-        },
-      )
-    }
+  DisposableEffect(lifecycleOwner, viewModel) {
+    lifecycleOwner.lifecycle.addObserver(viewModel)
+    onDispose { lifecycleOwner.lifecycle.removeObserver(viewModel) }
   }
 
-  var profile: Profile
-    get() = requireNotNull(profileState)
-    set(value) {
-      if (originalProfileState == null) {
-        originalProfileState = value.copy()
+  LaunchedEffect(eventState) {
+    when (val event = eventState) {
+      PropertiesViewModel.EventState.Idle -> Unit
+      is PropertiesViewModel.EventState.BrowseFiles -> {
+        onBrowseFiles(event.uuid)
       }
-      profileState = value
-    }
-
-  suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) =
-    try {
-      withContext(Dispatchers.Main) {
-        processingState = true
-        progressBarState.visible = true
-        progressBarState.isIndeterminate = true
-        progressBarState.text = context.getString(R.string.initializing)
-        progressBarState.progress = 0
-        progressBarState.max = 0
+      is PropertiesViewModel.EventState.Finish -> {
+        onFinish(event.success)
       }
-
-      executeTask { status -> withContext(Dispatchers.Main) { progressBarState.applyFrom(status) } }
-    } finally {
-      withContext(Dispatchers.Main) {
-        progressBarState.visible = false
-        progressBarState.text = null
-        processingState = false
+      is PropertiesViewModel.EventState.ShowMessage -> {
+        context.toast(event.message)
       }
     }
-
-  private fun hasUnsavedChanges(profile: Profile, original: Profile): Boolean {
-    return profile.name != original.name ||
-      profile.source != original.source ||
-      profile.interval != original.interval
+    viewModel.consumeEvent()
   }
 
-  private fun ModelProgressBarState.applyFrom(status: FetchStatus) {
-    when (status.action) {
-      FetchStatus.Action.FetchConfiguration -> {
-        text = context.getString(R.string.format_fetching_configuration, status.args[0])
-        isIndeterminate = true
-      }
-      FetchStatus.Action.FetchProviders -> {
-        text = context.getString(R.string.format_fetching_provider, status.args[0])
-        isIndeterminate = false
-        max = status.max
-        progress = status.progress
-      }
-      FetchStatus.Action.Verifying -> {
-        text = context.getString(R.string.verifying)
-        isIndeterminate = false
-        max = status.max
-        progress = status.progress
-      }
-    }
+  val profile = uiState.profile
+  if (profile != null) {
+    PropertiesContent(
+      modifier = modifier,
+      profile = profile,
+      processing = uiState.processing,
+      progressState = uiState.progress,
+      hasUnsavedChanges = uiState.hasUnsavedChanges,
+      onBrowseFiles = viewModel::onBrowseFiles,
+      onCommit = viewModel::onCommit,
+      onRequestClose = viewModel::onRequestClose,
+      onNameChanged = viewModel::onNameChanged,
+      onUrlChanged = viewModel::onUrlChanged,
+      onIntervalChanged = viewModel::onIntervalChanged,
+    )
   }
 }
 
 @Composable
 @OptIn(ExperimentalMaterial3Api::class)
-private fun PropertiesScreen(
+private fun PropertiesContent(
+  modifier: Modifier = Modifier,
   profile: Profile,
   processing: Boolean,
-  progressBarState: ModelProgressBarState,
+  progressState: PropertiesViewModel.ProgressState,
   hasUnsavedChanges: Boolean,
   onBrowseFiles: () -> Unit,
   onCommit: () -> Unit,
@@ -170,6 +138,15 @@ private fun PropertiesScreen(
   var showInputUrlDialog by rememberSaveable { mutableStateOf(false) }
   var showInputIntervalDialog by rememberSaveable { mutableStateOf(false) }
 
+  val progressBarState = remember { ModelProgressBarState() }
+  with(progressBarState) {
+    visible = progressState.visible
+    isIndeterminate = progressState.isIndeterminate
+    text = progressState.text
+    progress = progressState.progress
+    max = progressState.max
+  }
+
   val onBack = {
     when {
       processing -> Unit
@@ -182,6 +159,7 @@ private fun PropertiesScreen(
   BackHandler(onBack = onBack)
 
   MihomoScaffold(
+    modifier = modifier,
     title = stringResource(R.string.properties),
     onBack = onBack,
     scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
@@ -378,8 +356,8 @@ private fun PropertiesActionItem(
 
 @PreviewMihomo
 @Composable
-private fun PropertiesScreenPreview() = MihomoTheme {
-  PropertiesScreen(
+private fun PropertiesContentPreview() = MihomoTheme {
+  PropertiesContent(
     profile =
       Profile(
         uuid = UUID(0, 0),
@@ -397,7 +375,7 @@ private fun PropertiesScreenPreview() = MihomoTheme {
         pending = false,
       ),
     processing = false,
-    progressBarState = ModelProgressBarState(),
+    progressState = PropertiesViewModel.ProgressState(),
     hasUnsavedChanges = false,
     onBrowseFiles = {},
     onCommit = {},
diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt
new file mode 100644
index 0000000000..964bf800e0
--- /dev/null
+++ b/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt
@@ -0,0 +1,247 @@
+package com.github.kr328.clash.profile.vm
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.viewModelScope
+import com.github.kr328.clash.R
+import com.github.kr328.clash.core.model.FetchStatus
+import com.github.kr328.clash.service.model.Profile
+import com.github.kr328.clash.util.withProfile
+import java.util.UUID
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class PropertiesViewModel(app: Application) : AndroidViewModel(app), DefaultLifecycleObserver {
+  private var rootUuid: UUID? = null
+  private var canceled = false
+
+  val uiState: StateFlow<UiState>
+    field = MutableStateFlow(UiState())
+
+  val eventState: StateFlow<EventState>
+    field = MutableStateFlow<EventState>(EventState.Idle)
+
+  fun init(uuid: UUID) {
+    if (rootUuid != null) return
+    rootUuid = uuid
+
+    viewModelScope.launch {
+      val profile = withProfile { queryByUUID(uuid) }
+      if (profile == null) {
+        eventState.value = EventState.Finish(false)
+        return@launch
+      }
+      uiState.update { it.copy(profile = profile, originalProfile = profile.copy()) }
+    }
+  }
+
+  fun consumeEvent() {
+    eventState.value = EventState.Idle
+  }
+
+  override fun onStop(owner: LifecycleOwner) {
+    if (!canceled && uiState.value.hasUnsavedChanges) {
+      val profile = uiState.value.profile ?: return
+      viewModelScope.launch {
+        runCatching {
+          withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval) }
+        }
+      }
+    }
+  }
+
+  @OptIn(DelicateCoroutinesApi::class)
+  override fun onCleared() {
+    rootUuid?.let { uuid ->
+      GlobalScope.launch(Dispatchers.IO) {
+        try {
+          withProfile { release(uuid) }
+        } catch (e: Exception) {
+          // ignore
+        }
+      }
+    }
+    super.onCleared()
+  }
+
+  fun onNameChanged(name: String) {
+    uiState.update { current ->
+      val profile = current.profile?.copy(name = name) ?: return@update current
+      current.copy(
+        profile = profile,
+        hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile),
+      )
+    }
+  }
+
+  fun onUrlChanged(url: String) {
+    uiState.update { current ->
+      val profile = current.profile?.copy(source = url) ?: return@update current
+      current.copy(
+        profile = profile,
+        hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile),
+      )
+    }
+  }
+
+  fun onIntervalChanged(interval: Long) {
+    uiState.update { current ->
+      val profile = current.profile?.copy(interval = interval) ?: return@update current
+      current.copy(
+        profile = profile,
+        hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile),
+      )
+    }
+  }
+
+  fun onBrowseFiles() {
+    val uuid = rootUuid ?: return
+    eventState.value = EventState.BrowseFiles(uuid)
+  }
+
+  fun onRequestClose() {
+    canceled = true
+    eventState.value = EventState.Finish(false)
+  }
+
+  fun onCommit() {
+    val profile = uiState.value.profile ?: return
+
+    if (profile.name.isBlank()) {
+      eventState.value =
+        EventState.ShowMessage(getApplication<Application>().getString(R.string.empty_name))
+      return
+    }
+
+    if (profile.type != Profile.Type.File && profile.source.isBlank()) {
+      eventState.value =
+        EventState.ShowMessage(getApplication<Application>().getString(R.string.invalid_url))
+      return
+    }
+
+    viewModelScope.launch {
+      try {
+        withProcessing { updateStatus ->
+          withProfile {
+            patch(profile.uuid, profile.name, profile.source, profile.interval)
+            coroutineScope { commit(profile.uuid) { launch { updateStatus(it) } } }
+          }
+        }
+        canceled = true
+        eventState.value = EventState.Finish(true)
+      } catch (e: Exception) {
+        eventState.value = EventState.ShowMessage(e.message ?: "Unknown error")
+      }
+    }
+  }
+
+  private suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) {
+    try {
+      withContext(Dispatchers.Main) {
+        uiState.update {
+          it.copy(
+            processing = true,
+            progress =
+              ProgressState(
+                visible = true,
+                isIndeterminate = true,
+                text = getApplication<Application>().getString(R.string.initializing),
+                progress = 0,
+                max = 0,
+              ),
+          )
+        }
+      }
+
+      executeTask { status -> withContext(Dispatchers.Main) { applyProgressStatus(status) } }
+    } finally {
+      withContext(Dispatchers.Main) {
+        uiState.update {
+          it.copy(processing = false, progress = it.progress.copy(visible = false, text = null))
+        }
+      }
+    }
+  }
+
+  private fun applyProgressStatus(status: FetchStatus) {
+    uiState.update { current ->
+      val context = getApplication<Application>()
+      val newProgress =
+        when (status.action) {
+          FetchStatus.Action.FetchConfiguration -> {
+            current.progress.copy(
+              text =
+                context.getString(
+                  R.string.format_fetching_configuration,
+                  status.args.getOrNull(0) ?: "",
+                ),
+              isIndeterminate = true,
+            )
+          }
+          FetchStatus.Action.FetchProviders -> {
+            current.progress.copy(
+              text =
+                context.getString(
+                  R.string.format_fetching_provider,
+                  status.args.getOrNull(0) ?: "",
+                ),
+              isIndeterminate = false,
+              max = status.max,
+              progress = status.progress,
+            )
+          }
+          FetchStatus.Action.Verifying -> {
+            current.progress.copy(
+              text = context.getString(R.string.verifying),
+              isIndeterminate = false,
+              max = status.max,
+              progress = status.progress,
+            )
+          }
+        }
+      current.copy(progress = newProgress)
+    }
+  }
+
+  private fun hasUnsavedChanges(profile: Profile, original: Profile?): Boolean {
+    if (original == null) return false
+    return profile.name != original.name ||
+      profile.source != original.source ||
+      profile.interval != original.interval
+  }
+
+  data class UiState(
+    val profile: Profile? = null,
+    val originalProfile: Profile? = null,
+    val processing: Boolean = false,
+    val progress: ProgressState = ProgressState(),
+    val hasUnsavedChanges: Boolean = false,
+  )
+
+  data class ProgressState(
+    val visible: Boolean = false,
+    val isIndeterminate: Boolean = false,
+    val text: String? = null,
+    val progress: Int = 0,
+    val max: Int = 0,
+  )
+
+  sealed interface EventState {
+    data object Idle : EventState
+
+    data class Finish(val success: Boolean) : EventState
+
+    data class BrowseFiles(val uuid: UUID) : EventState
+
+    data class ShowMessage(val message: String) : EventState
+  }
+}
