refactor(ui): establish modular Compose foundation

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

View File

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

View File

@@ -1,28 +1,48 @@
package com.vnidrop.app
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.IosLocalNotificationService
import platform.Foundation.NSBundle
import platform.Foundation.NSTemporaryDirectory
import platform.UIKit.UIDevice
class IOSPlatform : Platform {
private val device: UIDevice = UIDevice.currentDevice
@Composable
fun rememberIosAppDependencies(): AppDependencies {
val fileSystemService = rememberFileSystemService()
return remember(fileSystemService) {
val device = UIDevice.currentDevice
AppDependencies(
environment = PlatformEnvironment(
name = device.systemName() + " " + device.systemVersion,
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop",
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
),
deviceInfoProvider = IosDeviceInfoProvider(device),
fileSystemService = fileSystemService,
localNotificationService = IosLocalNotificationService(),
)
}
}
override val name: String = device.systemName() + " " + device.systemVersion
override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop"
override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive"
override val deviceInfo: DeviceInfo = DeviceInfo(
private class IosDeviceInfoProvider(
private val device: UIDevice,
) : DeviceInfoProvider {
override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = device.name,
deviceModel = device.model,
operatingSystem = device.systemName() + " " + device.systemVersion,
network = null,
batteryLevel = batteryLevel(device),
batteryLevel = runCatching {
val wasMonitoring = device.batteryMonitoringEnabled
try {
device.batteryMonitoringEnabled = true
device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" }
} finally {
device.batteryMonitoringEnabled = wasMonitoring
}
}.getOrNull(),
)
}
actual fun getPlatform(): Platform = IOSPlatform()
private fun batteryLevel(device: UIDevice): String? =
runCatching {
device.batteryMonitoringEnabled = true
val level = device.batteryLevel
if (level >= 0.0) "${(level * 100).toInt()}%" else null
}.getOrNull()

View File

@@ -73,15 +73,6 @@ actual fun rememberReceiveFolderPicker(
}
}
actual suspend fun sharePickedFile(
repository: CoreRepository,
file: PickedShareFile,
transferName: String,
senderName: String,
) {
repository.shareSecurityScopedFileUrl(file.value, file.displayName, transferName, senderName)
}
private class DocumentPickerDelegate(
private val onFilePicked: (PickedShareFile) -> Unit,
private val onError: (String) -> Unit,

View File

@@ -42,6 +42,18 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
): Result<Share> = repository.shareSecurityScopedFileUrl(
file.value,
file.displayName,
transferName,
senderName,
)
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
val didStartAccess = url.startAccessingSecurityScopedResource()

View File

@@ -0,0 +1,99 @@
package com.vnidrop.app.notifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import platform.UIKit.UIApplicationOpenNotificationSettingsURLString
import platform.UserNotifications.UNAuthorizationOptionAlert
import platform.UserNotifications.UNAuthorizationOptionSound
import platform.UserNotifications.UNAuthorizationStatusAuthorized
import platform.UserNotifications.UNAuthorizationStatusDenied
import platform.UserNotifications.UNAuthorizationStatusEphemeral
import platform.UserNotifications.UNAuthorizationStatusNotDetermined
import platform.UserNotifications.UNAuthorizationStatusProvisional
import platform.UserNotifications.UNMutableNotificationContent
import platform.UserNotifications.UNNotificationRequest
import platform.UserNotifications.UNUserNotificationCenter
import kotlin.coroutines.resume
class IosLocalNotificationService : LocalNotificationService {
private val center = UNUserNotificationCenter.currentNotificationCenter()
private val _permission = MutableStateFlow(NotificationPermission.NotDetermined)
override val permission: StateFlow<NotificationPermission> = _permission.asStateFlow()
override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation ->
center.getNotificationSettingsWithCompletionHandler { settings ->
val mapped = when (settings?.authorizationStatus) {
UNAuthorizationStatusAuthorized,
UNAuthorizationStatusProvisional,
UNAuthorizationStatusEphemeral -> NotificationPermission.Granted
UNAuthorizationStatusDenied -> NotificationPermission.Denied
UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined
else -> NotificationPermission.Unsupported
}
_permission.value = mapped
if (continuation.isActive) continuation.resume(mapped)
}
}
override suspend fun requestPermission(): NotificationPermission {
val current = refreshPermission()
if (current != NotificationPermission.NotDetermined) return current
return suspendCancellableCoroutine { continuation ->
center.requestAuthorizationWithOptions(
options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound,
completionHandler = { granted, _ ->
val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
_permission.value = result
if (continuation.isActive) continuation.resume(result)
},
)
}
}
override suspend fun openSettings(): Result<Unit> {
val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
val opened = suspendCancellableCoroutine { continuation ->
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
if (continuation.isActive) continuation.resume(success)
}
}
return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings"))
}
override suspend fun publish(notification: LocalNotification): Result<Unit> = runCatching {
check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
val content = UNMutableNotificationContent().apply {
setTitle(notification.title)
setBody(notification.body)
setSound(platform.UserNotifications.UNNotificationSound.defaultSound)
}
val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null)
suspendCancellableCoroutine { continuation ->
center.addNotificationRequest(request) { error ->
if (!continuation.isActive) return@addNotificationRequest
if (error == null) {
continuation.resume(Unit)
} else {
continuation.resumeWith(
Result.failure(IllegalStateException(error.localizedDescription)),
)
}
}
}
}
override suspend fun cancel(id: String) {
center.removePendingNotificationRequestsWithIdentifiers(listOf(id))
center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
}
override suspend fun cancelAll() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
}