diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 542f6f8..5eda2d3 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
+
+
+
+
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt
index 5ed1e3b..b96e861 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt
@@ -5,65 +5,71 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
-import android.os.Environment
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.vnidrop.app.core.rememberFileSystemService
+import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import java.net.NetworkInterface
-class AndroidPlatform : Platform {
- override val name: String = "Android ${Build.VERSION.SDK_INT}"
- override val defaultCoreDataDir: String =
- AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath
- ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop")
- override val defaultReceiveDir: String =
- AndroidPlatformContextHolder.context
- ?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
- ?.absolutePath
- ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
- override val deviceInfo: DeviceInfo = DeviceInfo(
+@Composable
+fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies {
+ val context = activity.applicationContext
+ val fileSystemService = rememberFileSystemService()
+ val notificationService = rememberAndroidLocalNotificationService(activity)
+ return remember(context, fileSystemService, notificationService) {
+ AppDependencies(
+ environment = PlatformEnvironment(
+ name = "Android ${Build.VERSION.SDK_INT}",
+ appVersion = context.appVersion(),
+ defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath,
+ defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver",
+ ),
+ deviceInfoProvider = AndroidDeviceInfoProvider(context),
+ fileSystemService = fileSystemService,
+ localNotificationService = notificationService,
+ )
+ }
+}
+
+private class AndroidDeviceInfoProvider(
+ private val context: Context,
+) : DeviceInfoProvider {
+ override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = Build.DEVICE,
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
- .filter { it.isNotBlank() }
+ .filter(String::isNotBlank)
.joinToString(" ")
.ifBlank { null },
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
- network = activeNetworkSummary(),
- batteryLevel = batteryLevel(),
+ network = context.activeNetworkSummary(),
+ batteryLevel = context.batteryLevel(),
)
}
-actual fun getPlatform(): Platform = AndroidPlatform()
+private fun Context.appVersion(): String = runCatching {
+ packageManager.getPackageInfo(packageName, 0).versionName
+}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0"
-fun attachAndroidPlatformContext(context: Context) {
- AndroidPlatformContextHolder.context = context.applicationContext
-}
+private fun Context.activeNetworkSummary(): String? = runCatching {
+ val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ ?: return@runCatching networkInterfaceName()
+ val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
+ val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
+ when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
+ else -> networkInterfaceName()
+ }
+}.getOrNull()
-private object AndroidPlatformContextHolder {
- var context: Context? = null
-}
-
-private fun activeNetworkSummary(): String? =
- runCatching {
- val context = AndroidPlatformContextHolder.context ?: return@runCatching networkInterfaceName()
- val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
- ?: return@runCatching networkInterfaceName()
- val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
- val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
-
- when {
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
- else -> networkInterfaceName()
- }
- }.getOrNull()
-
-private fun batteryLevel(): String? =
- runCatching {
- val context = AndroidPlatformContextHolder.context ?: return@runCatching null
- val manager = context.getSystemService(BatteryManager::class.java)
- val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
- level.takeIf { it >= 0 }?.let { "$it%" }
- }.getOrNull()
+private fun Context.batteryLevel(): String? = runCatching {
+ val manager = getSystemService(BatteryManager::class.java)
+ val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
+ level.takeIf { it >= 0 }?.let { "$it%" }
+}.getOrNull()
private fun networkInterfaceName(): String? =
NetworkInterface.getNetworkInterfaces()
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
index 155b2cc..e3f101d 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
@@ -62,35 +62,6 @@ actual fun rememberReceiveFolderPicker(
}
}
-actual suspend fun sharePickedFile(
- repository: CoreRepository,
- file: PickedShareFile,
- transferName: String,
- senderName: String,
-) {
- val context = AndroidContextHolder.context
- errorIfNull(context, "Android context has not been attached")
- .contentResolver
- .openFileDescriptor(Uri.parse(file.value), "r")
- .use { descriptor ->
- checkNotNull(descriptor) { "Could not open selected file descriptor" }
- repository.shareFileDescriptor(
- fd = descriptor.fd,
- displayName = file.displayName,
- transferName = transferName,
- senderName = senderName,
- )
- }
-}
-
-private object AndroidContextHolder {
- var context: Context? = null
-}
-
-fun attachAndroidFilePickerContext(context: Context) {
- AndroidContextHolder.context = context.applicationContext
-}
-
private fun Context.displayName(uri: Uri): String {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -100,6 +71,3 @@ private fun Context.displayName(uri: Uri): String {
}
return uri.lastPathSegment ?: "transfer"
}
-
-private fun errorIfNull(value: T?, message: String): T =
- value ?: error(message)
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
index 026b8ae..a03f160 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
@@ -45,6 +45,23 @@ private class AndroidFileSystemService(
return AndroidTreeReceiveOutputSink(context, folder.value.toUri())
}
+ override suspend fun sharePickedFile(
+ repository: CoreGateway,
+ file: PickedShareFile,
+ transferName: String,
+ senderName: String,
+ ): Result = runCatching {
+ context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor ->
+ checkNotNull(descriptor) { "Could not open selected file descriptor" }
+ repository.shareFileDescriptor(
+ fd = descriptor.fd,
+ displayName = file.displayName,
+ transferName = transferName,
+ senderName = senderName,
+ ).getOrThrow()
+ }
+ }
+
private fun validatePath(path: String): FolderAccessStatus =
runCatching {
val directory = java.io.File(path)
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt
new file mode 100644
index 0000000..8cc39ca
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt
@@ -0,0 +1,168 @@
+package com.vnidrop.app.notifications
+
+import android.annotation.SuppressLint
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+@Composable
+fun rememberAndroidLocalNotificationService(activity: ComponentActivity): LocalNotificationService {
+ val holder = viewModel { AndroidNotificationServiceHolder(activity.applicationContext) }
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ holder.service.completePermissionRequest(granted)
+ }
+ SideEffect {
+ holder.service.attachPermissionLauncher {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ return holder.service
+}
+
+private class AndroidNotificationServiceHolder(context: Context) : ViewModel() {
+ val service = AndroidLocalNotificationService(context)
+}
+
+private class AndroidLocalNotificationService(
+ private val context: Context,
+) : LocalNotificationService {
+ private val _permission = MutableStateFlow(currentPermission())
+ override val permission: StateFlow = _permission.asStateFlow()
+ private var permissionContinuation: CancellableContinuation? = null
+ private var launchPermissionRequest: (() -> Unit)? = null
+
+ init {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(
+ NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH),
+ )
+ }
+ }
+
+ override suspend fun refreshPermission(): NotificationPermission = currentPermission().also { _permission.value = it }
+
+ override suspend fun requestPermission(): NotificationPermission {
+ val current = refreshPermission()
+ if (current != NotificationPermission.NotDetermined) return current
+ return suspendCancellableCoroutine { continuation ->
+ permissionContinuation?.cancel()
+ permissionContinuation = continuation
+ continuation.invokeOnCancellation { permissionContinuation = null }
+ val launcher = launchPermissionRequest
+ if (launcher == null) {
+ permissionContinuation = null
+ continuation.resume(NotificationPermission.Denied)
+ } else {
+ markPermissionRequested()
+ launcher()
+ }
+ }
+ }
+
+ override suspend fun openSettings(): Result = runCatching {
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ } else {
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
+ }
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+
+ @SuppressLint("MissingPermission")
+ override suspend fun publish(notification: LocalNotification): Result = runCatching {
+ check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ val pendingIntent = launchIntent?.let {
+ PendingIntent.getActivity(
+ context,
+ notification.id.hashCode(),
+ it,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(context, ChannelId)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(context)
+ }
+ val built = builder
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentTitle(notification.title)
+ .setContentText(notification.body)
+ .setStyle(Notification.BigTextStyle().bigText(notification.body))
+ .setAutoCancel(true)
+ .setPriority(Notification.PRIORITY_HIGH)
+ .setContentIntent(pendingIntent)
+ .build()
+ context.getSystemService(NotificationManager::class.java).notify(notification.id.hashCode(), built)
+ }
+
+ override suspend fun cancel(id: String) {
+ context.getSystemService(NotificationManager::class.java).cancel(id.hashCode())
+ }
+
+ override suspend fun cancelAll() {
+ context.getSystemService(NotificationManager::class.java).cancelAll()
+ }
+
+ fun completePermissionRequest(granted: Boolean) {
+ markPermissionRequested()
+ val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
+ _permission.value = result
+ permissionContinuation?.takeIf { it.isActive }?.resume(result)
+ permissionContinuation = null
+ }
+
+ fun attachPermissionLauncher(launcher: () -> Unit) {
+ launchPermissionRequest = launcher
+ }
+
+ private fun currentPermission(): NotificationPermission {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ return if (manager.areNotificationsEnabled()) NotificationPermission.Granted else NotificationPermission.Denied
+ }
+ val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+ return when {
+ granted && manager.areNotificationsEnabled() -> NotificationPermission.Granted
+ wasPermissionRequested() -> NotificationPermission.Denied
+ else -> NotificationPermission.NotDetermined
+ }
+ }
+
+ private fun markPermissionRequested() {
+ context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).edit().putBoolean(PermissionRequestedKey, true).apply()
+ }
+
+ private fun wasPermissionRequested(): Boolean =
+ context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).getBoolean(PermissionRequestedKey, false)
+
+ private companion object {
+ const val ChannelId = "vnidrop-connection-requests"
+ const val PreferencesName = "vnidrop-notifications"
+ const val PermissionRequestedKey = "permission-requested"
+ }
+}
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 4b661ec..41f495b 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -43,6 +43,7 @@
Receive folder
Choose folder
Reset default
+ Back
Writable
Permission required
Unavailable
@@ -62,6 +63,18 @@
Network
Battery level
Not available
+ Notifications
+ Allow notifications
+ Let VniDrop notify you about new connection requests while the app is running in the background.
+ Notifications are turned off for VniDrop. You can enable them in Settings.
+ Notifications are not available on this device.
+ Notifications enabled.
+ Could not open notification settings.
+ Open Settings
+ On
+ Off
+ Connection request
+ %1$d requests are waiting
Ready
Event log
No events have been emitted yet.
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
index 994cf01..1905137 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
@@ -1,130 +1,106 @@
package com.vnidrop.app
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vnidrop.app.core.rememberFileSystemService
-import com.vnidrop.app.core.rememberReceiveFolderPicker
-import com.vnidrop.app.core.rememberShareFilePicker
-import com.vnidrop.app.logging.AppLogger
-import com.vnidrop.app.preferences.AppPreferencesDefaults
-import com.vnidrop.app.preferences.AppPreferencesRepository
-import com.vnidrop.app.preferences.createAppPreferencesDataStore
+import com.vnidrop.app.feature.app.AppViewModel
+import com.vnidrop.app.feature.app.AppGraphViewModel
+import com.vnidrop.app.feature.approvals.ApprovalBannerHost
+import com.vnidrop.app.feature.receive.ReceiveRoute
+import com.vnidrop.app.feature.receive.ReceiveViewModel
+import com.vnidrop.app.feature.send.SendRoute
+import com.vnidrop.app.feature.send.SendViewModel
+import com.vnidrop.app.feature.settings.SettingsRoute
+import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance
+import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
import com.vnidrop.app.ui.navigation.AppDestination
-import com.vnidrop.app.ui.screens.ReceiveScreen
-import com.vnidrop.app.ui.screens.SendScreen
-import com.vnidrop.app.ui.screens.SettingsScreen
import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.state.windowClassFor
-import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
@Composable
-@Preview
-fun App() {
- val platform = remember { getPlatform() }
- val fileSystemService = rememberFileSystemService()
- val preferencesRepository = remember(platform.defaultCoreDataDir) {
- AppPreferencesRepository(
- dataStore = createAppPreferencesDataStore(platform.defaultCoreDataDir),
- defaults = AppPreferencesDefaults(
- username = platform.deviceInfo.deviceName?.takeIf { it.isNotBlank() } ?: "Receiver",
- receiveFolder = fileSystemService.defaultReceiveFolder(),
- themeMode = ThemeMode.System,
- ),
- )
- }
- val viewModel = viewModel {
- VniDropAppViewModel(
- appDataDir = platform.defaultCoreDataDir,
- platformName = platform.name,
- preferencesRepository = preferencesRepository,
- fileSystemService = fileSystemService,
- )
- }
- val state by viewModel.state.collectAsStateWithLifecycle()
- val coreState by viewModel.coreState.collectAsStateWithLifecycle()
- val clipboard = LocalClipboardManager.current
- val picker = rememberShareFilePicker(
- onFilePicked = { file ->
- viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file))
- },
- onError = { error ->
- viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
- },
- )
- val receiveFolderPicker = rememberReceiveFolderPicker(
- onFolderPicked = { folder ->
- viewModel.onEvent(VniDropAppEvent.ReceiveFolderPicked(folder))
- },
- onError = { error ->
- viewModel.onEvent(VniDropAppEvent.ReceiveFolderPickFailed(error))
- },
- )
+fun App(dependencies: AppDependencies) {
+ val graphHolder = viewModel { AppGraphViewModel(dependencies) }
+ val graph = graphHolder.graph
- LaunchedEffect(viewModel) {
- viewModel.effectFlow.collect { effect ->
- when (effect) {
- VniDropAppEffect.OpenShareFilePicker -> {
- AppLogger.info("file-picker", "open share file picker")
- picker.pickFile()
- }
- VniDropAppEffect.OpenReceiveFolderPicker -> {
- AppLogger.info("file-picker", "open receive folder picker")
- receiveFolderPicker.pickFolder()
- }
- is VniDropAppEffect.CopyTicket -> {
- AppLogger.info("send", "ticket copied")
- clipboard.setText(AnnotatedString(effect.ticket))
+ val appViewModel = viewModel {
+ AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
+ }
+ val sendViewModel = viewModel {
+ SendViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
+ }
+ val receiveViewModel = viewModel {
+ ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
+ }
+ val settingsViewModel = viewModel {
+ SettingsViewModel(
+ dependencies.environment,
+ dependencies.deviceInfoProvider,
+ dependencies.fileSystemService,
+ graph.preferencesRepository,
+ dependencies.localNotificationService,
+ graph.messages,
+ )
+ }
+ val appState by appViewModel.state.collectAsStateWithLifecycle()
+ val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_START -> {
+ graph.visibility.setForeground(true)
+ settingsViewModel.refreshNotificationPermission()
}
+ Lifecycle.Event.ON_STOP -> graph.visibility.setForeground(false)
+ else -> Unit
}
}
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
- val isDarkTheme = rememberResolvedDarkTheme(state.app.themeMode)
- PlatformSystemAppearance(isDarkTheme)
- LaunchedEffect(isDarkTheme) {
- AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
- }
-
- VniDropTheme(isDarkTheme = isDarkTheme) {
+ val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
+ PlatformSystemAppearance(darkTheme)
+ VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value)
- AppShell(
- selectedDestination = state.app.destination,
- windowClass = windowClass,
- onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) },
- ) {
- when (state.app.destination) {
- AppDestination.Send -> SendScreen(
- coreState = coreState,
- sendState = state.send,
- windowClass = windowClass,
- onEvent = viewModel::onEvent,
- )
- AppDestination.Receive -> ReceiveScreen(
- coreState = coreState,
- receiveState = state.receive,
- preferencesState = state.preferences,
- onEvent = viewModel::onEvent,
- )
- AppDestination.Settings -> SettingsScreen(
- deviceInfo = platform.deviceInfo,
- coreState = coreState,
- themeMode = state.app.themeMode,
- preferencesState = state.preferences,
- windowClass = windowClass,
- onEvent = viewModel::onEvent,
- )
+ Column(Modifier.fillMaxSize()) {
+ ApprovalBannerHost(
+ state = approvalState,
+ onAccept = graph.approvalCoordinator::accept,
+ onRefuse = graph.approvalCoordinator::refuse,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ AppShell(
+ modifier = Modifier.fillMaxSize().weight(1f),
+ selectedDestination = appState.destination,
+ windowClass = windowClass,
+ onDestinationSelected = appViewModel::selectDestination,
+ overlay = {
+ VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
+ },
+ ) {
+ when (appState.destination) {
+ AppDestination.Send -> SendRoute(sendViewModel, windowClass) { ticket ->
+ receiveViewModel.setTicket(ticket)
+ appViewModel.selectDestination(AppDestination.Receive)
+ }
+ AppDestination.Receive -> ReceiveRoute(receiveViewModel)
+ AppDestination.Settings -> SettingsRoute(settingsViewModel, windowClass)
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
new file mode 100644
index 0000000..52b03b9
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
@@ -0,0 +1,51 @@
+package com.vnidrop.app
+
+import com.vnidrop.app.core.CoreGateway
+import com.vnidrop.app.core.CoreRepository
+import com.vnidrop.app.feature.approvals.ApprovalCoordinator
+import com.vnidrop.app.logging.AppLogger
+import com.vnidrop.app.platform.AppVisibility
+import com.vnidrop.app.preferences.AppPreferencesDefaults
+import com.vnidrop.app.preferences.AppPreferencesRepository
+import com.vnidrop.app.preferences.createAppPreferencesDataStore
+import com.vnidrop.app.ui.feedback.UiMessageController
+import com.vnidrop.app.ui.theme.ThemeMode
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+
+class AppGraph(
+ val dependencies: AppDependencies,
+ private val applicationScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
+ val coreRepository: CoreGateway = CoreRepository(),
+) {
+ val visibility = AppVisibility()
+ val messages = UiMessageController()
+ val preferencesRepository = AppPreferencesRepository(
+ dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
+ defaults = AppPreferencesDefaults(
+ username = dependencies.environment.defaultUsername,
+ receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
+ themeMode = ThemeMode.System,
+ notificationsEnabled = false,
+ ),
+ )
+ val approvalCoordinator = ApprovalCoordinator(
+ repository = coreRepository,
+ preferencesRepository = preferencesRepository,
+ notifications = dependencies.localNotificationService,
+ visibility = visibility,
+ messages = messages,
+ scope = applicationScope,
+ )
+
+ init {
+ AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
+ }
+
+ fun close() {
+ coreRepository.shutdown()
+ applicationScope.cancel()
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt
deleted file mode 100644
index 2f46b02..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.vnidrop.app
-
-class Greeting {
- private val platform = getPlatform()
-
- fun greet(): String {
- return sayHello(platform.name)
- }
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt
deleted file mode 100644
index 2eeba45..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.vnidrop.app
-
-fun sayHello(to: String): String =
- "Hello, $to!"
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt
index cee02ae..23b146a 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt
@@ -1,11 +1,14 @@
package com.vnidrop.app
-interface Platform {
- val name: String
- val defaultCoreDataDir: String
- val defaultReceiveDir: String
- val deviceInfo: DeviceInfo
-}
+import com.vnidrop.app.core.FileSystemService
+import com.vnidrop.app.notifications.LocalNotificationService
+
+data class PlatformEnvironment(
+ val name: String,
+ val appVersion: String,
+ val defaultCoreDataDir: String,
+ val defaultUsername: String = "Receiver",
+)
data class DeviceInfo(
val deviceName: String?,
@@ -15,4 +18,13 @@ data class DeviceInfo(
val batteryLevel: String?,
)
-expect fun getPlatform(): Platform
+fun interface DeviceInfoProvider {
+ suspend fun load(): DeviceInfo
+}
+
+data class AppDependencies(
+ val environment: PlatformEnvironment,
+ val deviceInfoProvider: DeviceInfoProvider,
+ val fileSystemService: FileSystemService,
+ val localNotificationService: LocalNotificationService,
+)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt
deleted file mode 100644
index 19d229d..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt
+++ /dev/null
@@ -1,354 +0,0 @@
-package com.vnidrop.app
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.vnidrop.app.core.CoreRepository
-import com.vnidrop.app.core.CoreUiState
-import com.vnidrop.app.core.FileSystemService
-import com.vnidrop.app.core.FolderAccessStatus
-import com.vnidrop.app.core.PickedShareFile
-import com.vnidrop.app.core.ReceiveFolder
-import com.vnidrop.app.core.ReceiveFolderKind
-import com.vnidrop.app.core.sharePickedFile
-import com.vnidrop.app.logging.AppLogger
-import com.vnidrop.app.preferences.AppPreferences
-import com.vnidrop.app.preferences.AppPreferencesRepository
-import com.vnidrop.app.ui.navigation.AppDestination
-import com.vnidrop.app.ui.state.AppUiState
-import com.vnidrop.app.ui.state.PreferencesUiState
-import com.vnidrop.app.ui.state.ReceiveUiState
-import com.vnidrop.app.ui.state.SendUiState
-import com.vnidrop.app.ui.theme.ThemeMode
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-
-data class VniDropAppState(
- val app: AppUiState = AppUiState(),
- val preferences: PreferencesUiState = PreferencesUiState(),
- val send: SendUiState = SendUiState(),
- val receive: ReceiveUiState = ReceiveUiState(),
-)
-
-sealed interface VniDropAppEvent {
- data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent
- data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent
- data class UsernameChanged(val value: String) : VniDropAppEvent
- data object ChooseReceiveFolderClicked : VniDropAppEvent
- data class ReceiveFolderPicked(val folder: ReceiveFolder) : VniDropAppEvent
- data class ReceiveFolderPickFailed(val reason: String) : VniDropAppEvent
- data object ResetReceiveFolderClicked : VniDropAppEvent
- data object SelectFileClicked : VniDropAppEvent
- data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent
- data class ShareFilePickFailed(val reason: String) : VniDropAppEvent
- data object ClearSelectedSourceClicked : VniDropAppEvent
- data class TransferNameChanged(val value: String) : VniDropAppEvent
- data class SenderNameChanged(val value: String) : VniDropAppEvent
- data object CreateShareClicked : VniDropAppEvent
- data class CopyTicketClicked(val ticket: String) : VniDropAppEvent
- data class UseTicketLocallyClicked(val ticket: String) : VniDropAppEvent
- data class RefreshReceiverRequestsClicked(val transferId: ULong) : VniDropAppEvent
- data class RespondReceiverRequestClicked(val requestId: String, val accepted: Boolean) : VniDropAppEvent
- data class ReceiveTicketChanged(val value: String) : VniDropAppEvent
- data class OutputDirectoryChanged(val value: String) : VniDropAppEvent
- data class ReceiverNameChanged(val value: String) : VniDropAppEvent
- data object InspectTicketClicked : VniDropAppEvent
- data object ReceiveClicked : VniDropAppEvent
-}
-
-sealed interface VniDropAppEffect {
- data object OpenShareFilePicker : VniDropAppEffect
- data object OpenReceiveFolderPicker : VniDropAppEffect
- data class CopyTicket(val ticket: String) : VniDropAppEffect
-}
-
-class VniDropAppViewModel(
- appDataDir: String,
- platformName: String,
- private val preferencesRepository: AppPreferencesRepository,
- private val fileSystemService: FileSystemService,
- private val repository: CoreRepository = CoreRepository(),
-) : ViewModel() {
- private val _state = MutableStateFlow(VniDropAppState())
- val state: StateFlow = _state
- val coreState: StateFlow = repository.state
-
- private val effects = Channel(Channel.BUFFERED)
- val effectFlow = effects.receiveAsFlow()
-
- private var selectedFile: PickedShareFile? = null
-
- override fun onCleared() {
- repository.shutdown()
- super.onCleared()
- }
-
- init {
- AppLogger.initialize(appDataDir)
- AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName))
- AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir))
- viewModelScope.launch {
- repository.initialize(appDataDir)
- }
- viewModelScope.launch {
- preferencesRepository.preferences.collect { preferences ->
- applyPreferences(preferences)
- validateReceiveFolder(preferences.receiveFolder)
- }
- }
- }
-
- fun onEvent(event: VniDropAppEvent) {
- when (event) {
- is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) }
- is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode)
- is VniDropAppEvent.UsernameChanged -> setUsername(event.value)
- VniDropAppEvent.ChooseReceiveFolderClicked -> sendEffect(VniDropAppEffect.OpenReceiveFolderPicker)
- is VniDropAppEvent.ReceiveFolderPicked -> setReceiveFolder(event.folder)
- is VniDropAppEvent.ReceiveFolderPickFailed -> setFilePickerError(event.reason)
- VniDropAppEvent.ResetReceiveFolderClicked -> resetReceiveFolder()
- VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker)
- is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file)
- is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason)
- VniDropAppEvent.ClearSelectedSourceClicked -> clearSelectedSource()
- is VniDropAppEvent.TransferNameChanged -> updateSendState { copy(transferName = event.value) }
- is VniDropAppEvent.SenderNameChanged -> updateSendState { copy(senderName = event.value) }
- VniDropAppEvent.CreateShareClicked -> createShare()
- is VniDropAppEvent.CopyTicketClicked -> sendEffect(VniDropAppEffect.CopyTicket(event.ticket))
- is VniDropAppEvent.UseTicketLocallyClicked -> useTicketLocally(event.ticket)
- is VniDropAppEvent.RefreshReceiverRequestsClicked -> refreshReceiverRequests(event.transferId)
- is VniDropAppEvent.RespondReceiverRequestClicked -> respondReceiverRequest(event.requestId, event.accepted)
- is VniDropAppEvent.ReceiveTicketChanged -> updateReceiveState { copy(ticket = event.value) }
- is VniDropAppEvent.OutputDirectoryChanged -> updateReceiveState { copy(outputDirectory = event.value) }
- is VniDropAppEvent.ReceiverNameChanged -> updateReceiveState { copy(receiverName = event.value) }
- VniDropAppEvent.InspectTicketClicked -> inspectTicket()
- VniDropAppEvent.ReceiveClicked -> receive()
- }
- }
-
- private fun setThemeMode(mode: ThemeMode) {
- AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name))
- updateAppState { copy(themeMode = mode) }
- viewModelScope.launch {
- preferencesRepository.setThemeMode(mode)
- }
- }
-
- private fun setUsername(username: String) {
- viewModelScope.launch {
- preferencesRepository.setUsername(username)
- }
- }
-
- private fun setReceiveFolder(folder: ReceiveFolder) {
- viewModelScope.launch {
- preferencesRepository.setReceiveFolder(folder)
- }
- }
-
- private fun resetReceiveFolder() {
- viewModelScope.launch {
- preferencesRepository.resetReceiveFolder()
- }
- }
-
- private fun setSelectedFile(file: PickedShareFile) {
- AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
- selectedFile = file
- updateSendState { withSelectedFile(file) }
- }
-
- private fun setFilePickerError(reason: String) {
- AppLogger.warn("file-picker", "file picker error", mapOf("reason" to reason))
- viewModelScope.launch { repository.setError(reason) }
- }
-
- private fun clearSelectedSource() {
- selectedFile = null
- updateSendState {
- copy(
- selectedSource = "",
- selectedDisplayName = "",
- )
- }
- }
-
- private fun createShare() {
- val sendState = state.value.send
- if (!sendState.canCreateShare(coreState.value.isInitialized)) return
-
- viewModelScope.launch {
- AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource))
- updateSendState { copy(isSharing = true) }
- try {
- val file = selectedFile
- if (file == null) {
- repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
- } else {
- sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
- }
- repository.state.value.lastShare?.let { share ->
- repository.refreshReceiverRequests(share.transferId)
- }
- } finally {
- updateSendState { copy(isSharing = false) }
- }
- }
- }
-
- private fun useTicketLocally(ticket: String) {
- updateReceiveState { copy(ticket = ticket) }
- updateAppState { copy(destination = AppDestination.Receive) }
- }
-
- private fun refreshReceiverRequests(transferId: ULong) {
- viewModelScope.launch {
- repository.refreshReceiverRequests(transferId)
- }
- }
-
- private fun respondReceiverRequest(requestId: String, accepted: Boolean) {
- viewModelScope.launch {
- repository.respondReceiverRequest(
- requestId = requestId,
- accepted = accepted,
- reason = if (accepted) null else "sender-refused",
- )
- }
- }
-
- private fun inspectTicket() {
- val receiveState = state.value.receive
- if (!receiveState.canInspect(coreState.value.isInitialized)) return
-
- viewModelScope.launch {
- repository.inspectTicket(receiveState.ticket)
- }
- }
-
- private fun receive() {
- val receiveState = state.value.receive
- val preferences = state.value.preferences
- if (!receiveState.canReceive(coreState.value.isInitialized) || !preferences.canReceiveIntoFolder) return
-
- viewModelScope.launch {
- AppLogger.info("receive", "receive requested")
- updateReceiveState { copy(isReceiving = true) }
- try {
- receiveIntoFolder(receiveState, preferences.receiveFolder)
- } finally {
- updateReceiveState { copy(isReceiving = false) }
- }
- }
- }
-
- private suspend fun receiveIntoFolder(receiveState: ReceiveUiState, folder: ReceiveFolder) {
- val outputSink = fileSystemService.createReceiveOutputSink(folder)
- when {
- outputSink != null -> repository.receiveWithOutputSink(receiveState.ticket, outputSink, receiveState.receiverName)
- folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> {
- repository.receiveIntoSecurityScopedDirectory(receiveState.ticket, folder.value, receiveState.receiverName)
- }
- else -> repository.receive(receiveState.ticket, folder.value, receiveState.receiverName)
- }
- }
-
- private fun applyPreferences(preferences: AppPreferences) {
- _state.update { current ->
- val previousUsername = current.preferences.username
- current.copy(
- app = current.app.copy(themeMode = preferences.themeMode),
- preferences = current.preferences.copy(
- username = preferences.username,
- receiveFolder = preferences.receiveFolder,
- ),
- send = current.send.withDefaultName(
- currentValue = current.send.senderName,
- previousDefault = previousUsername,
- nextDefault = preferences.username,
- update = { copy(senderName = it) },
- ),
- receive = current.receive
- .withDefaultName(
- currentValue = current.receive.receiverName,
- previousDefault = previousUsername,
- nextDefault = preferences.username,
- update = { copy(receiverName = it) },
- )
- .copy(outputDirectory = preferences.receiveFolder.value),
- )
- }
- }
-
- private suspend fun validateReceiveFolder(folder: ReceiveFolder) {
- updatePreferencesState { copy(isValidatingFolder = true) }
- val status = fileSystemService.validateReceiveFolder(folder)
- updatePreferencesState {
- copy(
- folderAccessStatus = status,
- isValidatingFolder = false,
- )
- }
- if (status != FolderAccessStatus.Writable) {
- repository.setError("Receive folder is not writable.")
- }
- }
-
- private fun sendEffect(effect: VniDropAppEffect) {
- viewModelScope.launch {
- effects.send(effect)
- }
- }
-
- private fun updateAppState(reducer: AppUiState.() -> AppUiState) {
- _state.update { current ->
- val next = current.app.reducer()
- if (next == current.app) current else current.copy(app = next)
- }
- }
-
- private fun updateSendState(reducer: SendUiState.() -> SendUiState) {
- _state.update { current ->
- val next = current.send.reducer()
- if (next == current.send) current else current.copy(send = next)
- }
- }
-
- private fun updateReceiveState(reducer: ReceiveUiState.() -> ReceiveUiState) {
- _state.update { current ->
- val next = current.receive.reducer()
- if (next == current.receive) current else current.copy(receive = next)
- }
- }
-
- private fun updatePreferencesState(reducer: PreferencesUiState.() -> PreferencesUiState) {
- _state.update { current ->
- val next = current.preferences.reducer()
- if (next == current.preferences) current else current.copy(preferences = next)
- }
- }
-}
-
-private fun T.withDefaultName(
- currentValue: String,
- previousDefault: String,
- nextDefault: String,
- update: T.(String) -> T,
-): T =
- if (currentValue.isBlank() || currentValue == previousDefault) {
- update(nextDefault)
- } else {
- this
- }
-
-private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
- copy(
- selectedSource = file.value,
- selectedDisplayName = file.displayName,
- transferName = if (transferName == DefaultTransferName || transferName.isBlank()) file.displayName else transferName,
- )
-
-private const val DefaultTransferName = "VniDrop transfer"
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
new file mode 100644
index 0000000..a516a40
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
@@ -0,0 +1,104 @@
+package com.vnidrop.app.core
+
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import uniffi.vnidrop.ReceiveOutputSink
+
+data class CoreStatus(
+ val endpointId: String,
+ val activeTransfers: ULong,
+ val activeShares: ULong,
+)
+
+data class CoreEventModel(
+ val id: String,
+ val timestamp: Long,
+ val scope: String,
+ val transferId: ULong?,
+ val direction: String?,
+ val phase: String,
+ val kind: String,
+ val dataJson: String,
+)
+
+data class Transfer(
+ val localId: String,
+ val transferId: ULong,
+ val direction: String,
+ val status: String,
+ val peerId: String?,
+ val transferName: String?,
+ val fileCount: ULong,
+ val totalSize: ULong,
+ val ticket: String?,
+)
+
+data class Share(
+ val transferId: ULong,
+ val ticket: String,
+ val transferName: String,
+ val contentHash: String,
+ val fileCount: ULong,
+ val totalSize: ULong,
+)
+
+data class TransferMetadataModel(
+ val transferId: ULong,
+ val transferName: String,
+ val senderName: String?,
+ val contentHash: String,
+ val fileCount: ULong,
+ val totalSize: ULong,
+)
+
+data class TicketInspectionModel(
+ val kind: String,
+ val blobTicket: String,
+ val metadata: TransferMetadataModel?,
+)
+
+data class ReceiverRequestModel(
+ val id: String,
+ val transferId: ULong,
+ val remoteEndpointId: String,
+ val transferName: String,
+ val receiverName: String?,
+ val receiverDeviceName: String?,
+ val appVersion: String,
+ val status: String,
+ val reason: String?,
+ val requestedAt: Long,
+ val respondedAt: Long?,
+)
+
+data class CoreState(
+ val isInitialized: Boolean = false,
+ val status: CoreStatus? = null,
+ val events: List = emptyList(),
+ val transfers: List = emptyList(),
+ val lastShare: Share? = null,
+ val lastInspection: TicketInspectionModel? = null,
+)
+
+sealed interface CoreSignal {
+ data class ApprovalChanged(val transferId: ULong) : CoreSignal
+}
+
+interface CoreGateway {
+ val state: StateFlow
+ val signals: SharedFlow
+
+ suspend fun initialize(appDataDir: String): Result
+ fun shutdown()
+ suspend fun sharePath(path: String, transferName: String, senderName: String): Result
+ suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String): Result
+ suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String): Result
+ suspend fun inspectTicket(ticket: String): Result
+ suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result
+ suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result
+ suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result
+ suspend fun cancel(transferId: ULong): Result
+ suspend fun receiverRequests(transferId: ULong): Result>
+ suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result
+ suspend fun refresh(): Result
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
index ac4c48d..b157106 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
@@ -1,68 +1,70 @@
package com.vnidrop.app.core
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlin.random.Random
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink
-import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ReceiveOutputSink
+import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.ShareSource
import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
+import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.VnidropCore
-data class CoreUiState(
- val isInitialized: Boolean = false,
- val status: String = "Not initialized",
- val events: List = emptyList(),
- val transfers: List = emptyList(),
- val lastShare: ShareResult? = null,
- val lastInspection: TicketInspection? = null,
- val receiverRequests: List = emptyList(),
- val error: String? = null,
-)
-
class CoreRepository(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
-) {
- private val _state = MutableStateFlow(CoreUiState())
- val state: StateFlow = _state
+) : CoreGateway {
+ private val _state = MutableStateFlow(CoreState())
+ override val state: StateFlow = _state.asStateFlow()
+
+ private val _signals = MutableSharedFlow(
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ override val signals: SharedFlow = _signals.asSharedFlow()
private var core: VnidropCore? = null
- fun shutdown() {
- core?.shutdown()
- core = null
- _state.update { it.copy(isInitialized = false, status = "Not initialized") }
- }
-
private val sink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) {
- _state.update { current ->
- current.copy(events = (listOf(event) + current.events).take(200))
+ val model = event.toModel()
+ _state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
+ if (model.phase == "approval" && model.transferId != null) {
+ _signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
}
}
}
- suspend fun initialize(appDataDir: String) = runCore {
+ override suspend fun initialize(appDataDir: String): Result = runCore {
core?.shutdown()
core = VnidropCore.initialize(appDataDir, sink)
- refreshStatus()
- loadTransfers()
- loadEvents()
- _state.update { it.copy(isInitialized = true, error = null) }
+ refreshSnapshot()
+ _state.update { it.copy(isInitialized = true) }
}
- suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore {
+ override fun shutdown() {
+ core?.shutdown()
+ core = null
+ _state.value = CoreState()
+ }
+
+ override suspend fun sharePath(path: String, transferName: String, senderName: String): Result =
shareSources(
sources = listOf(
ShareSource(
@@ -75,17 +77,13 @@ class CoreRepository(
transferName = transferName,
senderName = senderName,
)
- }
- suspend fun shareFileDescriptor(
+ override suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
- ) = runCore {
- // The fd is borrowed from platform code. Rust duplicates it before
- // starting the import, so Android may close the ParcelFileDescriptor
- // once this suspend call returns.
+ ): Result =
shareSources(
sources = listOf(
ShareSource(
@@ -98,16 +96,13 @@ class CoreRepository(
transferName = transferName,
senderName = senderName,
)
- }
- suspend fun shareSecurityScopedFileUrl(
+ override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
- ) = runCore {
- // The iOS actual for withPlatformPathAccess starts and stops the
- // security-scoped URL lease around this entire shareFiles call.
+ ): Result =
shareSources(
sources = listOf(
ShareSource(
@@ -120,99 +115,75 @@ class CoreRepository(
transferName = transferName,
senderName = senderName,
)
+
+ override suspend fun inspectTicket(ticket: String): Result = runCore {
+ requireCore().inspectTicket(ticket).toModel().also { inspection ->
+ _state.update { it.copy(lastInspection = inspection) }
+ }
}
- suspend fun inspectTicket(ticket: String) = runCore {
- val inspection = requireCore().inspectTicket(ticket)
- _state.update { it.copy(lastInspection = inspection, error = null) }
- }
-
- suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
+ override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result = runCore {
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
- refreshStatus()
- loadTransfers()
+ refreshSnapshot()
}
- suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = runCore {
+ override suspend fun receiveWithOutputSink(
+ ticket: String,
+ outputSink: ReceiveOutputSink,
+ receiverName: String,
+ ): Result = runCore {
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
- refreshStatus()
- loadTransfers()
+ refreshSnapshot()
}
- suspend fun receiveIntoSecurityScopedDirectory(
+ override suspend fun receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String,
- ) = runCore {
+ ): Result = runCore {
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
}
- refreshStatus()
- loadTransfers()
+ refreshSnapshot()
}
- suspend fun cancel(transferId: ULong) = runCore {
+ override suspend fun cancel(transferId: ULong): Result = runCore {
requireCore().cancelTransfer(transferId)
- refreshStatus()
- loadTransfers()
+ refreshSnapshot()
}
- suspend fun refreshReceiverRequests(transferId: ULong) = runCore {
- val requests = requireCore().listReceiverRequests(transferId)
- _state.update { it.copy(receiverRequests = requests, error = null) }
+ override suspend fun receiverRequests(transferId: ULong): Result> = runCore {
+ requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
}
- suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore {
+ override suspend fun respondReceiverRequest(
+ requestId: String,
+ accepted: Boolean,
+ reason: String?,
+ ): Result = runCore {
requireCore().respondReceiverRequest(requestId, accepted, reason)
- state.value.lastShare?.let { share ->
- val requests = requireCore().listReceiverRequests(share.transferId)
- _state.update { it.copy(receiverRequests = requests, error = null) }
- }
}
- suspend fun refreshTransfers() = runCore {
- loadTransfers()
- }
-
- suspend fun refreshEvents() = runCore {
- loadEvents()
- }
-
- suspend fun setError(message: String) {
- _state.update { it.copy(error = message) }
- }
-
- private suspend fun runCore(block: suspend () -> Unit) {
- withContext(dispatcher) {
- try {
- block()
- } catch (error: Throwable) {
- _state.update { it.copy(error = error.message ?: error.toString()) }
- }
- }
- }
-
- private fun requireCore(): VnidropCore =
- core ?: error("Initialize the core first.")
+ override suspend fun refresh(): Result = runCore { refreshSnapshot() }
private suspend fun shareSources(
sources: List,
transferName: String,
senderName: String,
- ) {
+ ): Result = runCore {
withPlatformPathAccess(sources) {
- val result = requireCore().shareFiles(
+ requireCore().shareFiles(
sources = sources,
metadata = ShareMetadataInput(
transferId = nextTransferId(),
transferName = transferName.ifBlank { null },
senderName = senderName.ifBlank { null },
),
- )
- _state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
+ ).toModel()
+ }.also { share ->
+ refreshSnapshot()
+ _state.update { it.copy(lastShare = share) }
}
- refreshStatus()
- loadTransfers()
}
private suspend fun withPlatformPathAccess(
@@ -220,36 +191,101 @@ class CoreRepository(
index: Int = 0,
block: suspend () -> T,
): T {
- if (index >= sources.size) {
- return block()
- }
+ if (index >= sources.size) return block()
val source = sources[index]
return withPlatformPathAccess(source.kind, source.value) {
withPlatformPathAccess(sources, index + 1, block)
}
}
- private fun refreshStatus() {
- val status = core?.status()
+ private fun refreshSnapshot() {
+ val activeCore = requireCore()
+ val status = activeCore.status()
_state.update {
it.copy(
- status = status?.let { value ->
- "Endpoint ${value.endpointId.take(12)}... | active=${value.activeTransfers} shares=${value.activeShares}"
- } ?: "Not initialized",
+ status = CoreStatus(status.endpointId, status.activeTransfers, status.activeShares),
+ transfers = activeCore.listTransfers().map(StoredTransfer::toModel),
+ events = activeCore.listEvents(null).map(CoreEvent::toModel).take(MaxEvents),
)
}
}
- private fun loadTransfers() {
- val transfers = core?.listTransfers().orEmpty()
- _state.update { it.copy(transfers = transfers, error = null) }
- }
+ private suspend fun runCore(block: suspend () -> T): Result =
+ withContext(dispatcher) {
+ try {
+ Result.success(block())
+ } catch (error: Throwable) {
+ if (error is CancellationException) throw error
+ Result.failure(error)
+ }
+ }
- private fun loadEvents() {
- val events = core?.listEvents(null).orEmpty()
- _state.update { it.copy(events = events.take(200), error = null) }
- }
+ private fun requireCore(): VnidropCore = core ?: error("Initialize the core first.")
- private fun nextTransferId(): ULong =
- Random.nextLong(1, Long.MAX_VALUE).toULong()
+ private fun nextTransferId(): ULong = Random.nextLong(1, Long.MAX_VALUE).toULong()
+
+ private companion object {
+ const val MaxEvents = 200
+ }
}
+
+private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
+ id = id,
+ timestamp = timestamp,
+ scope = scope,
+ transferId = transferId,
+ direction = direction,
+ phase = phase,
+ kind = kind,
+ dataJson = dataJson,
+)
+
+private fun StoredTransfer.toModel(): Transfer = Transfer(
+ localId = localId,
+ transferId = transferId,
+ direction = direction,
+ status = status,
+ peerId = peerId,
+ transferName = transferName,
+ fileCount = fileCount,
+ totalSize = totalSize,
+ ticket = ticket,
+)
+
+private fun ShareResult.toModel(): Share = Share(
+ transferId = transferId,
+ ticket = ticket,
+ transferName = transferName,
+ contentHash = hash,
+ fileCount = fileCount,
+ totalSize = totalSize,
+)
+
+private fun TicketInspection.toModel(): TicketInspectionModel = TicketInspectionModel(
+ kind = kind,
+ blobTicket = blobTicket,
+ metadata = metadata?.toModel(),
+)
+
+private fun TransferMetadata.toModel(): TransferMetadataModel = TransferMetadataModel(
+ transferId = transferId,
+ transferName = transferName,
+ senderName = senderName,
+ contentHash = contentHash,
+ fileCount = fileCount,
+ totalSize = totalSize,
+)
+
+private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestModel(
+ id = id,
+ transferId = transferId,
+ remoteEndpointId = remoteEndpointId,
+ transferName = transferName,
+ receiverName = receiverName,
+ receiverDeviceName = receiverDeviceName,
+ appVersion = appVersion,
+ status = status,
+ reason = reason,
+ requestedAt = requestedAt,
+ respondedAt = respondedAt,
+)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
index cc7652c..81a17d3 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
@@ -26,10 +26,3 @@ expect fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker
-
-expect suspend fun sharePickedFile(
- repository: CoreRepository,
- file: PickedShareFile,
- transferName: String,
- senderName: String,
-)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
index 7906f7f..b0c7bbf 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
@@ -25,6 +25,12 @@ interface FileSystemService {
fun defaultReceiveFolder(): ReceiveFolder
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink?
+ suspend fun sharePickedFile(
+ repository: CoreGateway,
+ file: PickedShareFile,
+ transferName: String,
+ senderName: String,
+ ): Result
}
@Composable
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt
index 068da8a..f120814 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt
@@ -3,7 +3,7 @@ package com.vnidrop.app.core
import uniffi.vnidrop.SourceKind
// Platform file handles have different lifetime rules. Desktop paths need no
-// extra work, Android fd sources are duplicated immediately by Rust, and iOS
+// extra work, Rust duplicates Android fd sources immediately, and iOS
// security-scoped URLs must remain leased while Rust performs the blocking
// import/export call.
internal expect suspend fun withPlatformPathAccess(
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt
new file mode 100644
index 0000000..2e0f2dc
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt
@@ -0,0 +1,58 @@
+package com.vnidrop.app.feature.app
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vnidrop.app.PlatformEnvironment
+import com.vnidrop.app.AppDependencies
+import com.vnidrop.app.AppGraph
+import com.vnidrop.app.core.CoreGateway
+import com.vnidrop.app.logging.AppLogger
+import com.vnidrop.app.preferences.PreferencesRepository
+import com.vnidrop.app.ui.feedback.UiMessageController
+import com.vnidrop.app.ui.navigation.AppDestination
+import com.vnidrop.app.ui.theme.ThemeMode
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class AppState(
+ val destination: AppDestination = AppDestination.Send,
+ val themeMode: ThemeMode = ThemeMode.System,
+)
+
+class AppGraphViewModel(dependencies: AppDependencies) : ViewModel() {
+ val graph = AppGraph(dependencies)
+
+ override fun onCleared() {
+ graph.close()
+ super.onCleared()
+ }
+}
+
+class AppViewModel(
+ private val environment: PlatformEnvironment,
+ private val repository: CoreGateway,
+ preferencesRepository: PreferencesRepository,
+ private val messages: UiMessageController,
+) : ViewModel() {
+ private val _state = MutableStateFlow(AppState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
+ viewModelScope.launch {
+ repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
+ }
+ viewModelScope.launch {
+ preferencesRepository.preferences.collect { preferences ->
+ _state.update { it.copy(themeMode = preferences.themeMode) }
+ }
+ }
+ }
+
+ fun selectDestination(destination: AppDestination) {
+ _state.update { it.copy(destination = destination) }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt
new file mode 100644
index 0000000..65da73f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt
@@ -0,0 +1,58 @@
+package com.vnidrop.app.feature.approvals
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.components.PrimaryButton
+import com.vnidrop.app.ui.components.SecondaryButton
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.approval_connection_request
+import vnidrop.shared.generated.resources.approval_pending_count
+import vnidrop.shared.generated.resources.button_approve
+import vnidrop.shared.generated.resources.button_refuse
+
+@Composable
+fun ApprovalBannerHost(
+ state: ApprovalState,
+ onAccept: (String) -> Unit,
+ onRefuse: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val request = state.current ?: return
+ val busy = request.id in state.respondingIds
+ val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
+ val colors = LocalVniDropColors.current
+ Card(
+ modifier = modifier.widthIn(max = 640.dp).fillMaxWidth().padding(12.dp),
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(containerColor = colors.backgroundDialog),
+ border = BorderStroke(1.dp, colors.warningDefault),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(stringResource(Res.string.approval_connection_request), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text("$receiver wants to receive ${request.transferName}.")
+ if (state.pending.size > 1) {
+ Text(stringResource(Res.string.approval_pending_count, state.pending.size), style = MaterialTheme.typography.bodySmall)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRefuse(request.id) }, enabled = !busy)
+ PrimaryButton(stringResource(Res.string.button_approve), onClick = { onAccept(request.id) }, enabled = !busy)
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
new file mode 100644
index 0000000..af58250
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
@@ -0,0 +1,165 @@
+package com.vnidrop.app.feature.approvals
+
+import com.vnidrop.app.core.CoreGateway
+import com.vnidrop.app.core.CoreSignal
+import com.vnidrop.app.core.ReceiverRequestModel
+import com.vnidrop.app.notifications.LocalNotification
+import com.vnidrop.app.notifications.LocalNotificationService
+import com.vnidrop.app.notifications.NotificationPermission
+import com.vnidrop.app.platform.AppVisibility
+import com.vnidrop.app.preferences.PreferencesRepository
+import com.vnidrop.app.ui.feedback.UiMessageController
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class PendingApproval(
+ val id: String,
+ val transferId: ULong,
+ val transferName: String,
+ val receiverName: String?,
+ val receiverDeviceName: String?,
+ val requestedAt: Long,
+)
+
+data class ApprovalState(
+ val pending: List = emptyList(),
+ val respondingIds: Set = emptySet(),
+) {
+ val current: PendingApproval?
+ get() = pending.firstOrNull()
+}
+
+class ApprovalCoordinator(
+ private val repository: CoreGateway,
+ private val preferencesRepository: PreferencesRepository,
+ private val notifications: LocalNotificationService,
+ private val visibility: AppVisibility,
+ private val messages: UiMessageController,
+ private val scope: CoroutineScope,
+) {
+ private val _state = MutableStateFlow(ApprovalState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val publishedNotificationIds = mutableSetOf()
+
+ init {
+ scope.launch {
+ repository.signals.collect { signal ->
+ when (signal) {
+ is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
+ }
+ }
+ }
+ scope.launch {
+ repository.state.collectLatest { core ->
+ if (core.isInitialized) {
+ core.transfers
+ .filter { it.direction == "send" && it.status == "sharing" }
+ .forEach { refresh(it.transferId) }
+ }
+ }
+ }
+ scope.launch {
+ combine(
+ preferencesRepository.preferences,
+ visibility.isForeground,
+ state,
+ notifications.permission,
+ ) { preferences, foreground, approvalState, permission ->
+ NotificationContext(preferences.notificationsEnabled, foreground, approvalState.pending, permission)
+ }.collect { context ->
+ synchronizeNotifications(context)
+ }
+ }
+ }
+
+ fun accept(requestId: String) = respond(requestId, accepted = true)
+
+ fun refuse(requestId: String) = respond(requestId, accepted = false)
+
+ private fun respond(requestId: String, accepted: Boolean) {
+ if (!_state.value.respondingIds.addable(requestId)) return
+ _state.update { it.copy(respondingIds = it.respondingIds + requestId) }
+ scope.launch {
+ val request = _state.value.pending.firstOrNull { it.id == requestId }
+ val result = repository.respondReceiverRequest(
+ requestId = requestId,
+ accepted = accepted,
+ reason = if (accepted) null else "sender-refused",
+ )
+ _state.update { it.copy(respondingIds = it.respondingIds - requestId) }
+ result.fold(
+ onSuccess = {
+ if (request != null) refresh(request.transferId)
+ },
+ onFailure = messages::error,
+ )
+ }
+ }
+
+ private suspend fun refresh(transferId: ULong) {
+ repository.receiverRequests(transferId).fold(
+ onSuccess = { requests ->
+ val refreshed = requests.filter { it.status == "requested" }.map(ReceiverRequestModel::toPending)
+ val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
+ removed.forEach { id ->
+ notifications.cancel(notificationId(id))
+ publishedNotificationIds.remove(id)
+ }
+ _state.update { current ->
+ current.copy(
+ pending = (current.pending.filterNot { it.transferId == transferId } + refreshed)
+ .distinctBy(PendingApproval::id)
+ .sortedBy(PendingApproval::requestedAt),
+ )
+ }
+ },
+ onFailure = messages::error,
+ )
+ }
+
+ private suspend fun synchronizeNotifications(context: NotificationContext) {
+ if (context.foreground || !context.enabled || context.permission != NotificationPermission.Granted) {
+ notifications.cancelAll()
+ return
+ }
+ context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request ->
+ val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
+ notifications.publish(
+ LocalNotification(
+ id = notificationId(request.id),
+ title = "Connection request",
+ body = "$receiver wants to receive ${request.transferName}",
+ ),
+ ).onSuccess {
+ publishedNotificationIds += request.id
+ }.onFailure(messages::error)
+ }
+ }
+}
+
+private data class NotificationContext(
+ val enabled: Boolean,
+ val foreground: Boolean,
+ val pending: List,
+ val permission: NotificationPermission,
+)
+
+private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
+ id = id,
+ transferId = transferId,
+ transferName = transferName,
+ receiverName = receiverName,
+ receiverDeviceName = receiverDeviceName,
+ requestedAt = requestedAt,
+)
+
+private fun Set.addable(value: String): Boolean = value !in this
+
+private fun notificationId(requestId: String): String = "approval-$requestId"
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt
new file mode 100644
index 0000000..c6f2df9
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt
@@ -0,0 +1,19 @@
+package com.vnidrop.app.feature.receive
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+
+@Composable
+fun ReceiveRoute(viewModel: ReceiveViewModel) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val coreState by viewModel.coreState.collectAsStateWithLifecycle()
+ ReceiveScreen(
+ coreState = coreState,
+ state = state,
+ onTicketChanged = viewModel::setTicket,
+ onReceiverNameChanged = viewModel::setReceiverName,
+ onInspectTicket = viewModel::inspectTicket,
+ onReceive = viewModel::receive,
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
new file mode 100644
index 0000000..cb8dc74
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
@@ -0,0 +1,76 @@
+package com.vnidrop.app.feature.receive
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.core.CoreState
+import com.vnidrop.app.core.FolderAccessStatus
+import com.vnidrop.app.ui.components.AppCard
+import com.vnidrop.app.ui.components.Field
+import com.vnidrop.app.ui.components.MetadataRow
+import com.vnidrop.app.ui.components.PrimaryButton
+import com.vnidrop.app.ui.components.SecondaryButton
+import com.vnidrop.app.ui.screens.ProgressSection
+import com.vnidrop.app.ui.screens.ScreenHeader
+import com.vnidrop.app.ui.screens.TicketInspectionCard
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_inspect_ticket
+import vnidrop.shared.generated.resources.button_receive
+import vnidrop.shared.generated.resources.button_receiving
+import vnidrop.shared.generated.resources.field_output_directory
+import vnidrop.shared.generated.resources.field_receiver_name
+import vnidrop.shared.generated.resources.field_ticket
+import vnidrop.shared.generated.resources.folder_status_permission_required
+import vnidrop.shared.generated.resources.folder_status_unavailable
+import vnidrop.shared.generated.resources.folder_status_writable
+import vnidrop.shared.generated.resources.metadata_status
+import vnidrop.shared.generated.resources.receive_subtitle
+import vnidrop.shared.generated.resources.receive_title
+import vnidrop.shared.generated.resources.ticket_card_title
+
+@Composable
+fun ReceiveScreen(
+ coreState: CoreState,
+ state: ReceiveState,
+ onTicketChanged: (String) -> Unit,
+ onReceiverNameChanged: (String) -> Unit,
+ onInspectTicket: () -> Unit,
+ onReceive: () -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
+ AppCard(title = stringResource(Res.string.ticket_card_title)) {
+ Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4)
+ MetadataRow(
+ stringResource(Res.string.field_output_directory),
+ state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory,
+ )
+ MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName())
+ Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ SecondaryButton(
+ stringResource(Res.string.button_inspect_ticket),
+ onClick = onInspectTicket,
+ enabled = state.canInspect(coreState.isInitialized),
+ )
+ PrimaryButton(
+ if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
+ onClick = onReceive,
+ enabled = state.canReceive(coreState.isInitialized),
+ )
+ }
+ }
+ coreState.lastInspection?.let { TicketInspectionCard(it) }
+ ProgressSection(coreState)
+ }
+}
+
+@Composable
+private fun FolderAccessStatus.displayName(): String = when (this) {
+ FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
+ FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
+ FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
new file mode 100644
index 0000000..588e3cb
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
@@ -0,0 +1,91 @@
+package com.vnidrop.app.feature.receive
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vnidrop.app.core.CoreGateway
+import com.vnidrop.app.core.FileSystemService
+import com.vnidrop.app.core.FolderAccessStatus
+import com.vnidrop.app.core.ReceiveFolder
+import com.vnidrop.app.core.ReceiveFolderKind
+import com.vnidrop.app.preferences.PreferencesRepository
+import com.vnidrop.app.ui.feedback.UiMessageController
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class ReceiveState(
+ val ticket: String = "",
+ val outputDirectory: String = "",
+ val receiverName: String = "",
+ val receiveFolder: ReceiveFolder? = null,
+ val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
+ val isReceiving: Boolean = false,
+) {
+ fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank()
+ fun canReceive(coreInitialized: Boolean): Boolean =
+ coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() &&
+ folderAccessStatus == FolderAccessStatus.Writable && !isReceiving
+}
+
+class ReceiveViewModel(
+ private val repository: CoreGateway,
+ private val fileSystemService: FileSystemService,
+ preferencesRepository: PreferencesRepository,
+ private val messages: UiMessageController,
+) : ViewModel() {
+ private val _state = MutableStateFlow(ReceiveState())
+ val state: StateFlow = _state.asStateFlow()
+ val coreState = repository.state
+
+ init {
+ viewModelScope.launch {
+ preferencesRepository.preferences.collect { preferences ->
+ val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder)
+ _state.update { current ->
+ current.copy(
+ receiverName = current.receiverName.ifBlank { preferences.username },
+ receiveFolder = preferences.receiveFolder,
+ outputDirectory = preferences.receiveFolder.value,
+ folderAccessStatus = status,
+ )
+ }
+ }
+ }
+ }
+
+ fun setTicket(value: String) = _state.update { it.copy(ticket = value) }
+ fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) }
+ fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) }
+
+ fun inspectTicket() {
+ val current = state.value
+ if (!current.canInspect(coreState.value.isInitialized)) return
+ viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) }
+ }
+
+ fun receive() {
+ val current = state.value
+ val folder = current.receiveFolder ?: return
+ if (!current.canReceive(coreState.value.isInitialized)) return
+ viewModelScope.launch {
+ _state.update { it.copy(isReceiving = true) }
+ try {
+ val outputSink = fileSystemService.createReceiveOutputSink(folder)
+ val result = when {
+ outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
+ folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
+ current.ticket,
+ folder.value,
+ current.receiverName,
+ )
+ else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName)
+ }
+ result.onFailure(messages::error)
+ } finally {
+ _state.update { it.copy(isReceiving = false) }
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
new file mode 100644
index 0000000..8d3537e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
@@ -0,0 +1,45 @@
+package com.vnidrop.app.feature.send
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vnidrop.app.core.rememberShareFilePicker
+import com.vnidrop.app.ui.state.WindowClass
+
+@Composable
+fun SendRoute(
+ viewModel: SendViewModel,
+ windowClass: WindowClass,
+ onUseTicket: (String) -> Unit,
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val coreState by viewModel.coreState.collectAsStateWithLifecycle()
+ val clipboard = LocalClipboardManager.current
+ val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
+
+ LaunchedEffect(viewModel) {
+ viewModel.effectFlow.collect { effect ->
+ when (effect) {
+ SendEffect.OpenFilePicker -> picker.pickFile()
+ is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
+ is SendEffect.UseTicket -> onUseTicket(effect.ticket)
+ }
+ }
+ }
+
+ SendScreen(
+ coreState = coreState,
+ state = state,
+ windowClass = windowClass,
+ onSelectFile = viewModel::selectFile,
+ onClearFile = viewModel::clearSelectedSource,
+ onTransferNameChanged = viewModel::setTransferName,
+ onSenderNameChanged = viewModel::setSenderName,
+ onCreateShare = viewModel::createShare,
+ onCopyTicket = viewModel::copyTicket,
+ onUseTicket = viewModel::useTicket,
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
new file mode 100644
index 0000000..12f4334
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
@@ -0,0 +1,125 @@
+package com.vnidrop.app.feature.send
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.core.CoreState
+import com.vnidrop.app.core.Share
+import com.vnidrop.app.ui.components.AppCard
+import com.vnidrop.app.ui.components.Field
+import com.vnidrop.app.ui.components.MetadataRow
+import com.vnidrop.app.ui.components.PillTone
+import com.vnidrop.app.ui.components.PrimaryButton
+import com.vnidrop.app.ui.components.SecondaryButton
+import com.vnidrop.app.ui.components.StatusPill
+import com.vnidrop.app.ui.screens.EmptyText
+import com.vnidrop.app.ui.screens.ProgressSection
+import com.vnidrop.app.ui.screens.ScreenHeader
+import com.vnidrop.app.ui.screens.SendEmptyState
+import com.vnidrop.app.ui.screens.TicketText
+import com.vnidrop.app.ui.screens.shouldShowEmptyState
+import com.vnidrop.app.ui.state.WindowClass
+import com.vnidrop.app.ui.state.formatBytes
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_clear
+import vnidrop.shared.generated.resources.button_copy
+import vnidrop.shared.generated.resources.button_create_share
+import vnidrop.shared.generated.resources.button_creating_share
+import vnidrop.shared.generated.resources.button_select_file
+import vnidrop.shared.generated.resources.button_use_locally
+import vnidrop.shared.generated.resources.field_sender_name
+import vnidrop.shared.generated.resources.field_transfer_name
+import vnidrop.shared.generated.resources.metadata_name
+import vnidrop.shared.generated.resources.metadata_size
+import vnidrop.shared.generated.resources.metadata_source
+import vnidrop.shared.generated.resources.metadata_transfer
+import vnidrop.shared.generated.resources.send_source_empty
+import vnidrop.shared.generated.resources.send_subtitle
+import vnidrop.shared.generated.resources.send_title
+import vnidrop.shared.generated.resources.share_details_title
+import vnidrop.shared.generated.resources.source_title
+import vnidrop.shared.generated.resources.transfer_details_title
+
+@Composable
+fun SendScreen(
+ coreState: CoreState,
+ state: SendState,
+ windowClass: WindowClass,
+ onSelectFile: () -> Unit,
+ onClearFile: () -> Unit,
+ onTransferNameChanged: (String) -> Unit,
+ onSenderNameChanged: (String) -> Unit,
+ onCreateShare: () -> Unit,
+ onCopyTicket: (String) -> Unit,
+ onUseTicket: (String) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ if (!state.shouldShowEmptyState(coreState)) {
+ ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
+ }
+ if (state.shouldShowEmptyState(coreState)) {
+ SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile)
+ } else {
+ SourceCard(state, onSelectFile, onClearFile)
+ DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
+ coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
+ ProgressSection(coreState)
+ }
+ }
+}
+
+@Composable
+private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) {
+ AppCard(title = stringResource(Res.string.source_title)) {
+ if (state.selectedSource.isBlank()) {
+ EmptyText(stringResource(Res.string.send_source_empty))
+ } else {
+ MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') })
+ MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
+ SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource)
+ }
+ }
+}
+
+@Composable
+private fun DetailsCard(
+ coreState: CoreState,
+ state: SendState,
+ onTransferNameChanged: (String) -> Unit,
+ onSenderNameChanged: (String) -> Unit,
+ onCreateShare: () -> Unit,
+) {
+ AppCard(title = stringResource(Res.string.transfer_details_title)) {
+ Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
+ Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
+ PrimaryButton(
+ text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
+ onClick = onCreateShare,
+ enabled = state.canCreateShare(coreState.isInitialized),
+ )
+ }
+}
+
+@Composable
+private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) {
+ AppCard(
+ title = stringResource(Res.string.share_details_title),
+ trailing = {
+ StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
+ },
+ ) {
+ MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
+ MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
+ TicketText(share.ticket)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
+ SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) })
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
new file mode 100644
index 0000000..ef6e5ad
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
@@ -0,0 +1,108 @@
+package com.vnidrop.app.feature.send
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vnidrop.app.core.CoreGateway
+import com.vnidrop.app.core.FileSystemService
+import com.vnidrop.app.core.PickedShareFile
+import com.vnidrop.app.preferences.PreferencesRepository
+import com.vnidrop.app.ui.feedback.UiMessageController
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val DefaultTransferName = "VniDrop transfer"
+
+data class SendState(
+ val selectedSource: String = "",
+ val selectedDisplayName: String = "",
+ val transferName: String = DefaultTransferName,
+ val senderName: String = "",
+ val isSharing: Boolean = false,
+) {
+ val hasSelectedSource: Boolean
+ get() = selectedSource.isNotBlank()
+
+ fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing
+}
+
+sealed interface SendEffect {
+ data object OpenFilePicker : SendEffect
+ data class CopyTicket(val ticket: String) : SendEffect
+ data class UseTicket(val ticket: String) : SendEffect
+}
+
+class SendViewModel(
+ private val repository: CoreGateway,
+ private val fileSystemService: FileSystemService,
+ preferencesRepository: PreferencesRepository,
+ private val messages: UiMessageController,
+) : ViewModel() {
+ private val _state = MutableStateFlow(SendState())
+ val state: StateFlow = _state.asStateFlow()
+ val coreState = repository.state
+
+ private val effects = Channel(Channel.BUFFERED)
+ val effectFlow = effects.receiveAsFlow()
+ private var selectedFile: PickedShareFile? = null
+
+ init {
+ viewModelScope.launch {
+ preferencesRepository.preferences.collect { preferences ->
+ _state.update { current ->
+ if (current.senderName.isBlank()) current.copy(senderName = preferences.username) else current
+ }
+ }
+ }
+ }
+
+ fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
+
+ fun onFilePicked(file: PickedShareFile) {
+ selectedFile = file
+ _state.update {
+ it.copy(
+ selectedSource = file.value,
+ selectedDisplayName = file.displayName,
+ transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName,
+ )
+ }
+ }
+
+ fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
+
+ fun clearSelectedSource() {
+ selectedFile = null
+ _state.update { it.copy(selectedSource = "", selectedDisplayName = "") }
+ }
+
+ fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
+ fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
+ fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
+ fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket))
+
+ fun createShare() {
+ val current = state.value
+ if (!current.canCreateShare(coreState.value.isInitialized)) return
+ viewModelScope.launch {
+ _state.update { it.copy(isSharing = true) }
+ try {
+ val result = selectedFile?.let { file ->
+ fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName)
+ } ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName)
+ result.onFailure(messages::error)
+ } finally {
+ _state.update { it.copy(isSharing = false) }
+ }
+ }
+ }
+
+ private fun sendEffect(effect: SendEffect) {
+ viewModelScope.launch { effects.send(effect) }
+ }
+
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt
new file mode 100644
index 0000000..ee7dabd
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt
@@ -0,0 +1,55 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.about_bug_report
+import vnidrop.shared.generated.resources.about_privacy
+import vnidrop.shared.generated.resources.about_title
+import vnidrop.shared.generated.resources.battery_level_title
+import vnidrop.shared.generated.resources.device_model_title
+import vnidrop.shared.generated.resources.device_name_title
+import vnidrop.shared.generated.resources.network_title
+import vnidrop.shared.generated.resources.os_version_title
+import vnidrop.shared.generated.resources.value_unavailable
+import vnidrop.shared.generated.resources.version_title
+
+@Composable
+internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) {
+ val unavailable = stringResource(Res.string.value_unavailable)
+ val info = state.deviceInfo
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack)
+ SettingsGroup {
+ SettingsRow(
+ icon = SettingsIcons.Document,
+ title = stringResource(Res.string.about_privacy),
+ iconTone = SettingsIconTone.Neutral,
+ )
+ SettingsDivider()
+ SettingsRow(
+ icon = SettingsIcons.Bug,
+ title = stringResource(Res.string.about_bug_report),
+ iconTone = SettingsIconTone.Neutral,
+ )
+ }
+ SettingsGroup {
+ InfoItem(stringResource(Res.string.version_title), state.appVersion)
+ SettingsDivider(startPadding = 16.dp)
+ InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
+ SettingsDivider(startPadding = 16.dp)
+ InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
+ SettingsDivider(startPadding = 16.dp)
+ InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
+ SettingsDivider(startPadding = 16.dp)
+ InfoItem(stringResource(Res.string.network_title), info?.network.orUnavailable(unavailable))
+ SettingsDivider(startPadding = 16.dp)
+ InfoItem(stringResource(Res.string.battery_level_title), info?.batteryLevel.orUnavailable(unavailable))
+ }
+ }
+}
+
+private fun String?.orUnavailable(fallback: String): String = this?.takeIf(String::isNotBlank) ?: fallback
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt
new file mode 100644
index 0000000..9349518
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt
@@ -0,0 +1,66 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.ThemeMode
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.appearance_dark_mode
+import vnidrop.shared.generated.resources.appearance_light_mode
+import vnidrop.shared.generated.resources.appearance_system_mode
+import vnidrop.shared.generated.resources.appearance_title
+
+@Composable
+internal fun AppearanceSettings(
+ mode: ThemeMode,
+ onModeChanged: (ThemeMode) -> Unit,
+ onBack: () -> Unit,
+ showBack: Boolean,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SettingsTopBar(stringResource(Res.string.appearance_title), onBack, showBack)
+ SettingsGroup {
+ ThemeSettingsRow(SettingsIcons.Device, stringResource(Res.string.appearance_system_mode), mode == ThemeMode.System) {
+ onModeChanged(ThemeMode.System)
+ }
+ SettingsDivider()
+ ThemeSettingsRow(SettingsIcons.Moon, stringResource(Res.string.appearance_dark_mode), mode == ThemeMode.Dark) {
+ onModeChanged(ThemeMode.Dark)
+ }
+ SettingsDivider()
+ ThemeSettingsRow(SettingsIcons.Sun, stringResource(Res.string.appearance_light_mode), mode == ThemeMode.Light) {
+ onModeChanged(ThemeMode.Light)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ThemeSettingsRow(icon: ImageVector, title: String, selected: Boolean, onClick: () -> Unit) {
+ SettingsRow(
+ icon = icon,
+ title = title,
+ selected = selected,
+ onClick = onClick,
+ showsDisclosure = false,
+ trailing = if (selected) {
+ {
+ Icon(
+ SettingsIcons.Check,
+ contentDescription = null,
+ tint = LocalVniDropColors.current.brandLink,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ } else {
+ null
+ },
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt
new file mode 100644
index 0000000..8916d91
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt
@@ -0,0 +1,65 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.notifications.NotificationPermission
+import com.vnidrop.app.ui.components.SecondaryButton
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_open_settings
+import vnidrop.shared.generated.resources.notifications_description
+import vnidrop.shared.generated.resources.notifications_local_title
+import vnidrop.shared.generated.resources.notifications_permission_denied
+import vnidrop.shared.generated.resources.notifications_unsupported
+import vnidrop.shared.generated.resources.notifications_title
+
+@Composable
+internal fun NotificationSettings(
+ state: SettingsState,
+ onEnabledChanged: (Boolean) -> Unit,
+ onOpenSettings: () -> Unit,
+ onBack: () -> Unit,
+ showBack: Boolean,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SettingsTopBar(stringResource(Res.string.notifications_title), onBack, showBack)
+ SettingsGroup {
+ SettingsToggleRow(
+ icon = SettingsIcons.Bell,
+ title = stringResource(Res.string.notifications_local_title),
+ description = stringResource(Res.string.notifications_description),
+ checked = state.notificationsEnabled,
+ enabled = state.notificationPermission != NotificationPermission.Unsupported,
+ onCheckedChange = onEnabledChanged,
+ )
+ }
+ when (state.notificationPermission) {
+ NotificationPermission.Denied -> NotificationPermissionHelp(onOpenSettings)
+ NotificationPermission.Unsupported -> NotificationSupportText(stringResource(Res.string.notifications_unsupported))
+ else -> Unit
+ }
+ }
+}
+
+@Composable
+private fun NotificationPermissionHelp(onOpenSettings: () -> Unit) {
+ Column(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ NotificationSupportText(stringResource(Res.string.notifications_permission_denied))
+ SecondaryButton(stringResource(Res.string.button_open_settings), onClick = onOpenSettings)
+ }
+}
+
+@Composable
+private fun NotificationSupportText(text: String) {
+ Text(text = text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt
new file mode 100644
index 0000000..c7f720a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt
@@ -0,0 +1,66 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.core.FolderAccessStatus
+import com.vnidrop.app.ui.components.Field
+import com.vnidrop.app.ui.components.PrimaryButton
+import com.vnidrop.app.ui.components.SecondaryButton
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_choose_folder
+import vnidrop.shared.generated.resources.button_reset_default
+import vnidrop.shared.generated.resources.field_username
+import vnidrop.shared.generated.resources.folder_status_permission_required
+import vnidrop.shared.generated.resources.folder_status_unavailable
+import vnidrop.shared.generated.resources.folder_status_validating
+import vnidrop.shared.generated.resources.folder_status_writable
+import vnidrop.shared.generated.resources.preferences_receive_folder_title
+import vnidrop.shared.generated.resources.preferences_title
+
+@Composable
+internal fun PreferencesSettings(
+ state: SettingsState,
+ onUsernameChanged: (String) -> Unit,
+ onChooseFolder: () -> Unit,
+ onResetFolder: () -> Unit,
+ onBack: () -> Unit,
+ showBack: Boolean,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SettingsTopBar(stringResource(Res.string.preferences_title), onBack, showBack)
+ Field(state.username, onUsernameChanged, stringResource(Res.string.field_username))
+ SettingsGroup {
+ SettingsRow(
+ icon = SettingsIcons.Folder,
+ title = stringResource(Res.string.preferences_receive_folder_title),
+ value = state.receiveFolder?.displayName?.ifBlank { state.receiveFolder.value },
+ iconTone = SettingsIconTone.Neutral,
+ )
+ SettingsDivider()
+ SettingsRow(
+ icon = SettingsIcons.Check,
+ title = state.folderStatusLabel(),
+ iconTone = SettingsIconTone.Neutral,
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
+ SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
+ }
+ }
+}
+
+@Composable
+private fun SettingsState.folderStatusLabel(): String = if (isValidatingFolder) {
+ stringResource(Res.string.folder_status_validating)
+} else {
+ when (folderAccessStatus) {
+ FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
+ FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
+ FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt
new file mode 100644
index 0000000..9b8b838
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt
@@ -0,0 +1,216 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_back
+
+internal enum class SettingsIconTone { Brand, Neutral }
+
+@Composable
+internal fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (showBack) {
+ IconButton(onClick = onBack) {
+ Icon(
+ SettingsIcons.Back,
+ contentDescription = stringResource(Res.string.button_back),
+ tint = LocalVniDropColors.current.foregroundDefault,
+ )
+ }
+ }
+ Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ }
+}
+
+@Composable
+internal fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) {
+ val colors = LocalVniDropColors.current
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
+ border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
+ ) {
+ Column(content = content)
+ }
+}
+
+@Composable
+internal fun SettingsRow(
+ icon: ImageVector,
+ title: String,
+ value: String? = null,
+ subtitle: String? = null,
+ selected: Boolean = false,
+ iconTone: SettingsIconTone = SettingsIconTone.Brand,
+ onClick: (() -> Unit)? = null,
+ showsDisclosure: Boolean = onClick != null,
+ trailing: @Composable (() -> Unit)? = null,
+) {
+ val colors = LocalVniDropColors.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .background(if (selected) colors.backgroundSelection else Color.Transparent)
+ .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SettingsLeadingIcon(icon, iconTone)
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ subtitle?.let {
+ Text(
+ it,
+ color = colors.foregroundLighter,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ value?.let {
+ Text(
+ it,
+ modifier = Modifier.padding(start = 12.dp),
+ color = colors.foregroundLighter,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ when {
+ trailing != null -> {
+ Spacer(Modifier.width(10.dp))
+ trailing()
+ }
+ onClick != null && showsDisclosure -> {
+ Spacer(Modifier.width(8.dp))
+ Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
+ }
+ }
+ }
+}
+
+@Composable
+internal fun SettingsToggleRow(
+ icon: ImageVector,
+ title: String,
+ description: String,
+ checked: Boolean,
+ enabled: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ val colors = LocalVniDropColors.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(if (enabled) 1f else 0.55f)
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SettingsLeadingIcon(icon, SettingsIconTone.Brand)
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
+ Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall)
+ }
+ Spacer(Modifier.width(16.dp))
+ Switch(
+ checked = checked,
+ onCheckedChange = null,
+ enabled = enabled,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Color.White,
+ checkedTrackColor = colors.brandButton,
+ ),
+ )
+ }
+}
+
+@Composable
+internal fun SettingsDivider(startPadding: Dp = 60.dp) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = startPadding),
+ color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.72f),
+ )
+}
+
+@Composable
+internal fun InfoItem(title: String, value: String) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ Text(title, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelLarge)
+ Text(value, style = MaterialTheme.typography.bodyLarge)
+ }
+}
+
+@Composable
+private fun SettingsLeadingIcon(icon: ImageVector, tone: SettingsIconTone) {
+ val colors = LocalVniDropColors.current
+ val foreground = if (tone == SettingsIconTone.Brand) colors.brandLink else colors.foregroundLight
+ val background = if (tone == SettingsIconTone.Brand) colors.brandLink.copy(alpha = 0.13f) else colors.backgroundSurface300
+ Box(
+ modifier = Modifier.size(34.dp).background(background, RoundedCornerShape(10.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(19.dp))
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt
new file mode 100644
index 0000000..9b9c885
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt
@@ -0,0 +1,143 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathBuilder
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+internal object SettingsIcons {
+ val ChevronRight = lineIcon("ChevronRight") {
+ moveTo(9f, 18f)
+ lineTo(15f, 12f)
+ lineTo(9f, 6f)
+ }
+ val Back = lineIcon("Back") {
+ moveTo(19f, 12f)
+ lineTo(5f, 12f)
+ moveTo(12f, 19f)
+ lineTo(5f, 12f)
+ lineTo(12f, 5f)
+ }
+ val Check = lineIcon("Check") {
+ moveTo(20f, 6f)
+ lineTo(9f, 17f)
+ lineTo(4f, 12f)
+ }
+ val Sun = lineIcon("Sun") {
+ moveTo(12f, 4f)
+ lineTo(12f, 2f)
+ moveTo(12f, 22f)
+ lineTo(12f, 20f)
+ moveTo(4.93f, 4.93f)
+ lineTo(6.34f, 6.34f)
+ moveTo(17.66f, 17.66f)
+ lineTo(19.07f, 19.07f)
+ moveTo(2f, 12f)
+ lineTo(4f, 12f)
+ moveTo(20f, 12f)
+ lineTo(22f, 12f)
+ moveTo(4.93f, 19.07f)
+ lineTo(6.34f, 17.66f)
+ moveTo(17.66f, 6.34f)
+ lineTo(19.07f, 4.93f)
+ moveTo(16f, 12f)
+ arcTo(4f, 4f, 0f, true, true, 8f, 12f)
+ arcTo(4f, 4f, 0f, true, true, 16f, 12f)
+ }
+ val Moon = lineIcon("Moon") {
+ moveTo(21f, 12.79f)
+ arcTo(9f, 9f, 0f, true, true, 11.21f, 3f)
+ arcTo(7f, 7f, 0f, false, false, 21f, 12.79f)
+ }
+ val Device = lineIcon("Device") {
+ roundRect(7f, 2f, 10f, 20f, 2.5f)
+ moveTo(11f, 18f)
+ lineTo(13f, 18f)
+ }
+ val Folder = lineIcon("Folder") {
+ moveTo(3f, 7f)
+ lineTo(9f, 7f)
+ lineTo(11f, 9f)
+ lineTo(21f, 9f)
+ lineTo(21f, 19f)
+ lineTo(3f, 19f)
+ close()
+ }
+ val Info = lineIcon("Info") {
+ moveTo(12f, 16f)
+ lineTo(12f, 12f)
+ moveTo(12f, 8f)
+ lineTo(12.01f, 8f)
+ moveTo(21f, 12f)
+ arcTo(9f, 9f, 0f, true, true, 3f, 12f)
+ arcTo(9f, 9f, 0f, true, true, 21f, 12f)
+ }
+ val Bell = lineIcon("Bell") {
+ moveTo(18f, 8f)
+ arcTo(6f, 6f, 0f, false, false, 6f, 8f)
+ lineTo(6f, 13f)
+ lineTo(4f, 17f)
+ lineTo(20f, 17f)
+ lineTo(18f, 13f)
+ close()
+ moveTo(10f, 21f)
+ arcTo(2f, 2f, 0f, false, false, 14f, 21f)
+ }
+ val Document = lineIcon("Document") {
+ moveTo(14f, 2f)
+ lineTo(6f, 2f)
+ arcTo(2f, 2f, 0f, false, false, 4f, 4f)
+ lineTo(4f, 20f)
+ arcTo(2f, 2f, 0f, false, false, 6f, 22f)
+ lineTo(18f, 22f)
+ arcTo(2f, 2f, 0f, false, false, 20f, 20f)
+ lineTo(20f, 8f)
+ lineTo(14f, 2f)
+ moveTo(14f, 2f)
+ lineTo(14f, 8f)
+ lineTo(20f, 8f)
+ }
+ val Bug = lineIcon("Bug") {
+ roundRect(7f, 6f, 10f, 14f, 5f)
+ moveTo(3f, 10f)
+ lineTo(7f, 10f)
+ moveTo(17f, 10f)
+ lineTo(21f, 10f)
+ moveTo(3f, 16f)
+ lineTo(7f, 16f)
+ moveTo(17f, 16f)
+ lineTo(21f, 16f)
+ moveTo(12f, 6f)
+ lineTo(12f, 20f)
+ }
+}
+
+private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
+ ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
+ path(
+ fill = SolidColor(Color.Transparent),
+ stroke = SolidColor(Color.Black),
+ strokeLineWidth = 2f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round,
+ pathFillType = PathFillType.NonZero,
+ pathBuilder = block,
+ )
+ }.build()
+
+private fun PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) {
+ moveTo(x + radius, y)
+ lineTo(x + width - radius, y)
+ arcTo(radius, radius, 0f, false, true, x + width, y + radius)
+ lineTo(x + width, y + height - radius)
+ arcTo(radius, radius, 0f, false, true, x + width - radius, y + height)
+ lineTo(x + radius, y + height)
+ arcTo(radius, radius, 0f, false, true, x, y + height - radius)
+ lineTo(x, y + radius)
+ arcTo(radius, radius, 0f, false, true, x + radius, y)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
new file mode 100644
index 0000000..583b7e8
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
@@ -0,0 +1,75 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.ThemeMode
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.about_title
+import vnidrop.shared.generated.resources.appearance_dark_mode
+import vnidrop.shared.generated.resources.appearance_light_mode
+import vnidrop.shared.generated.resources.appearance_system_mode
+import vnidrop.shared.generated.resources.appearance_title
+import vnidrop.shared.generated.resources.notifications_title
+import vnidrop.shared.generated.resources.preferences_title
+import vnidrop.shared.generated.resources.settings_title
+
+@Composable
+internal fun SettingsOverview(
+ state: SettingsState,
+ onSectionSelected: (SettingsSection) -> Unit,
+ largeTitle: Boolean,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ stringResource(Res.string.settings_title),
+ style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ SettingsGroup {
+ SettingsRow(
+ icon = SettingsIcons.Device,
+ title = stringResource(Res.string.preferences_title),
+ value = state.username,
+ selected = state.selectedSection == SettingsSection.Preferences,
+ onClick = { onSectionSelected(SettingsSection.Preferences) },
+ )
+ SettingsDivider()
+ SettingsRow(
+ icon = SettingsIcons.Sun,
+ title = stringResource(Res.string.appearance_title),
+ value = themeModeLabel(state.themeMode),
+ selected = state.selectedSection == SettingsSection.Appearance,
+ onClick = { onSectionSelected(SettingsSection.Appearance) },
+ )
+ }
+ SettingsGroup {
+ SettingsRow(
+ icon = SettingsIcons.Bell,
+ title = stringResource(Res.string.notifications_title),
+ selected = state.selectedSection == SettingsSection.Notifications,
+ onClick = { onSectionSelected(SettingsSection.Notifications) },
+ )
+ SettingsDivider()
+ SettingsRow(
+ icon = SettingsIcons.Info,
+ title = stringResource(Res.string.about_title),
+ selected = state.selectedSection == SettingsSection.About,
+ iconTone = SettingsIconTone.Neutral,
+ onClick = { onSectionSelected(SettingsSection.About) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun themeModeLabel(mode: ThemeMode): String = when (mode) {
+ ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
+ ThemeMode.Light -> stringResource(Res.string.appearance_light_mode)
+ ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt
new file mode 100644
index 0000000..7701ed5
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt
@@ -0,0 +1,32 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vnidrop.app.core.rememberReceiveFolderPicker
+import com.vnidrop.app.ui.state.WindowClass
+
+@Composable
+fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val picker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed)
+ LaunchedEffect(viewModel) {
+ viewModel.effectFlow.collect { effect ->
+ when (effect) {
+ SettingsEffect.OpenReceiveFolderPicker -> picker.pickFolder()
+ }
+ }
+ }
+ SettingsScreen(
+ state = state,
+ windowClass = windowClass,
+ onSectionSelected = viewModel::selectSection,
+ onUsernameChanged = viewModel::setUsername,
+ onThemeModeChanged = viewModel::setThemeMode,
+ onChooseFolder = viewModel::chooseReceiveFolder,
+ onResetFolder = viewModel::resetReceiveFolder,
+ onNotificationsChanged = viewModel::setNotificationsEnabled,
+ onOpenNotificationSettings = viewModel::openNotificationSettings,
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt
new file mode 100644
index 0000000..faf0a3f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt
@@ -0,0 +1,88 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.state.WindowClass
+import com.vnidrop.app.ui.theme.ThemeMode
+
+@Composable
+fun SettingsScreen(
+ state: SettingsState,
+ windowClass: WindowClass,
+ onSectionSelected: (SettingsSection) -> Unit,
+ onUsernameChanged: (String) -> Unit,
+ onThemeModeChanged: (ThemeMode) -> Unit,
+ onChooseFolder: () -> Unit,
+ onResetFolder: () -> Unit,
+ onNotificationsChanged: (Boolean) -> Unit,
+ onOpenNotificationSettings: () -> Unit,
+) {
+ if (windowClass == WindowClass.Desktop) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Column(Modifier.widthIn(min = 280.dp, max = 340.dp)) {
+ SettingsOverview(state, onSectionSelected, largeTitle = false)
+ }
+ Column(Modifier.weight(1f)) {
+ SettingsSectionContent(
+ state = state,
+ section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
+ onBack = {},
+ showBack = false,
+ onUsernameChanged = onUsernameChanged,
+ onThemeModeChanged = onThemeModeChanged,
+ onChooseFolder = onChooseFolder,
+ onResetFolder = onResetFolder,
+ onNotificationsChanged = onNotificationsChanged,
+ onOpenNotificationSettings = onOpenNotificationSettings,
+ )
+ }
+ }
+ } else {
+ when (state.selectedSection) {
+ SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true)
+ else -> SettingsSectionContent(
+ state = state,
+ section = state.selectedSection,
+ onBack = { onSectionSelected(SettingsSection.Overview) },
+ showBack = true,
+ onUsernameChanged = onUsernameChanged,
+ onThemeModeChanged = onThemeModeChanged,
+ onChooseFolder = onChooseFolder,
+ onResetFolder = onResetFolder,
+ onNotificationsChanged = onNotificationsChanged,
+ onOpenNotificationSettings = onOpenNotificationSettings,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsSectionContent(
+ state: SettingsState,
+ section: SettingsSection,
+ onBack: () -> Unit,
+ showBack: Boolean,
+ onUsernameChanged: (String) -> Unit,
+ onThemeModeChanged: (ThemeMode) -> Unit,
+ onChooseFolder: () -> Unit,
+ onResetFolder: () -> Unit,
+ onNotificationsChanged: (Boolean) -> Unit,
+ onOpenNotificationSettings: () -> Unit,
+) {
+ when (section) {
+ SettingsSection.Overview -> Unit
+ SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
+ SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
+ SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
+ SettingsSection.About -> AboutSettings(state, onBack, showBack)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..042473a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
@@ -0,0 +1,206 @@
+package com.vnidrop.app.feature.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vnidrop.app.DeviceInfo
+import com.vnidrop.app.DeviceInfoProvider
+import com.vnidrop.app.PlatformEnvironment
+import com.vnidrop.app.core.FileSystemService
+import com.vnidrop.app.core.FolderAccessStatus
+import com.vnidrop.app.core.ReceiveFolder
+import com.vnidrop.app.notifications.LocalNotificationService
+import com.vnidrop.app.notifications.NotificationPermission
+import com.vnidrop.app.preferences.PreferencesRepository
+import com.vnidrop.app.ui.feedback.UiMessage
+import com.vnidrop.app.ui.feedback.UiMessageController
+import com.vnidrop.app.ui.feedback.UiMessageTone
+import com.vnidrop.app.ui.feedback.UiText
+import com.vnidrop.app.ui.theme.ThemeMode
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.button_open_settings
+import vnidrop.shared.generated.resources.notifications_enabled_message
+import vnidrop.shared.generated.resources.notifications_permission_denied
+import vnidrop.shared.generated.resources.notifications_settings_open_failed
+import vnidrop.shared.generated.resources.notifications_unsupported
+
+enum class SettingsSection {
+ Overview,
+ Preferences,
+ Appearance,
+ Notifications,
+ About,
+}
+
+data class SettingsState(
+ val selectedSection: SettingsSection = SettingsSection.Overview,
+ val username: String = "",
+ val receiveFolder: ReceiveFolder? = null,
+ val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
+ val isValidatingFolder: Boolean = false,
+ val themeMode: ThemeMode = ThemeMode.System,
+ val notificationsEnabled: Boolean = false,
+ val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
+ val deviceInfo: DeviceInfo? = null,
+ val appVersion: String = "",
+ val isLoadingDeviceInfo: Boolean = false,
+)
+
+sealed interface SettingsEffect {
+ data object OpenReceiveFolderPicker : SettingsEffect
+}
+
+class SettingsViewModel(
+ private val environment: PlatformEnvironment,
+ private val deviceInfoProvider: DeviceInfoProvider,
+ private val fileSystemService: FileSystemService,
+ private val preferencesRepository: PreferencesRepository,
+ private val notifications: LocalNotificationService,
+ private val messages: UiMessageController,
+) : ViewModel() {
+ private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
+ val state: StateFlow = _state.asStateFlow()
+
+ private val effects = Channel(Channel.BUFFERED)
+ val effectFlow = effects.receiveAsFlow()
+ private var enableNotificationsAfterSettings = false
+
+ init {
+ viewModelScope.launch {
+ preferencesRepository.preferences.collect { preferences ->
+ _state.update {
+ it.copy(
+ username = preferences.username,
+ receiveFolder = preferences.receiveFolder,
+ themeMode = preferences.themeMode,
+ notificationsEnabled = preferences.notificationsEnabled,
+ )
+ }
+ validateFolder(preferences.receiveFolder)
+ }
+ }
+ refreshNotificationPermission()
+ loadDeviceInfo()
+ }
+
+ fun selectSection(section: SettingsSection) {
+ _state.update { it.copy(selectedSection = section) }
+ if (section == SettingsSection.About) loadDeviceInfo()
+ }
+
+ fun setUsername(value: String) {
+ viewModelScope.launch { preferencesRepository.setUsername(value) }
+ }
+
+ fun setThemeMode(mode: ThemeMode) {
+ viewModelScope.launch { preferencesRepository.setThemeMode(mode) }
+ }
+
+ fun chooseReceiveFolder() {
+ viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) }
+ }
+
+ fun onReceiveFolderPicked(folder: ReceiveFolder) {
+ viewModelScope.launch { preferencesRepository.setReceiveFolder(folder) }
+ }
+
+ fun onReceiveFolderPickFailed(reason: String) = messages.error(IllegalStateException(reason))
+
+ fun resetReceiveFolder() {
+ viewModelScope.launch { preferencesRepository.resetReceiveFolder() }
+ }
+
+ fun setNotificationsEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ if (!enabled) {
+ preferencesRepository.setNotificationsEnabled(false)
+ notifications.cancelAll()
+ return@launch
+ }
+
+ val permission = notifications.requestPermission()
+ _state.update { it.copy(notificationPermission = permission) }
+ if (permission == NotificationPermission.Granted) {
+ enableNotifications()
+ } else {
+ preferencesRepository.setNotificationsEnabled(false)
+ messages.show(
+ UiMessage(
+ UiText.Resource(
+ if (permission == NotificationPermission.Unsupported) Res.string.notifications_unsupported
+ else Res.string.notifications_permission_denied,
+ ),
+ UiMessageTone.Warning,
+ actionLabel = if (permission == NotificationPermission.Denied) {
+ UiText.Resource(Res.string.button_open_settings)
+ } else {
+ null
+ },
+ onAction = if (permission == NotificationPermission.Denied) ::openNotificationSettings else null,
+ ),
+ )
+ }
+ }
+ }
+
+ fun openNotificationSettings() {
+ viewModelScope.launch {
+ enableNotificationsAfterSettings = true
+ notifications.openSettings().onFailure {
+ enableNotificationsAfterSettings = false
+ messages.show(
+ UiMessage(UiText.Resource(Res.string.notifications_settings_open_failed), UiMessageTone.Error),
+ )
+ }
+ }
+ }
+
+ fun refreshNotificationPermission() {
+ viewModelScope.launch {
+ val permission = notifications.refreshPermission()
+ _state.update { it.copy(notificationPermission = permission) }
+ if (enableNotificationsAfterSettings) {
+ enableNotificationsAfterSettings = false
+ if (permission == NotificationPermission.Granted) enableNotifications()
+ } else if (permission != NotificationPermission.Granted && _state.value.notificationsEnabled) {
+ preferencesRepository.setNotificationsEnabled(false)
+ notifications.cancelAll()
+ }
+ }
+ }
+
+ private suspend fun enableNotifications() {
+ preferencesRepository.setNotificationsEnabled(true)
+ messages.show(
+ UiMessage(UiText.Resource(Res.string.notifications_enabled_message), UiMessageTone.Success),
+ )
+ }
+
+ private fun loadDeviceInfo() {
+ if (_state.value.isLoadingDeviceInfo) return
+ viewModelScope.launch {
+ _state.update { it.copy(isLoadingDeviceInfo = true) }
+ try {
+ val info = deviceInfoProvider.load()
+ _state.update { it.copy(deviceInfo = info, isLoadingDeviceInfo = false) }
+ } catch (error: Throwable) {
+ if (error is CancellationException) throw error
+ _state.update { it.copy(isLoadingDeviceInfo = false) }
+ messages.error(error, "Could not load device information.")
+ }
+ }
+ }
+
+ private suspend fun validateFolder(folder: ReceiveFolder) {
+ _state.update { it.copy(isValidatingFolder = true) }
+ val status = fileSystemService.validateReceiveFolder(folder)
+ _state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt
new file mode 100644
index 0000000..228e737
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt
@@ -0,0 +1,27 @@
+package com.vnidrop.app.notifications
+
+import kotlinx.coroutines.flow.StateFlow
+
+enum class NotificationPermission {
+ NotDetermined,
+ Granted,
+ Denied,
+ Unsupported,
+}
+
+data class LocalNotification(
+ val id: String,
+ val title: String,
+ val body: String,
+)
+
+interface LocalNotificationService {
+ val permission: StateFlow
+
+ suspend fun refreshPermission(): NotificationPermission
+ suspend fun requestPermission(): NotificationPermission
+ suspend fun openSettings(): Result
+ suspend fun publish(notification: LocalNotification): Result
+ suspend fun cancel(id: String)
+ suspend fun cancelAll()
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt
new file mode 100644
index 0000000..cb71749
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt
@@ -0,0 +1,14 @@
+package com.vnidrop.app.platform
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class AppVisibility(initiallyForeground: Boolean = true) {
+ private val _isForeground = MutableStateFlow(initiallyForeground)
+ val isForeground: StateFlow = _isForeground.asStateFlow()
+
+ fun setForeground(value: Boolean) {
+ _isForeground.value = value
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
index fed6f77..c9eaabd 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.booleanPreferencesKey
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.ui.theme.ThemeMode
@@ -18,19 +19,30 @@ data class AppPreferences(
val username: String,
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
+ val notificationsEnabled: Boolean,
)
class AppPreferencesDefaults(
val username: String,
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
+ val notificationsEnabled: Boolean = false,
)
+interface PreferencesRepository {
+ val preferences: Flow
+ suspend fun setUsername(username: String)
+ suspend fun setReceiveFolder(folder: ReceiveFolder)
+ suspend fun resetReceiveFolder()
+ suspend fun setThemeMode(mode: ThemeMode)
+ suspend fun setNotificationsEnabled(enabled: Boolean)
+}
+
class AppPreferencesRepository(
private val dataStore: DataStore,
private val defaults: AppPreferencesDefaults,
-) {
- val preferences: Flow = dataStore.data
+) : PreferencesRepository {
+ override val preferences: Flow = dataStore.data
.catch { emit(emptyPreferences()) }
.map { prefs ->
AppPreferences(
@@ -44,16 +56,17 @@ class AppPreferencesRepository(
?: defaults.receiveFolder.displayName,
),
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
+ notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
)
}
- suspend fun setUsername(username: String) {
+ override suspend fun setUsername(username: String) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.Username] = username.trim()
}
}
- suspend fun setReceiveFolder(folder: ReceiveFolder) {
+ override suspend fun setReceiveFolder(folder: ReceiveFolder) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name
prefs[PreferenceKeys.ReceiveFolderValue] = folder.value
@@ -61,15 +74,21 @@ class AppPreferencesRepository(
}
}
- suspend fun resetReceiveFolder() {
+ override suspend fun resetReceiveFolder() {
setReceiveFolder(defaults.receiveFolder)
}
- suspend fun setThemeMode(mode: ThemeMode) {
+ override suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ThemeMode] = mode.name
}
}
+
+ override suspend fun setNotificationsEnabled(enabled: Boolean) {
+ dataStore.edit { prefs ->
+ prefs[PreferenceKeys.NotificationsEnabled] = enabled
+ }
+ }
}
fun createAppPreferencesDataStore(appDataDir: String): DataStore =
@@ -83,6 +102,7 @@ private object PreferenceKeys {
val ReceiveFolderValue = stringPreferencesKey("receive_folder_value")
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode")
+ val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
}
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt
new file mode 100644
index 0000000..dec2df6
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt
@@ -0,0 +1,44 @@
+package com.vnidrop.app.ui.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+
+@Composable
+fun AppCard(
+ title: String,
+ modifier: Modifier = Modifier,
+ trailing: @Composable (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val colors = LocalVniDropColors.current
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
+ border = BorderStroke(1.dp, colors.borderDefault),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ trailing?.invoke()
+ }
+ HorizontalDivider(color = colors.borderDefault)
+ content()
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt
new file mode 100644
index 0000000..ac68baa
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt
@@ -0,0 +1,42 @@
+package com.vnidrop.app.ui.components
+
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+
+@Composable
+fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier.heightIn(min = 44.dp),
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, contentColor = Color.White),
+ ) {
+ Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+fun SecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ OutlinedButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp)) {
+ Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
+ Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt
new file mode 100644
index 0000000..3cca324
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt
@@ -0,0 +1,29 @@
+package com.vnidrop.app.ui.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Field(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+ minLines: Int = 1,
+ enabled: Boolean = true,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ modifier = modifier.fillMaxWidth(),
+ minLines = minLines,
+ enabled = enabled,
+ shape = RoundedCornerShape(8.dp),
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt
new file mode 100644
index 0000000..3bfbee9
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt
@@ -0,0 +1,39 @@
+package com.vnidrop.app.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+
+enum class PillTone { Neutral, Success, Warning, Destructive, Brand }
+
+@Composable
+fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = PillTone.Neutral) {
+ val colors = LocalVniDropColors.current
+ val color = when (tone) {
+ PillTone.Neutral -> colors.foregroundLighter
+ PillTone.Success, PillTone.Brand -> colors.brandLink
+ PillTone.Warning -> colors.warningDefault
+ PillTone.Destructive -> colors.destructiveDefault
+ }
+ Row(
+ modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f))
+ .border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(7.dp).clip(CircleShape).background(color))
+ Text(label, modifier = Modifier.padding(start = 6.dp), color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
new file mode 100644
index 0000000..6f04cdc
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
@@ -0,0 +1,36 @@
+package com.vnidrop.app.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+
+@Composable
+fun ProgressRow(label: String, progress: Float?, modifier: Modifier = Modifier) {
+ Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
+ else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
+ }
+}
+
+@Composable
+fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
+ Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt
deleted file mode 100644
index 468a832..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt
+++ /dev/null
@@ -1,232 +0,0 @@
-package com.vnidrop.app.ui.components
-
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import com.vnidrop.app.ui.theme.LocalVniDropColors
-
-@Composable
-fun AppCard(
- title: String,
- modifier: Modifier = Modifier,
- trailing: @Composable (() -> Unit)? = null,
- content: @Composable ColumnScope.() -> Unit,
-) {
- val colors = LocalVniDropColors.current
- Card(
- modifier = modifier.fillMaxWidth(),
- shape = RoundedCornerShape(8.dp),
- colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
- border = BorderStroke(1.dp, colors.borderDefault),
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- trailing?.invoke()
- }
- HorizontalDivider(color = colors.borderDefault)
- content()
- }
- }
-}
-
-@Composable
-fun Field(
- value: String,
- onValueChange: (String) -> Unit,
- label: String,
- modifier: Modifier = Modifier,
- minLines: Int = 1,
- enabled: Boolean = true,
-) {
- OutlinedTextField(
- value = value,
- onValueChange = onValueChange,
- label = { Text(label) },
- modifier = modifier.fillMaxWidth(),
- minLines = minLines,
- enabled = enabled,
- shape = RoundedCornerShape(8.dp),
- )
-}
-
-@Composable
-fun PrimaryButton(
- text: String,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- enabled: Boolean = true,
-) {
- val colors = LocalVniDropColors.current
- Button(
- onClick = onClick,
- enabled = enabled,
- modifier = modifier.heightIn(min = 44.dp),
- shape = RoundedCornerShape(8.dp),
- colors = ButtonDefaults.buttonColors(containerColor = colors.brandButton, contentColor = Color.White),
- ) {
- Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
- }
-}
-
-@Composable
-fun SecondaryButton(
- text: String,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- enabled: Boolean = true,
-) {
- OutlinedButton(
- onClick = onClick,
- enabled = enabled,
- modifier = modifier.heightIn(min = 44.dp),
- shape = RoundedCornerShape(8.dp),
- ) {
- Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
- }
-}
-
-@Composable
-fun QuietButton(
- text: String,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- enabled: Boolean = true,
-) {
- TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
- Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
- }
-}
-
-@Composable
-fun StatusPill(
- label: String,
- modifier: Modifier = Modifier,
- tone: PillTone = PillTone.Neutral,
-) {
- val colors = LocalVniDropColors.current
- val color = when (tone) {
- PillTone.Neutral -> colors.foregroundLighter
- PillTone.Success -> colors.brandLink
- PillTone.Warning -> colors.warningDefault
- PillTone.Destructive -> colors.destructiveDefault
- PillTone.Brand -> colors.brandLink
- }
- Row(
- modifier = modifier
- .clip(RoundedCornerShape(999.dp))
- .background(color.copy(alpha = 0.12f))
- .border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
- .padding(horizontal = 10.dp, vertical = 5.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Box(
- modifier = Modifier
- .size(7.dp)
- .clip(CircleShape)
- .background(color),
- )
- Text(label, color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
- }
-}
-
-enum class PillTone {
- Neutral,
- Success,
- Warning,
- Destructive,
- Brand,
-}
-
-@Composable
-fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
- val colors = LocalVniDropColors.current
- Card(
- modifier = modifier.fillMaxWidth(),
- shape = RoundedCornerShape(8.dp),
- colors = CardDefaults.cardColors(containerColor = colors.destructive200),
- border = BorderStroke(1.dp, colors.destructive400),
- ) {
- Text(
- text = message,
- modifier = Modifier.padding(14.dp),
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodyMedium,
- )
- }
-}
-
-@Composable
-fun ProgressRow(
- label: String,
- progress: Float?,
- modifier: Modifier = Modifier,
-) {
- Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
- Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
- if (progress == null) {
- LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
- } else {
- LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
- }
- }
-}
-
-@Composable
-fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.Top,
- ) {
- Text(
- text = label,
- modifier = Modifier.weight(0.35f),
- color = LocalVniDropColors.current.foregroundLighter,
- style = MaterialTheme.typography.bodySmall,
- )
- Text(
- text = value,
- modifier = Modifier.weight(0.65f),
- style = MaterialTheme.typography.bodySmall,
- )
- }
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt
new file mode 100644
index 0000000..902f384
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt
@@ -0,0 +1,54 @@
+package com.vnidrop.app.ui.feedback
+
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import org.jetbrains.compose.resources.StringResource
+
+sealed interface UiText {
+ data class Resource(val resource: StringResource) : UiText
+ data class Dynamic(val value: String) : UiText
+}
+
+enum class UiMessageTone {
+ Info,
+ Success,
+ Warning,
+ Error,
+}
+
+data class UiMessage(
+ val text: UiText,
+ val tone: UiMessageTone = UiMessageTone.Info,
+ val actionLabel: UiText? = null,
+ val onAction: (() -> Unit)? = null,
+)
+
+class UiMessageController {
+ private val channel = Channel(Channel.BUFFERED)
+ val messages: Flow = channel.receiveAsFlow()
+ private val _dismissals = MutableSharedFlow(extraBufferCapacity = 1)
+ val dismissals: SharedFlow = _dismissals.asSharedFlow()
+
+ suspend fun show(message: UiMessage) {
+ channel.send(message)
+ }
+
+ fun tryShow(message: UiMessage): Boolean = channel.trySend(message).isSuccess
+
+ fun dismissCurrent() {
+ _dismissals.tryEmit(Unit)
+ }
+
+ fun error(error: Throwable, fallback: String = "Something went wrong.") {
+ tryShow(
+ UiMessage(
+ text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback),
+ tone = UiMessageTone.Error,
+ ),
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt
new file mode 100644
index 0000000..c3cd91f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt
@@ -0,0 +1,71 @@
+package com.vnidrop.app.ui.feedback
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.getString
+
+@Composable
+fun VniDropSnackbarHost(
+ controller: UiMessageController,
+ modifier: Modifier = Modifier,
+) {
+ val hostState = remember { SnackbarHostState() }
+ var tone by remember { mutableStateOf(UiMessageTone.Info) }
+ LaunchedEffect(controller) {
+ controller.messages.collect { message ->
+ tone = message.tone
+ val result = hostState.showSnackbar(
+ message = message.text.resolve(),
+ actionLabel = message.actionLabel?.resolve(),
+ withDismissAction = message.actionLabel == null,
+ duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short,
+ )
+ if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke()
+ }
+ }
+ LaunchedEffect(controller, hostState) {
+ controller.dismissals.collect {
+ hostState.currentSnackbarData?.dismiss()
+ }
+ }
+
+ SnackbarHost(hostState = hostState, modifier = modifier) { data ->
+ val colors = LocalVniDropColors.current
+ val accent = when (tone) {
+ UiMessageTone.Info -> colors.brandLink
+ UiMessageTone.Success -> colors.brandDefault
+ UiMessageTone.Warning -> colors.warningDefault
+ UiMessageTone.Error -> colors.destructiveDefault
+ }
+ Snackbar(
+ snackbarData = data,
+ modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
+ shape = RoundedCornerShape(10.dp),
+ containerColor = colors.backgroundDialog,
+ contentColor = colors.foregroundDefault,
+ actionColor = accent,
+ dismissActionContentColor = colors.foregroundLighter,
+ )
+ }
+}
+
+private suspend fun UiText.resolve(): String = when (this) {
+ is UiText.Dynamic -> value
+ is UiText.Resource -> getString(resource)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt
deleted file mode 100644
index 01fb391..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-package com.vnidrop.app.ui.screens
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.unit.dp
-import com.vnidrop.app.VniDropAppEvent
-import com.vnidrop.app.core.CoreUiState
-import com.vnidrop.app.core.FolderAccessStatus
-import com.vnidrop.app.ui.components.AppCard
-import com.vnidrop.app.ui.components.Field
-import com.vnidrop.app.ui.components.MetadataRow
-import com.vnidrop.app.ui.components.PrimaryButton
-import com.vnidrop.app.ui.components.SecondaryButton
-import com.vnidrop.app.ui.state.PreferencesUiState
-import com.vnidrop.app.ui.state.ReceiveUiState
-import org.jetbrains.compose.resources.stringResource
-import vnidrop.shared.generated.resources.Res
-import vnidrop.shared.generated.resources.button_inspect_ticket
-import vnidrop.shared.generated.resources.button_receive
-import vnidrop.shared.generated.resources.button_receiving
-import vnidrop.shared.generated.resources.field_output_directory
-import vnidrop.shared.generated.resources.field_receiver_name
-import vnidrop.shared.generated.resources.field_ticket
-import vnidrop.shared.generated.resources.folder_status_permission_required
-import vnidrop.shared.generated.resources.folder_status_unavailable
-import vnidrop.shared.generated.resources.folder_status_validating
-import vnidrop.shared.generated.resources.folder_status_writable
-import vnidrop.shared.generated.resources.metadata_status
-import vnidrop.shared.generated.resources.receive_subtitle
-import vnidrop.shared.generated.resources.receive_title
-import vnidrop.shared.generated.resources.ticket_card_title
-
-@Composable
-fun ReceiveScreen(
- coreState: CoreUiState,
- receiveState: ReceiveUiState,
- preferencesState: PreferencesUiState,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
- ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
- ErrorSection(coreState)
- AppCard(title = stringResource(Res.string.ticket_card_title)) {
- Field(
- value = receiveState.ticket,
- onValueChange = { onEvent(VniDropAppEvent.ReceiveTicketChanged(it)) },
- label = stringResource(Res.string.field_ticket),
- minLines = 4,
- )
- MetadataRow(
- label = stringResource(Res.string.field_output_directory),
- value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
- )
- MetadataRow(
- label = stringResource(Res.string.metadata_status),
- value = preferencesState.folderAccessStatus.displayName(preferencesState.isValidatingFolder),
- )
- Field(
- value = receiveState.receiverName,
- onValueChange = { onEvent(VniDropAppEvent.ReceiverNameChanged(it)) },
- label = stringResource(Res.string.field_receiver_name),
- )
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- SecondaryButton(
- text = stringResource(Res.string.button_inspect_ticket),
- onClick = { onEvent(VniDropAppEvent.InspectTicketClicked) },
- enabled = receiveState.canInspect(coreState.isInitialized),
- )
- PrimaryButton(
- text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
- onClick = { onEvent(VniDropAppEvent.ReceiveClicked) },
- enabled = receiveState.canReceive(coreState.isInitialized) && preferencesState.canReceiveIntoFolder,
- )
- }
- }
- coreState.lastInspection?.let { TicketInspectionCard(it) }
- ProgressSection(coreState)
- }
-}
-
-@Composable
-private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
- if (isValidating) {
- stringResource(Res.string.folder_status_validating)
- } else {
- when (this) {
- FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
- FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
- FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
- }
- }
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt
index ca11183..5749781 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt
@@ -3,15 +3,10 @@ package com.vnidrop.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,35 +14,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import com.vnidrop.app.core.CoreUiState
+import com.vnidrop.app.core.CoreState
+import com.vnidrop.app.core.TicketInspectionModel
import com.vnidrop.app.ui.components.AppCard
-import com.vnidrop.app.ui.components.ErrorBanner
import com.vnidrop.app.ui.components.MetadataRow
-import com.vnidrop.app.ui.components.PillTone
-import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
-import com.vnidrop.app.ui.components.SecondaryButton
-import com.vnidrop.app.ui.components.StatusPill
-import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
-import com.vnidrop.app.ui.state.friendlyCoreError
import com.vnidrop.app.ui.state.summarizeProgress
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
-import uniffi.vnidrop.CoreEvent
-import uniffi.vnidrop.ReceiverRequest
-import uniffi.vnidrop.TicketInspection
import vnidrop.shared.generated.resources.Res
-import vnidrop.shared.generated.resources.button_approve
-import vnidrop.shared.generated.resources.button_refuse
-import vnidrop.shared.generated.resources.event_log_title
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_hash
import vnidrop.shared.generated.resources.metadata_kind
import vnidrop.shared.generated.resources.metadata_sender
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_transfer
-import vnidrop.shared.generated.resources.no_events
import vnidrop.shared.generated.resources.progress_title
import vnidrop.shared.generated.resources.ticket_details_title
import vnidrop.shared.generated.resources.ticket_no_metadata
@@ -61,30 +43,23 @@ fun ScreenHeader(title: String, subtitle: String) {
}
}
-@Composable
-fun ErrorSection(coreState: CoreUiState) {
- friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
-}
-
@Composable
fun EmptyText(text: String) {
Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
}
@Composable
-fun ProgressSection(coreState: CoreUiState) {
+fun ProgressSection(coreState: CoreState) {
val progress = summarizeProgress(coreState.events)
if (progress.isNotEmpty()) {
AppCard(title = stringResource(Res.string.progress_title)) {
- progress.forEach { item ->
- ProgressRow(label = item.label, progress = item.progress)
- }
+ progress.forEach { item -> ProgressRow(label = item.label, progress = item.progress) }
}
}
}
@Composable
-fun TicketInspectionCard(inspection: TicketInspection) {
+fun TicketInspectionCard(inspection: TicketInspectionModel) {
AppCard(title = stringResource(Res.string.ticket_details_title)) {
MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
inspection.metadata?.let { metadata ->
@@ -97,90 +72,14 @@ fun TicketInspectionCard(inspection: TicketInspection) {
}
}
-@Composable
-fun ReceiverRequestList(
- requests: List,
- onRespondRequest: (String, Boolean) -> Unit,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- requests.forEach { request ->
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .background(LocalVniDropColors.current.backgroundSurface100)
- .padding(12.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
- Column(modifier = Modifier.weight(1f)) {
- Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
- Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
- }
- StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
- }
- request.reason?.let { Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) }
- if (request.status == "requested") {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRespondRequest(request.id, false) })
- PrimaryButton(stringResource(Res.string.button_approve), onClick = { onRespondRequest(request.id, true) })
- }
- }
- }
- }
- }
-}
-
@Composable
fun TicketText(ticket: String) {
SelectionContainer {
Text(
text = ticket,
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .background(LocalVniDropColors.current.backgroundSurface200)
- .padding(12.dp),
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
+ .background(LocalVniDropColors.current.backgroundSurface200).padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
-
-@Composable
-fun DiagnosticsPanel(events: List) {
- AppCard(title = stringResource(Res.string.event_log_title)) {
- if (events.isEmpty()) {
- EmptyText(stringResource(Res.string.no_events))
- } else {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .height(280.dp)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- events.forEach { event -> EventRow(event) }
- }
- }
- }
-}
-
-@Composable
-private fun EventRow(event: CoreEvent) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .background(LocalVniDropColors.current.backgroundSurface100)
- .padding(10.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
- Text(event.dataJson, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
- }
-}
-
-@Composable
-fun SectionDivider() {
- HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt
index 79fa345..f7c0d30 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt
@@ -16,10 +16,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import com.vnidrop.app.core.CoreUiState
+import com.vnidrop.app.core.CoreState
+import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.navigation.VniDropIcons
-import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.isActiveTransfer
import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -30,7 +30,7 @@ import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
@Composable
-internal fun SendEmptyState(windowClass: WindowClass) {
+internal fun SendEmptyState(windowClass: WindowClass, onCreateNewTransfer: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier
@@ -64,13 +64,13 @@ internal fun SendEmptyState(windowClass: WindowClass) {
)
PrimaryButton(
text = stringResource(Res.string.button_create_new_transfer),
- onClick = {},
+ onClick = onCreateNewTransfer,
modifier = Modifier.padding(top = 24.dp),
)
}
}
-internal fun SendUiState.shouldShowEmptyState(coreState: CoreUiState): Boolean =
+internal fun SendState.shouldShowEmptyState(coreState: CoreState): Boolean =
!hasSelectedSource &&
!isSharing &&
coreState.lastShare == null &&
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt
deleted file mode 100644
index 878175e..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-package com.vnidrop.app.ui.screens
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import com.vnidrop.app.VniDropAppEvent
-import com.vnidrop.app.core.CoreUiState
-import com.vnidrop.app.ui.components.AppCard
-import com.vnidrop.app.ui.components.Field
-import com.vnidrop.app.ui.components.MetadataRow
-import com.vnidrop.app.ui.components.PillTone
-import com.vnidrop.app.ui.components.PrimaryButton
-import com.vnidrop.app.ui.components.SecondaryButton
-import com.vnidrop.app.ui.components.StatusPill
-import com.vnidrop.app.ui.state.SendUiState
-import com.vnidrop.app.ui.state.WindowClass
-import com.vnidrop.app.ui.state.formatBytes
-import org.jetbrains.compose.resources.stringResource
-import uniffi.vnidrop.ReceiverRequest
-import uniffi.vnidrop.ShareResult
-import vnidrop.shared.generated.resources.Res
-import vnidrop.shared.generated.resources.button_approve
-import vnidrop.shared.generated.resources.button_clear
-import vnidrop.shared.generated.resources.button_copy
-import vnidrop.shared.generated.resources.button_create_share
-import vnidrop.shared.generated.resources.button_creating_share
-import vnidrop.shared.generated.resources.button_refresh
-import vnidrop.shared.generated.resources.button_refuse
-import vnidrop.shared.generated.resources.button_select_file
-import vnidrop.shared.generated.resources.button_use_locally
-import vnidrop.shared.generated.resources.field_sender_name
-import vnidrop.shared.generated.resources.field_transfer_name
-import vnidrop.shared.generated.resources.metadata_name
-import vnidrop.shared.generated.resources.metadata_size
-import vnidrop.shared.generated.resources.metadata_source
-import vnidrop.shared.generated.resources.metadata_transfer
-import vnidrop.shared.generated.resources.receiver_requests_title
-import vnidrop.shared.generated.resources.send_source_empty
-import vnidrop.shared.generated.resources.send_subtitle
-import vnidrop.shared.generated.resources.send_title
-import vnidrop.shared.generated.resources.share_details_title
-import vnidrop.shared.generated.resources.source_title
-import vnidrop.shared.generated.resources.transfer_details_title
-
-@Composable
-fun SendScreen(
- coreState: CoreUiState,
- sendState: SendUiState,
- windowClass: WindowClass,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
- if (!sendState.shouldShowEmptyState(coreState)) {
- ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
- }
- ErrorSection(coreState)
- if (sendState.shouldShowEmptyState(coreState)) {
- SendEmptyState(windowClass = windowClass)
- } else {
- SendSourceCard(
- sendState = sendState,
- onEvent = onEvent,
- )
- SendDetailsCard(
- coreState = coreState,
- sendState = sendState,
- onEvent = onEvent,
- )
- coreState.lastShare?.let { share ->
- ShareResultCard(
- share = share,
- requests = coreState.receiverRequests,
- onEvent = onEvent,
- )
- }
- ProgressSection(coreState)
- }
- }
-}
-
-@Composable
-private fun SendSourceCard(
- sendState: SendUiState,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- AppCard(title = stringResource(Res.string.source_title)) {
- if (sendState.selectedSource.isBlank()) {
- EmptyText(stringResource(Res.string.send_source_empty))
- } else {
- MetadataRow(stringResource(Res.string.metadata_name), sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
- MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource)
- }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- PrimaryButton(
- text = stringResource(Res.string.button_select_file),
- onClick = { onEvent(VniDropAppEvent.SelectFileClicked) },
- )
- SecondaryButton(
- text = stringResource(Res.string.button_clear),
- onClick = { onEvent(VniDropAppEvent.ClearSelectedSourceClicked) },
- enabled = sendState.hasSelectedSource,
- )
- }
- }
-}
-
-@Composable
-private fun SendDetailsCard(
- coreState: CoreUiState,
- sendState: SendUiState,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- AppCard(title = stringResource(Res.string.transfer_details_title)) {
- Field(
- value = sendState.transferName,
- onValueChange = { onEvent(VniDropAppEvent.TransferNameChanged(it)) },
- label = stringResource(Res.string.field_transfer_name),
- )
- Field(
- value = sendState.senderName,
- onValueChange = { onEvent(VniDropAppEvent.SenderNameChanged(it)) },
- label = stringResource(Res.string.field_sender_name),
- )
- PrimaryButton(
- text = if (sendState.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
- onClick = { onEvent(VniDropAppEvent.CreateShareClicked) },
- enabled = sendState.canCreateShare(coreState.isInitialized),
- )
- }
-}
-
-@Composable
-private fun ShareResultCard(
- share: ShareResult,
- requests: List,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- AppCard(title = stringResource(Res.string.share_details_title), trailing = {
- StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
- }) {
- MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
- MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
- TicketText(share.ticket)
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- PrimaryButton(
- text = stringResource(Res.string.button_copy),
- onClick = { onEvent(VniDropAppEvent.CopyTicketClicked(share.ticket)) },
- )
- SecondaryButton(
- text = stringResource(Res.string.button_use_locally),
- onClick = { onEvent(VniDropAppEvent.UseTicketLocallyClicked(share.ticket)) },
- )
- SecondaryButton(
- text = stringResource(Res.string.button_refresh),
- onClick = { onEvent(VniDropAppEvent.RefreshReceiverRequestsClicked(share.transferId)) },
- )
- }
- if (requests.isNotEmpty()) {
- SectionDivider()
- Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold)
- ReceiverRequestList(
- requests = requests,
- onRespondRequest = { requestId, accepted ->
- onEvent(VniDropAppEvent.RespondReceiverRequestClicked(requestId, accepted))
- },
- )
- }
- }
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt
deleted file mode 100644
index 1aa54ab..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt
+++ /dev/null
@@ -1,721 +0,0 @@
-package com.vnidrop.app.ui.screens
-
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.PathFillType
-import androidx.compose.ui.graphics.SolidColor
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.StrokeJoin
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.graphics.vector.path
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import com.vnidrop.app.DeviceInfo
-import com.vnidrop.app.VniDropAppEvent
-import com.vnidrop.app.core.CoreUiState
-import com.vnidrop.app.core.FolderAccessStatus
-import com.vnidrop.app.ui.components.Field
-import com.vnidrop.app.ui.components.PrimaryButton
-import com.vnidrop.app.ui.components.SecondaryButton
-import com.vnidrop.app.ui.state.PreferencesUiState
-import com.vnidrop.app.ui.state.WindowClass
-import com.vnidrop.app.ui.theme.LocalVniDropColors
-import com.vnidrop.app.ui.theme.ThemeMode
-import org.jetbrains.compose.resources.stringResource
-import vnidrop.shared.generated.resources.Res
-import vnidrop.shared.generated.resources.about_bug_report
-import vnidrop.shared.generated.resources.about_privacy
-import vnidrop.shared.generated.resources.about_title
-import vnidrop.shared.generated.resources.appearance_auto_description
-import vnidrop.shared.generated.resources.appearance_dark_mode
-import vnidrop.shared.generated.resources.appearance_light_mode
-import vnidrop.shared.generated.resources.appearance_mode_title
-import vnidrop.shared.generated.resources.appearance_system_mode
-import vnidrop.shared.generated.resources.appearance_title
-import vnidrop.shared.generated.resources.battery_level_title
-import vnidrop.shared.generated.resources.button_choose_folder
-import vnidrop.shared.generated.resources.core_status_ready
-import vnidrop.shared.generated.resources.device_model_title
-import vnidrop.shared.generated.resources.device_name_title
-import vnidrop.shared.generated.resources.folder_status_permission_required
-import vnidrop.shared.generated.resources.folder_status_unavailable
-import vnidrop.shared.generated.resources.folder_status_validating
-import vnidrop.shared.generated.resources.folder_status_writable
-import vnidrop.shared.generated.resources.preferences_receive_folder_title
-import vnidrop.shared.generated.resources.preferences_title
-import vnidrop.shared.generated.resources.button_reset_default
-import vnidrop.shared.generated.resources.field_username
-import vnidrop.shared.generated.resources.network_title
-import vnidrop.shared.generated.resources.node_title
-import vnidrop.shared.generated.resources.not_initialized
-import vnidrop.shared.generated.resources.os_version_title
-import vnidrop.shared.generated.resources.settings_title
-import vnidrop.shared.generated.resources.value_unavailable
-import vnidrop.shared.generated.resources.version_title
-
-private enum class SettingsPane {
- Overview,
- Preferences,
- Appearance,
- About,
-}
-
-@Composable
-fun SettingsScreen(
- deviceInfo: DeviceInfo,
- coreState: CoreUiState,
- themeMode: ThemeMode,
- preferencesState: PreferencesUiState,
- windowClass: WindowClass,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- var pane by remember { mutableStateOf(SettingsPane.Overview) }
-
- when (windowClass) {
- WindowClass.Desktop -> DesktopSettings(
- selectedPane = pane,
- onPaneSelected = { pane = it },
- deviceInfo = deviceInfo,
- coreState = coreState,
- themeMode = themeMode,
- preferencesState = preferencesState,
- onEvent = onEvent,
- )
- else -> MobileSettings(
- pane = pane,
- onPaneSelected = { pane = it },
- deviceInfo = deviceInfo,
- coreState = coreState,
- themeMode = themeMode,
- preferencesState = preferencesState,
- onEvent = onEvent,
- )
- }
-}
-
-@Composable
-private fun MobileSettings(
- pane: SettingsPane,
- onPaneSelected: (SettingsPane) -> Unit,
- deviceInfo: DeviceInfo,
- coreState: CoreUiState,
- themeMode: ThemeMode,
- preferencesState: PreferencesUiState,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- ErrorSection(coreState)
- when (pane) {
- SettingsPane.Overview -> SettingsOverview(
- coreState = coreState,
- themeMode = themeMode,
- preferencesState = preferencesState,
- onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
- onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
- onOpenAbout = { onPaneSelected(SettingsPane.About) },
- largeTitle = true,
- )
- SettingsPane.Preferences -> PreferencesSettings(
- preferencesState = preferencesState,
- onEvent = onEvent,
- onBack = { onPaneSelected(SettingsPane.Overview) },
- showBack = true,
- )
- SettingsPane.Appearance -> AppearanceSettings(
- themeMode = themeMode,
- onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
- onBack = { onPaneSelected(SettingsPane.Overview) },
- showBack = true,
- )
- SettingsPane.About -> AboutSettings(
- deviceInfo = deviceInfo,
- coreState = coreState,
- onBack = { onPaneSelected(SettingsPane.Overview) },
- showBack = true,
- )
- }
- }
-}
-
-@Composable
-private fun DesktopSettings(
- selectedPane: SettingsPane,
- onPaneSelected: (SettingsPane) -> Unit,
- deviceInfo: DeviceInfo,
- coreState: CoreUiState,
- themeMode: ThemeMode,
- preferencesState: PreferencesUiState,
- onEvent: (VniDropAppEvent) -> Unit,
-) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(24.dp),
- verticalAlignment = Alignment.Top,
- ) {
- Column(
- modifier = Modifier.widthIn(min = 280.dp, max = 340.dp),
- verticalArrangement = Arrangement.spacedBy(18.dp),
- ) {
- SettingsOverview(
- coreState = coreState,
- themeMode = themeMode,
- preferencesState = preferencesState,
- onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
- onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
- onOpenAbout = { onPaneSelected(SettingsPane.About) },
- largeTitle = false,
- selectedPane = selectedPane,
- )
- }
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(18.dp),
- ) {
- when (selectedPane) {
- SettingsPane.Overview,
- SettingsPane.Preferences -> PreferencesSettings(
- preferencesState = preferencesState,
- onEvent = onEvent,
- onBack = {},
- showBack = false,
- )
- SettingsPane.Appearance -> AppearanceSettings(
- themeMode = themeMode,
- onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
- onBack = {},
- showBack = false,
- )
- SettingsPane.About -> AboutSettings(
- deviceInfo = deviceInfo,
- coreState = coreState,
- onBack = {},
- showBack = false,
- )
- }
- }
- }
-}
-
-@Composable
-private fun SettingsOverview(
- coreState: CoreUiState,
- themeMode: ThemeMode,
- preferencesState: PreferencesUiState,
- onOpenPreferences: () -> Unit,
- onOpenAppearance: () -> Unit,
- onOpenAbout: () -> Unit,
- largeTitle: Boolean,
- selectedPane: SettingsPane = SettingsPane.Overview,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- if (largeTitle) {
- SettingsLargeTitle(stringResource(Res.string.settings_title))
- } else {
- Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
- }
- SettingsGroup {
- SettingsRow(
- icon = SettingsIcons.Device,
- title = stringResource(Res.string.preferences_title),
- value = preferencesState.username,
- selected = selectedPane == SettingsPane.Preferences,
- onClick = onOpenPreferences,
- )
- SettingsRow(
- icon = SettingsIcons.Sun,
- title = stringResource(Res.string.appearance_title),
- value = themeMode.displayName(),
- selected = selectedPane == SettingsPane.Appearance,
- onClick = onOpenAppearance,
- )
- }
- SettingsGroup {
- SettingsRow(
- icon = SettingsIcons.Node,
- title = stringResource(Res.string.node_title),
- value = if (coreState.isInitialized) stringResource(Res.string.core_status_ready) else stringResource(Res.string.not_initialized),
- iconTone = IconTone.Neutral,
- )
- SettingsRow(
- icon = SettingsIcons.Info,
- title = stringResource(Res.string.about_title),
- selected = selectedPane == SettingsPane.About,
- onClick = onOpenAbout,
- )
- }
- }
-}
-
-@Composable
-private fun PreferencesSettings(
- preferencesState: PreferencesUiState,
- onEvent: (VniDropAppEvent) -> Unit,
- onBack: () -> Unit,
- showBack: Boolean,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- SettingsTopBar(title = stringResource(Res.string.preferences_title), onBack = onBack, showBack = showBack)
- Field(
- value = preferencesState.username,
- onValueChange = { onEvent(VniDropAppEvent.UsernameChanged(it)) },
- label = stringResource(Res.string.field_username),
- )
- SettingsGroup {
- SettingsRow(
- icon = SettingsIcons.Folder,
- title = stringResource(Res.string.preferences_receive_folder_title),
- value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
- iconTone = IconTone.Neutral,
- )
- SettingsRow(
- icon = SettingsIcons.Check,
- title = preferencesState.folderAccessStatus.displayName(isValidating = preferencesState.isValidatingFolder),
- iconTone = IconTone.Neutral,
- )
- }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- PrimaryButton(
- text = stringResource(Res.string.button_choose_folder),
- onClick = { onEvent(VniDropAppEvent.ChooseReceiveFolderClicked) },
- )
- SecondaryButton(
- text = stringResource(Res.string.button_reset_default),
- onClick = { onEvent(VniDropAppEvent.ResetReceiveFolderClicked) },
- )
- }
- }
-}
-
-@Composable
-private fun AppearanceSettings(
- themeMode: ThemeMode,
- onThemeModeChange: (ThemeMode) -> Unit,
- onBack: () -> Unit,
- showBack: Boolean,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- SettingsTopBar(title = stringResource(Res.string.appearance_title), onBack = onBack, showBack = showBack)
- Text(
- text = stringResource(Res.string.appearance_mode_title),
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold,
- )
- ThemeChoice(
- icon = SettingsIcons.Device,
- title = stringResource(Res.string.appearance_system_mode),
- description = stringResource(Res.string.appearance_auto_description),
- selected = themeMode == ThemeMode.System,
- onClick = { onThemeModeChange(ThemeMode.System) },
- )
- ThemeChoice(
- icon = SettingsIcons.Moon,
- title = stringResource(Res.string.appearance_dark_mode),
- selected = themeMode == ThemeMode.Dark,
- onClick = { onThemeModeChange(ThemeMode.Dark) },
- )
- ThemeChoice(
- icon = SettingsIcons.Sun,
- title = stringResource(Res.string.appearance_light_mode),
- selected = themeMode == ThemeMode.Light,
- onClick = { onThemeModeChange(ThemeMode.Light) },
- )
- }
-}
-
-@Composable
-private fun AboutSettings(
- deviceInfo: DeviceInfo,
- coreState: CoreUiState,
- onBack: () -> Unit,
- showBack: Boolean,
-) {
- val unavailable = stringResource(Res.string.value_unavailable)
-
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- SettingsTopBar(title = stringResource(Res.string.about_title), onBack = onBack, showBack = showBack)
- SettingsGroup {
- SettingsRow(icon = SettingsIcons.Document, title = stringResource(Res.string.about_privacy), iconTone = IconTone.Neutral)
- SettingsRow(icon = SettingsIcons.Bug, title = stringResource(Res.string.about_bug_report), iconTone = IconTone.Neutral)
- }
- PlainInfoSection {
- PlainInfoItem(stringResource(Res.string.version_title), "0.1.0")
- PlainInfoItem(stringResource(Res.string.device_name_title), deviceInfo.deviceName.orUnavailable(unavailable))
- PlainInfoItem(stringResource(Res.string.device_model_title), deviceInfo.deviceModel.orUnavailable(unavailable))
- PlainInfoItem(stringResource(Res.string.os_version_title), deviceInfo.operatingSystem)
- PlainInfoItem(stringResource(Res.string.network_title), deviceInfo.network.orUnavailable(unavailable))
- PlainInfoItem(stringResource(Res.string.battery_level_title), deviceInfo.batteryLevel.orUnavailable(unavailable))
- PlainInfoItem(
- title = stringResource(Res.string.node_title),
- value = if (coreState.isInitialized) {
- stringResource(Res.string.core_status_ready)
- } else {
- stringResource(Res.string.not_initialized)
- },
- )
- }
- }
-}
-
-@Composable
-private fun SettingsLargeTitle(title: String) {
- Text(
- text = title,
- style = MaterialTheme.typography.headlineLarge,
- fontWeight = FontWeight.Bold,
- )
-}
-
-@Composable
-private fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- if (showBack) {
- Icon(
- imageVector = SettingsIcons.Back,
- contentDescription = null,
- tint = LocalVniDropColors.current.foregroundDefault,
- modifier = Modifier
- .size(30.dp)
- .clickable(onClick = onBack)
- .padding(3.dp),
- )
- }
- Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
- }
-}
-
-@Composable
-private fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) {
- val colors = LocalVniDropColors.current
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(18.dp),
- colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
- ) {
- Column(modifier = Modifier.padding(vertical = 4.dp), content = content)
- }
-}
-
-@Composable
-private fun PlainInfoSection(content: @Composable ColumnScope.() -> Unit) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(18.dp),
- content = content,
- )
-}
-
-@Composable
-private fun PlainInfoItem(title: String, value: String) {
- val colors = LocalVniDropColors.current
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- text = title,
- color = colors.foregroundLighter,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Medium,
- )
- Text(
- text = value,
- color = colors.foregroundDefault,
- style = MaterialTheme.typography.bodyLarge,
- )
- }
-}
-
-private fun String?.orUnavailable(fallback: String): String =
- this?.takeIf { it.isNotBlank() } ?: fallback
-
-@Composable
-private fun SettingsRow(
- icon: ImageVector,
- title: String,
- value: String? = null,
- selected: Boolean = false,
- iconTone: IconTone = IconTone.Brand,
- onClick: (() -> Unit)? = null,
-) {
- val colors = LocalVniDropColors.current
- val iconColor = when (iconTone) {
- IconTone.Brand -> colors.brandLink
- IconTone.Neutral -> colors.foregroundLighter
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(60.dp)
- .background(if (selected) colors.backgroundSurface300 else Color.Transparent)
- .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
- Spacer(Modifier.width(14.dp))
- Text(
- text = title,
- modifier = Modifier.weight(1f),
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- value?.let {
- Text(
- text = it,
- color = colors.foregroundLighter,
- style = MaterialTheme.typography.bodySmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Spacer(Modifier.width(10.dp))
- }
- if (onClick != null) {
- Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(20.dp))
- }
- }
-}
-
-@Composable
-private fun ThemeChoice(
- icon: ImageVector,
- title: String,
- selected: Boolean,
- onClick: () -> Unit,
- description: String? = null,
-) {
- val colors = LocalVniDropColors.current
- val shape = RoundedCornerShape(18.dp)
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(62.dp)
- .background(colors.backgroundSurface200, shape)
- .border(
- border = if (selected) BorderStroke(1.5.dp, colors.brandLink) else BorderStroke(1.dp, Color.Transparent),
- shape = shape,
- )
- .clickable(onClick = onClick)
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(icon, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(22.dp))
- Spacer(Modifier.width(14.dp))
- Text(
- text = title,
- modifier = Modifier.weight(1f),
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- )
- if (selected) {
- Icon(SettingsIcons.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
- }
- }
- description?.let {
- Text(
- text = it,
- color = colors.foregroundLighter,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
-}
-
-@Composable
-private fun ThemeMode.displayName(): String =
- when (this) {
- ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
- ThemeMode.Light -> stringResource(Res.string.appearance_light_mode)
- ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
- }
-
-@Composable
-private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
- if (isValidating) {
- stringResource(Res.string.folder_status_validating)
- } else {
- when (this) {
- FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
- FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
- FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
- }
- }
-
-private enum class IconTone {
- Brand,
- Neutral,
-}
-
-private object SettingsIcons {
- val ChevronRight = lineIcon("ChevronRight") {
- moveTo(9f, 18f)
- lineTo(15f, 12f)
- lineTo(9f, 6f)
- }
- val Back = lineIcon("Back") {
- moveTo(19f, 12f)
- lineTo(5f, 12f)
- moveTo(12f, 19f)
- lineTo(5f, 12f)
- lineTo(12f, 5f)
- }
- val Check = lineIcon("Check") {
- moveTo(20f, 6f)
- lineTo(9f, 17f)
- lineTo(4f, 12f)
- }
- val Sun = lineIcon("Sun") {
- moveTo(12f, 4f)
- lineTo(12f, 2f)
- moveTo(12f, 22f)
- lineTo(12f, 20f)
- moveTo(4.93f, 4.93f)
- lineTo(6.34f, 6.34f)
- moveTo(17.66f, 17.66f)
- lineTo(19.07f, 19.07f)
- moveTo(2f, 12f)
- lineTo(4f, 12f)
- moveTo(20f, 12f)
- lineTo(22f, 12f)
- moveTo(4.93f, 19.07f)
- lineTo(6.34f, 17.66f)
- moveTo(17.66f, 6.34f)
- lineTo(19.07f, 4.93f)
- moveTo(16f, 12f)
- arcTo(4f, 4f, 0f, true, true, 8f, 12f)
- arcTo(4f, 4f, 0f, true, true, 16f, 12f)
- }
- val Moon = lineIcon("Moon") {
- moveTo(21f, 12.79f)
- arcTo(9f, 9f, 0f, true, true, 11.21f, 3f)
- arcTo(7f, 7f, 0f, false, false, 21f, 12.79f)
- }
- val Device = lineIcon("Device") {
- roundRect(7f, 2f, 10f, 20f, 2.5f)
- moveTo(11f, 18f)
- lineTo(13f, 18f)
- }
- val Folder = lineIcon("Folder") {
- moveTo(3f, 7f)
- lineTo(9f, 7f)
- lineTo(11f, 9f)
- lineTo(21f, 9f)
- lineTo(21f, 19f)
- lineTo(3f, 19f)
- close()
- }
- val Info = lineIcon("Info") {
- moveTo(12f, 16f)
- lineTo(12f, 12f)
- moveTo(12f, 8f)
- lineTo(12.01f, 8f)
- moveTo(21f, 12f)
- arcTo(9f, 9f, 0f, true, true, 3f, 12f)
- arcTo(9f, 9f, 0f, true, true, 21f, 12f)
- }
- val Bug = lineIcon("Bug") {
- moveTo(8f, 2f)
- lineTo(9.88f, 3.88f)
- moveTo(16f, 2f)
- lineTo(14.12f, 3.88f)
- roundRect(7f, 6f, 10f, 14f, 5f)
- moveTo(3f, 10f)
- lineTo(7f, 10f)
- moveTo(17f, 10f)
- lineTo(21f, 10f)
- moveTo(3f, 16f)
- lineTo(7f, 16f)
- moveTo(17f, 16f)
- lineTo(21f, 16f)
- moveTo(12f, 6f)
- lineTo(12f, 20f)
- }
- val Document = lineIcon("Document") {
- moveTo(14f, 2f)
- lineTo(6f, 2f)
- arcTo(2f, 2f, 0f, false, false, 4f, 4f)
- lineTo(4f, 20f)
- arcTo(2f, 2f, 0f, false, false, 6f, 22f)
- lineTo(18f, 22f)
- arcTo(2f, 2f, 0f, false, false, 20f, 20f)
- lineTo(20f, 8f)
- lineTo(14f, 2f)
- moveTo(14f, 2f)
- lineTo(14f, 8f)
- lineTo(20f, 8f)
- moveTo(8f, 13f)
- lineTo(16f, 13f)
- moveTo(8f, 17f)
- lineTo(16f, 17f)
- }
- val Node = lineIcon("Node") {
- roundRect(4f, 4f, 16f, 16f, 3f)
- moveTo(9f, 9f)
- lineTo(15f, 9f)
- moveTo(9f, 13f)
- lineTo(15f, 13f)
- moveTo(9f, 17f)
- lineTo(12f, 17f)
- }
-}
-
-private fun lineIcon(name: String, block: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit): ImageVector =
- ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
- path(
- fill = SolidColor(Color.Transparent),
- stroke = SolidColor(Color.Black),
- strokeLineWidth = 2f,
- strokeLineCap = StrokeCap.Round,
- strokeLineJoin = StrokeJoin.Round,
- pathFillType = PathFillType.NonZero,
- pathBuilder = block,
- )
- }.build()
-
-private fun androidx.compose.ui.graphics.vector.PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) {
- moveTo(x + radius, y)
- lineTo(x + width - radius, y)
- arcTo(radius, radius, 0f, false, true, x + width, y + radius)
- lineTo(x + width, y + height - radius)
- arcTo(radius, radius, 0f, false, true, x + width - radius, y + height)
- lineTo(x + radius, y + height)
- arcTo(radius, radius, 0f, false, true, x, y + height - radius)
- lineTo(x, y + radius)
- arcTo(radius, radius, 0f, false, true, x + radius, y)
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt
index d90230c..c0f948d 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt
@@ -2,6 +2,7 @@ package com.vnidrop.app.ui.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -22,14 +23,16 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun AppShell(
+ modifier: Modifier = Modifier,
selectedDestination: AppDestination,
windowClass: WindowClass,
onDestinationSelected: (AppDestination) -> Unit,
+ overlay: @Composable BoxScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(
- modifier = Modifier
+ modifier = modifier
.fillMaxSize()
.background(colors.backgroundDashCanvas),
color = colors.backgroundDashCanvas,
@@ -38,12 +41,14 @@ fun AppShell(
PhoneShell(
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected,
+ overlay = overlay,
content = content,
)
} else {
WideShell(
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected,
+ overlay = overlay,
content = content,
)
}
@@ -54,6 +59,7 @@ fun AppShell(
private fun WideShell(
selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
+ overlay: @Composable BoxScope.() -> Unit,
content: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
@@ -61,7 +67,10 @@ private fun WideShell(
selected = selectedDestination,
onDestinationSelected = onDestinationSelected,
)
- ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
+ Box(modifier = Modifier.weight(1f).fillMaxSize()) {
+ ScreenScrollContainer(content = content)
+ overlay()
+ }
}
}
@@ -69,10 +78,14 @@ private fun WideShell(
private fun PhoneShell(
selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
+ overlay: @Composable BoxScope.() -> Unit,
content: @Composable () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
- ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
+ Box(modifier = Modifier.weight(1f).fillMaxSize()) {
+ ScreenScrollContainer(content = content)
+ overlay()
+ }
AppBottomNavigation(
selected = selectedDestination,
onDestinationSelected = onDestinationSelected,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
index ad46df2..a639fca 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
@@ -1,12 +1,7 @@
package com.vnidrop.app.ui.state
-import com.vnidrop.app.ui.theme.ThemeMode
-import com.vnidrop.app.ui.navigation.AppDestination
-import com.vnidrop.app.core.FolderAccessStatus
-import com.vnidrop.app.core.ReceiveFolder
-import com.vnidrop.app.core.ReceiveFolderKind
-import uniffi.vnidrop.CoreEvent
-import uniffi.vnidrop.StoredTransfer
+import com.vnidrop.app.core.CoreEventModel
+import com.vnidrop.app.core.Transfer
import kotlin.math.roundToInt
enum class WindowClass {
@@ -25,52 +20,6 @@ fun windowClassFor(widthDp: Float): WindowClass =
fun useBottomNavigation(windowClass: WindowClass): Boolean =
windowClass == WindowClass.Phone
-data class AppUiState(
- val destination: AppDestination = AppDestination.Send,
- val themeMode: ThemeMode = ThemeMode.System,
-)
-
-data class PreferencesUiState(
- val username: String = "",
- val receiveFolder: ReceiveFolder = ReceiveFolder(
- kind = ReceiveFolderKind.FileSystemPath,
- value = "",
- displayName = "",
- ),
- val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
- val isValidatingFolder: Boolean = false,
-) {
- val canReceiveIntoFolder: Boolean
- get() = folderAccessStatus == FolderAccessStatus.Writable
-}
-
-data class SendUiState(
- val selectedSource: String = "",
- val selectedDisplayName: String = "",
- val transferName: String = "VniDrop transfer",
- val senderName: String = "",
- val isSharing: Boolean = false,
-) {
- val hasSelectedSource: Boolean
- get() = selectedSource.isNotBlank()
-
- fun canCreateShare(isCoreInitialized: Boolean): Boolean =
- isCoreInitialized && hasSelectedSource && !isSharing
-}
-
-data class ReceiveUiState(
- val ticket: String = "",
- val outputDirectory: String = "",
- val receiverName: String = "",
- val isReceiving: Boolean = false,
-) {
- fun canInspect(isCoreInitialized: Boolean): Boolean =
- isCoreInitialized && ticket.isNotBlank()
-
- fun canReceive(isCoreInitialized: Boolean): Boolean =
- isCoreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && !isReceiving
-}
-
data class TransferProgress(
val transferId: ULong?,
val phase: String,
@@ -89,10 +38,10 @@ fun displayNameForStatus(status: String): String =
else -> status.replaceFirstChar { it.uppercase() }
}
-fun StoredTransfer.isActiveTransfer(): Boolean =
+fun Transfer.isActiveTransfer(): Boolean =
status.lowercase() in activeTransferStatuses
-fun summarizeProgress(events: List): List =
+fun summarizeProgress(events: List): List =
events
.filter { event -> event.transferId != null && event.phase in progressPhases }
.distinctBy { event -> "${event.transferId}:${event.phase}" }
@@ -106,7 +55,7 @@ fun summarizeProgress(events: List): List =
)
}
-fun transferSubtitle(transfer: StoredTransfer): String {
+fun transferSubtitle(transfer: Transfer): String {
val pieces = listOfNotNull(
transfer.transferName,
"${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
@@ -131,21 +80,10 @@ fun formatBytes(size: ULong): String {
}
}
-fun friendlyCoreError(raw: String?): String? {
- if (raw.isNullOrBlank()) return null
- return when {
- raw.contains("failed to parse transfer ticket", ignoreCase = true) -> "The ticket could not be read. Check that the full ticket was copied."
- raw.contains("permission", ignoreCase = true) || raw.contains("refused", ignoreCase = true) -> "The transfer is waiting for approval or was refused by the sender."
- raw.contains("Failed to bind sockets", ignoreCase = true) -> "VniDrop could not open its network sockets on this device."
- raw.contains("not found", ignoreCase = true) && raw.contains("libvnidrop", ignoreCase = true) -> "The native VniDrop library is missing from this build."
- else -> raw
- }
-}
-
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
private val activeTransferStatuses = setOf("importing", "sharing", "receiving")
-private fun eventLabel(event: CoreEvent): String {
+private fun eventLabel(event: CoreEventModel): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() }
val phase = event.phase.replaceFirstChar { it.uppercase() }
val kind = event.kind.replace('-', ' ')
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt
index 7fa0979..3a04e5e 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt
@@ -2,4 +2,4 @@ package com.vnidrop.app
import androidx.compose.ui.window.ComposeUIViewController
-fun MainViewController() = ComposeUIViewController { App() }
\ No newline at end of file
+fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) }
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt
index 7934fb8..cebe5b7 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt
@@ -1,28 +1,48 @@
package com.vnidrop.app
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.vnidrop.app.core.rememberFileSystemService
+import com.vnidrop.app.notifications.IosLocalNotificationService
+import platform.Foundation.NSBundle
import platform.Foundation.NSTemporaryDirectory
import platform.UIKit.UIDevice
-class IOSPlatform : Platform {
- private val device: UIDevice = UIDevice.currentDevice
+@Composable
+fun rememberIosAppDependencies(): AppDependencies {
+ val fileSystemService = rememberFileSystemService()
+ return remember(fileSystemService) {
+ val device = UIDevice.currentDevice
+ AppDependencies(
+ environment = PlatformEnvironment(
+ name = device.systemName() + " " + device.systemVersion,
+ appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
+ defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop",
+ defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
+ ),
+ deviceInfoProvider = IosDeviceInfoProvider(device),
+ fileSystemService = fileSystemService,
+ localNotificationService = IosLocalNotificationService(),
+ )
+ }
+}
- override val name: String = device.systemName() + " " + device.systemVersion
- override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop"
- override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive"
- override val deviceInfo: DeviceInfo = DeviceInfo(
+private class IosDeviceInfoProvider(
+ private val device: UIDevice,
+) : DeviceInfoProvider {
+ override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = device.name,
deviceModel = device.model,
operatingSystem = device.systemName() + " " + device.systemVersion,
network = null,
- batteryLevel = batteryLevel(device),
+ batteryLevel = runCatching {
+ val wasMonitoring = device.batteryMonitoringEnabled
+ try {
+ device.batteryMonitoringEnabled = true
+ device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" }
+ } finally {
+ device.batteryMonitoringEnabled = wasMonitoring
+ }
+ }.getOrNull(),
)
}
-
-actual fun getPlatform(): Platform = IOSPlatform()
-
-private fun batteryLevel(device: UIDevice): String? =
- runCatching {
- device.batteryMonitoringEnabled = true
- val level = device.batteryLevel
- if (level >= 0.0) "${(level * 100).toInt()}%" else null
- }.getOrNull()
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
index d710b7e..ff72d58 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
@@ -73,15 +73,6 @@ actual fun rememberReceiveFolderPicker(
}
}
-actual suspend fun sharePickedFile(
- repository: CoreRepository,
- file: PickedShareFile,
- transferName: String,
- senderName: String,
-) {
- repository.shareSecurityScopedFileUrl(file.value, file.displayName, transferName, senderName)
-}
-
private class DocumentPickerDelegate(
private val onFilePicked: (PickedShareFile) -> Unit,
private val onError: (String) -> Unit,
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
index bd9b711..773e6de 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
@@ -42,6 +42,18 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
+ override suspend fun sharePickedFile(
+ repository: CoreGateway,
+ file: PickedShareFile,
+ transferName: String,
+ senderName: String,
+ ): Result = repository.shareSecurityScopedFileUrl(
+ file.value,
+ file.displayName,
+ transferName,
+ senderName,
+ )
+
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
val didStartAccess = url.startAccessingSecurityScopedResource()
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt
new file mode 100644
index 0000000..9d52847
--- /dev/null
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt
@@ -0,0 +1,99 @@
+package com.vnidrop.app.notifications
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+import platform.UIKit.UIApplicationOpenNotificationSettingsURLString
+import platform.UserNotifications.UNAuthorizationOptionAlert
+import platform.UserNotifications.UNAuthorizationOptionSound
+import platform.UserNotifications.UNAuthorizationStatusAuthorized
+import platform.UserNotifications.UNAuthorizationStatusDenied
+import platform.UserNotifications.UNAuthorizationStatusEphemeral
+import platform.UserNotifications.UNAuthorizationStatusNotDetermined
+import platform.UserNotifications.UNAuthorizationStatusProvisional
+import platform.UserNotifications.UNMutableNotificationContent
+import platform.UserNotifications.UNNotificationRequest
+import platform.UserNotifications.UNUserNotificationCenter
+import kotlin.coroutines.resume
+
+class IosLocalNotificationService : LocalNotificationService {
+ private val center = UNUserNotificationCenter.currentNotificationCenter()
+ private val _permission = MutableStateFlow(NotificationPermission.NotDetermined)
+ override val permission: StateFlow = _permission.asStateFlow()
+
+ override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation ->
+ center.getNotificationSettingsWithCompletionHandler { settings ->
+ val mapped = when (settings?.authorizationStatus) {
+ UNAuthorizationStatusAuthorized,
+ UNAuthorizationStatusProvisional,
+ UNAuthorizationStatusEphemeral -> NotificationPermission.Granted
+ UNAuthorizationStatusDenied -> NotificationPermission.Denied
+ UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined
+ else -> NotificationPermission.Unsupported
+ }
+ _permission.value = mapped
+ if (continuation.isActive) continuation.resume(mapped)
+ }
+ }
+
+ override suspend fun requestPermission(): NotificationPermission {
+ val current = refreshPermission()
+ if (current != NotificationPermission.NotDetermined) return current
+ return suspendCancellableCoroutine { continuation ->
+ center.requestAuthorizationWithOptions(
+ options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound,
+ completionHandler = { granted, _ ->
+ val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
+ _permission.value = result
+ if (continuation.isActive) continuation.resume(result)
+ },
+ )
+ }
+ }
+
+ override suspend fun openSettings(): Result {
+ val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
+ ?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
+ val opened = suspendCancellableCoroutine { continuation ->
+ UIApplication.sharedApplication.openURL(url, emptyMap()) { success ->
+ if (continuation.isActive) continuation.resume(success)
+ }
+ }
+ return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings"))
+ }
+
+ override suspend fun publish(notification: LocalNotification): Result = runCatching {
+ check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
+ val content = UNMutableNotificationContent().apply {
+ setTitle(notification.title)
+ setBody(notification.body)
+ setSound(platform.UserNotifications.UNNotificationSound.defaultSound)
+ }
+ val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null)
+ suspendCancellableCoroutine { continuation ->
+ center.addNotificationRequest(request) { error ->
+ if (!continuation.isActive) return@addNotificationRequest
+ if (error == null) {
+ continuation.resume(Unit)
+ } else {
+ continuation.resumeWith(
+ Result.failure(IllegalStateException(error.localizedDescription)),
+ )
+ }
+ }
+ }
+ }
+
+ override suspend fun cancel(id: String) {
+ center.removePendingNotificationRequestsWithIdentifiers(listOf(id))
+ center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
+ }
+
+ override suspend fun cancelAll() {
+ center.removeAllPendingNotificationRequests()
+ center.removeAllDeliveredNotifications()
+ }
+}
diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt
index 085fa32..cef6d80 100644
--- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt
+++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt
@@ -1,37 +1,42 @@
package com.vnidrop.app
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.vnidrop.app.core.rememberFileSystemService
+import com.vnidrop.app.notifications.JvmLocalNotificationService
import java.net.NetworkInterface
-class JVMPlatform : Platform {
- override val name: String = "Java ${System.getProperty("java.version")}"
- override val defaultCoreDataDir: String =
- System.getProperty("user.home") + "/.vnidrop"
- override val defaultReceiveDir: String =
- System.getProperty("user.home") + "/Downloads"
- override val deviceInfo: DeviceInfo = DeviceInfo(
- deviceName = System.getenv("COMPUTERNAME")
- ?: System.getenv("HOSTNAME")
- ?: System.getProperty("user.name"),
- deviceModel = listOfNotNull(
- System.getProperty("os.arch"),
- System.getProperty("java.vm.name"),
- ).joinToString(" | ").ifBlank { null },
- operatingSystem = listOfNotNull(
- System.getProperty("os.name"),
- System.getProperty("os.version"),
- ).joinToString(" ").ifBlank { name },
- network = activeNetworkSummary(),
+@Composable
+fun rememberJvmAppDependencies(): AppDependencies {
+ val fileSystemService = rememberFileSystemService()
+ return remember(fileSystemService) {
+ AppDependencies(
+ environment = PlatformEnvironment(
+ name = "Java ${System.getProperty("java.version")}",
+ appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0",
+ defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
+ defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
+ ),
+ deviceInfoProvider = JvmDeviceInfoProvider,
+ fileSystemService = fileSystemService,
+ localNotificationService = JvmLocalNotificationService(),
+ )
+ }
+}
+
+private object JvmDeviceInfoProvider : DeviceInfoProvider {
+ override suspend fun load(): DeviceInfo = DeviceInfo(
+ deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"),
+ deviceModel = listOfNotNull(System.getProperty("os.arch"), System.getProperty("java.vm.name"))
+ .joinToString(" | ").ifBlank { null },
+ operatingSystem = listOfNotNull(System.getProperty("os.name"), System.getProperty("os.version"))
+ .joinToString(" ").ifBlank { "Java ${System.getProperty("java.version")}" },
+ network = runCatching {
+ NetworkInterface.getNetworkInterfaces().asSequence()
+ .filter { it.isUp && !it.isLoopback }
+ .map { it.displayName }
+ .firstOrNull()
+ }.getOrNull(),
batteryLevel = null,
)
}
-
-actual fun getPlatform(): Platform = JVMPlatform()
-
-private fun activeNetworkSummary(): String? =
- runCatching {
- NetworkInterface.getNetworkInterfaces()
- .asSequence()
- .filter { it.isUp && !it.isLoopback }
- .map { it.displayName }
- .firstOrNull()
- }.getOrNull()
diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
index b0298b3..e784821 100644
--- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
+++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
@@ -44,15 +44,6 @@ actual fun rememberReceiveFolderPicker(
}
}
-actual suspend fun sharePickedFile(
- repository: CoreRepository,
- file: PickedShareFile,
- transferName: String,
- senderName: String,
-) {
- repository.sharePath(file.value, transferName, senderName)
-}
-
private fun openPicker(
onError: (String) -> Unit,
block: () -> Unit,
diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt
index 01ebcd2..1b692be 100644
--- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt
+++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt
@@ -27,4 +27,11 @@ private class JvmFileSystemService : FileSystemService {
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
+
+ override suspend fun sharePickedFile(
+ repository: CoreGateway,
+ file: PickedShareFile,
+ transferName: String,
+ senderName: String,
+ ): Result = repository.sharePath(file.value, transferName, senderName)
}
diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt
new file mode 100644
index 0000000..a9fb196
--- /dev/null
+++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt
@@ -0,0 +1,63 @@
+package com.vnidrop.app.notifications
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.awt.Color
+import java.awt.Frame
+import java.awt.Graphics2D
+import java.awt.SystemTray
+import java.awt.TrayIcon
+import java.awt.image.BufferedImage
+
+class JvmLocalNotificationService : LocalNotificationService {
+ private val _permission = MutableStateFlow(
+ if (SystemTray.isSupported()) NotificationPermission.Granted else NotificationPermission.Unsupported,
+ )
+ override val permission: StateFlow = _permission.asStateFlow()
+ private var trayIcon: TrayIcon? = null
+
+ override suspend fun refreshPermission(): NotificationPermission = permission.value
+ override suspend fun requestPermission(): NotificationPermission = permission.value
+ override suspend fun openSettings(): Result = Result.failure(
+ UnsupportedOperationException("Notification settings are not available on this platform"),
+ )
+
+ override suspend fun publish(notification: LocalNotification): Result = runCatching {
+ check(permission.value == NotificationPermission.Granted) { "System notifications are not supported" }
+ val icon = trayIcon ?: createTrayIcon().also {
+ SystemTray.getSystemTray().add(it)
+ trayIcon = it
+ }
+ icon.displayMessage(notification.title, notification.body, TrayIcon.MessageType.INFO)
+ }
+
+ override suspend fun cancel(id: String) = Unit
+
+ override suspend fun cancelAll() {
+ trayIcon?.let { SystemTray.getSystemTray().remove(it) }
+ trayIcon = null
+ }
+
+ private fun createTrayIcon(): TrayIcon {
+ val image = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
+ val graphics = image.graphics as Graphics2D
+ try {
+ graphics.color = Color(83, 82, 237)
+ graphics.fillOval(2, 2, 28, 28)
+ graphics.color = Color.WHITE
+ graphics.fillRect(14, 8, 4, 16)
+ } finally {
+ graphics.dispose()
+ }
+ return TrayIcon(image, "VniDrop").apply {
+ isImageAutoSize = true
+ addActionListener {
+ Frame.getFrames().firstOrNull { it.isDisplayable }?.let { frame ->
+ frame.isVisible = true
+ frame.toFront()
+ }
+ }
+ }
+ }
+}