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