refactor(ui): establish modular Compose foundation

This commit is contained in:
2026-07-10 18:19:24 +02:00
parent 382a015688
commit a15be5787d
68 changed files with 3103 additions and 2167 deletions

View File

@@ -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"

View File

@@ -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()
}

View File

@@ -12,7 +12,7 @@ fun main() {
onCloseRequest = ::exitApplication,
title = "vnidrop",
) {
App()
App(rememberJvmAppDependencies())
}
}
}

View File

@@ -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" }

View File

@@ -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)
}
}
}

View 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>

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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)
}
}
}
}

View 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()
}
}

View File

@@ -1,9 +0,0 @@
package com.vnidrop.app
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return sayHello(platform.name)
}
}

View File

@@ -1,4 +0,0 @@
package com.vnidrop.app
fun sayHello(to: String): String =
"Hello, $to!"

View File

@@ -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,
)

View File

@@ -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"

View 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>
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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) }
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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"

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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) }
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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) })
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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

View File

@@ -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
},
)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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? =

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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),
)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
),
)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 &&

View File

@@ -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))
},
)
}
}
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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('-', ' ')

View File

@@ -2,4 +2,4 @@ package com.vnidrop.app
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) }

View File

@@ -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()

View File

@@ -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,

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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()
}
}
}
}
}