mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
refactor(ui): establish modular Compose foundation
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -4,25 +4,13 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vnidrop.app.core.attachAndroidFilePickerContext
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
attachAndroidFilePickerContext(this)
|
||||
attachAndroidPlatformContext(this)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
App(rememberAndroidAppDependencies(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
App()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ fun main() {
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "vnidrop",
|
||||
) {
|
||||
App()
|
||||
App(rememberJvmAppDependencies())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,11 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ kotlin {
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.compose.uiTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
shared/src/androidMain/AndroidManifest.xml
Normal file
5
shared/src/androidMain/AndroidManifest.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
</manifest>
|
||||
@@ -5,49 +5,57 @@ 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 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
|
||||
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"
|
||||
@@ -55,15 +63,13 @@ private fun activeNetworkSummary(): String? =
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
|
||||
else -> networkInterfaceName()
|
||||
}
|
||||
}.getOrNull()
|
||||
}.getOrNull()
|
||||
|
||||
private fun batteryLevel(): String? =
|
||||
runCatching {
|
||||
val context = AndroidPlatformContextHolder.context ?: return@runCatching null
|
||||
val manager = context.getSystemService(BatteryManager::class.java)
|
||||
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()
|
||||
}.getOrNull()
|
||||
|
||||
private fun networkInterfaceName(): String? =
|
||||
NetworkInterface.getNetworkInterfaces()
|
||||
|
||||
@@ -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 <T : Any> errorIfNull(value: T?, message: String): T =
|
||||
value ?: error(message)
|
||||
|
||||
@@ -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<Share> = 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)
|
||||
|
||||
@@ -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<NotificationPermission> = _permission.asStateFlow()
|
||||
private var permissionContinuation: CancellableContinuation<NotificationPermission>? = 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<Unit> = 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<Unit> = 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"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
<string name="preferences_receive_folder_title">Receive folder</string>
|
||||
<string name="button_choose_folder">Choose folder</string>
|
||||
<string name="button_reset_default">Reset default</string>
|
||||
<string name="button_back">Back</string>
|
||||
<string name="folder_status_writable">Writable</string>
|
||||
<string name="folder_status_permission_required">Permission required</string>
|
||||
<string name="folder_status_unavailable">Unavailable</string>
|
||||
@@ -62,6 +63,18 @@
|
||||
<string name="network_title">Network</string>
|
||||
<string name="battery_level_title">Battery level</string>
|
||||
<string name="value_unavailable">Not available</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_local_title">Allow notifications</string>
|
||||
<string name="notifications_description">Let VniDrop notify you about new connection requests while the app is running in the background.</string>
|
||||
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
||||
<string name="notifications_unsupported">Notifications are not available on this device.</string>
|
||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||
<string name="button_open_settings">Open Settings</string>
|
||||
<string name="value_on">On</string>
|
||||
<string name="value_off">Off</string>
|
||||
<string name="approval_connection_request">Connection request</string>
|
||||
<string name="approval_pending_count">%1$d requests are waiting</string>
|
||||
<string name="core_status_ready">Ready</string>
|
||||
<string name="event_log_title">Event log</string>
|
||||
<string name="no_events">No events have been emitted yet.</string>
|
||||
|
||||
@@ -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()
|
||||
val appViewModel = viewModel {
|
||||
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
||||
}
|
||||
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 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)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ApprovalBannerHost(
|
||||
state = approvalState,
|
||||
onAccept = graph.approvalCoordinator::accept,
|
||||
onRefuse = graph.approvalCoordinator::refuse,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
AppShell(
|
||||
selectedDestination = state.app.destination,
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
selectedDestination = appState.destination,
|
||||
windowClass = windowClass,
|
||||
onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) },
|
||||
onDestinationSelected = appViewModel::selectDestination,
|
||||
overlay = {
|
||||
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
|
||||
},
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
Normal file
51
shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return sayHello(platform.name)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
fun sayHello(to: String): String =
|
||||
"Hello, $to!"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<VniDropAppState> = _state
|
||||
val coreState: StateFlow<CoreUiState> = repository.state
|
||||
|
||||
private val effects = Channel<VniDropAppEffect>(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> 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"
|
||||
104
shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
Normal file
104
shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
Normal file
@@ -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<CoreEventModel> = emptyList(),
|
||||
val transfers: List<Transfer> = emptyList(),
|
||||
val lastShare: Share? = null,
|
||||
val lastInspection: TicketInspectionModel? = null,
|
||||
)
|
||||
|
||||
sealed interface CoreSignal {
|
||||
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
||||
}
|
||||
|
||||
interface CoreGateway {
|
||||
val state: StateFlow<CoreState>
|
||||
val signals: SharedFlow<CoreSignal>
|
||||
|
||||
suspend fun initialize(appDataDir: String): Result<Unit>
|
||||
fun shutdown()
|
||||
suspend fun sharePath(path: String, transferName: String, senderName: String): Result<Share>
|
||||
suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String): Result<Share>
|
||||
suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String): Result<Share>
|
||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
|
||||
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
|
||||
suspend fun refresh(): Result<Unit>
|
||||
}
|
||||
@@ -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<CoreEvent> = emptyList(),
|
||||
val transfers: List<StoredTransfer> = emptyList(),
|
||||
val lastShare: ShareResult? = null,
|
||||
val lastInspection: TicketInspection? = null,
|
||||
val receiverRequests: List<ReceiverRequest> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class CoreRepository(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
private val _state = MutableStateFlow(CoreUiState())
|
||||
val state: StateFlow<CoreUiState> = _state
|
||||
) : CoreGateway {
|
||||
private val _state = MutableStateFlow(CoreState())
|
||||
override val state: StateFlow<CoreState> = _state.asStateFlow()
|
||||
|
||||
private val _signals = MutableSharedFlow<CoreSignal>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val signals: SharedFlow<CoreSignal> = _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<Unit> = 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<Share> =
|
||||
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<Share> =
|
||||
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<Share> =
|
||||
shareSources(
|
||||
sources = listOf(
|
||||
ShareSource(
|
||||
@@ -120,99 +115,75 @@ class CoreRepository(
|
||||
transferName = transferName,
|
||||
senderName = senderName,
|
||||
)
|
||||
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = 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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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<List<ReceiverRequestModel>> = 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<Unit> = 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<Unit> = runCore { refreshSnapshot() }
|
||||
|
||||
private suspend fun shareSources(
|
||||
sources: List<ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
) {
|
||||
): Result<Share> = 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 <T> 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 <T> runCore(block: suspend () -> T): Result<T> =
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<Share>
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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 <T> withPlatformPathAccess(
|
||||
|
||||
@@ -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<AppState> = _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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PendingApproval> = emptyList(),
|
||||
val respondingIds: Set<String> = 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<ApprovalState> = _state.asStateFlow()
|
||||
|
||||
private val publishedNotificationIds = mutableSetOf<String>()
|
||||
|
||||
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<PendingApproval>,
|
||||
val permission: NotificationPermission,
|
||||
)
|
||||
|
||||
private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
|
||||
id = id,
|
||||
transferId = transferId,
|
||||
transferName = transferName,
|
||||
receiverName = receiverName,
|
||||
receiverDeviceName = receiverDeviceName,
|
||||
requestedAt = requestedAt,
|
||||
)
|
||||
|
||||
private fun Set<String>.addable(value: String): Boolean = value !in this
|
||||
|
||||
private fun notificationId(requestId: String): String = "approval-$requestId"
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<ReceiveState> = _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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SendState> = _state.asStateFlow()
|
||||
val coreState = repository.state
|
||||
|
||||
private val effects = Channel<SendEffect>(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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsState> = _state.asStateFlow()
|
||||
|
||||
private val effects = Channel<SettingsEffect>(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) }
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationPermission>
|
||||
|
||||
suspend fun refreshPermission(): NotificationPermission
|
||||
suspend fun requestPermission(): NotificationPermission
|
||||
suspend fun openSettings(): Result<Unit>
|
||||
suspend fun publish(notification: LocalNotification): Result<Unit>
|
||||
suspend fun cancel(id: String)
|
||||
suspend fun cancelAll()
|
||||
}
|
||||
@@ -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<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
}
|
||||
}
|
||||
@@ -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<AppPreferences>
|
||||
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<Preferences>,
|
||||
private val defaults: AppPreferencesDefaults,
|
||||
) {
|
||||
val preferences: Flow<AppPreferences> = dataStore.data
|
||||
) : PreferencesRepository {
|
||||
override val preferences: Flow<AppPreferences> = 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<Preferences> =
|
||||
@@ -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? =
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<UiMessage>(Channel.BUFFERED)
|
||||
val messages: Flow<UiMessage> = channel.receiveAsFlow()
|
||||
private val _dismissals = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val dismissals: SharedFlow<Unit> = _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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ReceiverRequest>,
|
||||
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<CoreEvent>) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<ReceiverRequest>,
|
||||
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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CoreEvent>): List<TransferProgress> =
|
||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||
events
|
||||
.filter { event -> event.transferId != null && event.phase in progressPhases }
|
||||
.distinctBy { event -> "${event.transferId}:${event.phase}" }
|
||||
@@ -106,7 +55,7 @@ fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
|
||||
)
|
||||
}
|
||||
|
||||
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('-', ' ')
|
||||
|
||||
@@ -2,4 +2,4 @@ package com.vnidrop.app
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Share> = 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()
|
||||
|
||||
@@ -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<NotificationPermission> = _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<Unit> {
|
||||
val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
|
||||
?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
|
||||
val opened = suspendCancellableCoroutine { continuation ->
|
||||
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { 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<Unit> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
batteryLevel = null,
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = JVMPlatform()
|
||||
|
||||
private fun activeNetworkSummary(): String? =
|
||||
runCatching {
|
||||
NetworkInterface.getNetworkInterfaces()
|
||||
.asSequence()
|
||||
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()
|
||||
}.getOrNull(),
|
||||
batteryLevel = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Share> = repository.sharePath(file.value, transferName, senderName)
|
||||
}
|
||||
|
||||
@@ -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<NotificationPermission> = _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<Unit> = Result.failure(
|
||||
UnsupportedOperationException("Notification settings are not available on this platform"),
|
||||
)
|
||||
|
||||
override suspend fun publish(notification: LocalNotification): Result<Unit> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user