From a15be5787daa19c31ded9d22dd92736b17e4a4d2 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 10 Jul 2026 18:19:24 +0200 Subject: [PATCH] refactor(ui): establish modular Compose foundation --- androidApp/src/main/AndroidManifest.xml | 1 + .../kotlin/com/vnidrop/app/MainActivity.kt | 14 +- .../src/main/kotlin/com/vnidrop/app/main.kt | 2 +- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 5 + shared/src/androidMain/AndroidManifest.xml | 5 + .../com/vnidrop/app/Platform.android.kt | 100 +-- .../vnidrop/app/core/FilePicker.android.kt | 32 - .../app/core/FileSystemService.android.kt | 17 + .../LocalNotificationService.android.kt | 168 ++++ .../composeResources/values/strings.xml | 13 + .../commonMain/kotlin/com/vnidrop/app/App.kt | 184 ++--- .../kotlin/com/vnidrop/app/AppGraph.kt | 51 ++ .../kotlin/com/vnidrop/app/Greeting.kt | 9 - .../kotlin/com/vnidrop/app/GreetingUtil.kt | 4 - .../kotlin/com/vnidrop/app/Platform.kt | 26 +- .../com/vnidrop/app/VniDropAppViewModel.kt | 354 --------- .../kotlin/com/vnidrop/app/core/CoreModels.kt | 104 +++ .../com/vnidrop/app/core/CoreRepository.kt | 264 ++++--- .../kotlin/com/vnidrop/app/core/FilePicker.kt | 7 - .../com/vnidrop/app/core/FileSystemService.kt | 6 + .../vnidrop/app/core/PlatformFileAccess.kt | 2 +- .../vnidrop/app/feature/app/AppViewModel.kt | 58 ++ .../feature/approvals/ApprovalBannerHost.kt | 58 ++ .../feature/approvals/ApprovalCoordinator.kt | 165 ++++ .../app/feature/receive/ReceiveRoute.kt | 19 + .../app/feature/receive/ReceiveScreen.kt | 76 ++ .../app/feature/receive/ReceiveViewModel.kt | 91 +++ .../com/vnidrop/app/feature/send/SendRoute.kt | 45 ++ .../vnidrop/app/feature/send/SendScreen.kt | 125 +++ .../vnidrop/app/feature/send/SendViewModel.kt | 108 +++ .../app/feature/settings/AboutSettings.kt | 55 ++ .../feature/settings/AppearanceSettings.kt | 66 ++ .../feature/settings/NotificationSettings.kt | 65 ++ .../feature/settings/PreferencesSettings.kt | 66 ++ .../feature/settings/SettingsComponents.kt | 216 ++++++ .../app/feature/settings/SettingsIcons.kt | 143 ++++ .../app/feature/settings/SettingsOverview.kt | 75 ++ .../app/feature/settings/SettingsRoute.kt | 32 + .../app/feature/settings/SettingsScreen.kt | 88 +++ .../app/feature/settings/SettingsViewModel.kt | 206 +++++ .../notifications/LocalNotificationService.kt | 27 + .../com/vnidrop/app/platform/AppVisibility.kt | 14 + .../preferences/AppPreferencesRepository.kt | 32 +- .../com/vnidrop/app/ui/components/AppCard.kt | 44 ++ .../com/vnidrop/app/ui/components/Buttons.kt | 42 + .../com/vnidrop/app/ui/components/Field.kt | 29 + .../com/vnidrop/app/ui/components/Status.kt | 39 + .../app/ui/components/TransferComponents.kt | 36 + .../app/ui/components/VniDropComponents.kt | 232 ------ .../app/ui/feedback/UiMessageController.kt | 54 ++ .../app/ui/feedback/VniDropSnackbarHost.kt | 71 ++ .../vnidrop/app/ui/screens/ReceiveScreen.kt | 93 --- .../vnidrop/app/ui/screens/ScreenSections.kt | 115 +-- .../vnidrop/app/ui/screens/SendEmptyState.kt | 10 +- .../com/vnidrop/app/ui/screens/SendScreen.kt | 173 ----- .../vnidrop/app/ui/screens/SettingsScreen.kt | 721 ------------------ .../com/vnidrop/app/ui/shell/AppShell.kt | 19 +- .../com/vnidrop/app/ui/state/AppUiModels.kt | 74 +- .../com/vnidrop/app/MainViewController.kt | 2 +- .../kotlin/com/vnidrop/app/Platform.ios.kt | 52 +- .../com/vnidrop/app/core/FilePicker.ios.kt | 9 - .../vnidrop/app/core/FileSystemService.ios.kt | 12 + .../LocalNotificationService.ios.kt | 99 +++ .../kotlin/com/vnidrop/app/Platform.jvm.kt | 65 +- .../com/vnidrop/app/core/FilePicker.jvm.kt | 9 - .../vnidrop/app/core/FileSystemService.jvm.kt | 7 + .../LocalNotificationService.jvm.kt | 63 ++ 68 files changed, 3103 insertions(+), 2167 deletions(-) create mode 100644 shared/src/androidMain/AndroidManifest.xml create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt 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() + } + } + } + } +}