Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import com.tt.honeyDue.storage.TokenManager
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
import com.tt.honeyDue.storage.TaskCacheManager
|
||||
import com.tt.honeyDue.storage.TaskCacheStorage
|
||||
import com.tt.honeyDue.storage.ThemeStorage
|
||||
import com.tt.honeyDue.storage.ThemeStorageManager
|
||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||
|
||||
fun MainViewController() = ComposeUIViewController {
|
||||
// Initialize TokenStorage with iOS TokenManager
|
||||
TokenStorage.initialize(TokenManager.getInstance())
|
||||
|
||||
// Initialize TaskCacheStorage for offline task caching
|
||||
TaskCacheStorage.initialize(TaskCacheManager.getInstance())
|
||||
|
||||
// Initialize ThemeStorage and ThemeManager
|
||||
ThemeStorage.initialize(ThemeStorageManager.getInstance())
|
||||
ThemeManager.initialize()
|
||||
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import platform.UIKit.UIDevice
|
||||
|
||||
class IOSPlatform: Platform {
|
||||
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = IOSPlatform()
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tt.honeyDue.analytics
|
||||
|
||||
/**
|
||||
* iOS implementation of PostHog Analytics.
|
||||
* Note: The actual PostHog SDK calls are made from Swift code.
|
||||
* This is a stub that does nothing - iOS uses the native Swift wrapper directly.
|
||||
*/
|
||||
actual object PostHogAnalytics {
|
||||
actual fun initialize() {
|
||||
// iOS initialization is done in Swift via PostHogAnalytics.swift
|
||||
}
|
||||
|
||||
actual fun identify(userId: String, properties: Map<String, Any>?) {
|
||||
// iOS uses Swift PostHogAnalytics.shared.identify() directly
|
||||
}
|
||||
|
||||
actual fun capture(event: String, properties: Map<String, Any>?) {
|
||||
// iOS uses Swift PostHogAnalytics.shared.capture() directly
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// iOS uses Swift PostHogAnalytics.shared.screen() directly
|
||||
}
|
||||
|
||||
actual fun reset() {
|
||||
// iOS uses Swift PostHogAnalytics.shared.reset() directly
|
||||
}
|
||||
|
||||
actual fun flush() {
|
||||
// iOS uses Swift PostHogAnalytics.shared.flush() directly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
/**
|
||||
* iOS implementation of PersistenceManager using NSUserDefaults.
|
||||
*/
|
||||
actual class PersistenceManager {
|
||||
private val defaults = NSUserDefaults.standardUserDefaults
|
||||
|
||||
actual fun save(key: String, value: String) {
|
||||
defaults.setObject(value, forKey = key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
actual fun load(key: String): String? {
|
||||
return defaults.stringForKey(key)
|
||||
}
|
||||
|
||||
actual fun remove(key: String) {
|
||||
defaults.removeObjectForKey(key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
actual fun clear() {
|
||||
// Get all keys with our prefix and remove them
|
||||
val dict = defaults.dictionaryRepresentation()
|
||||
dict.keys.forEach { key ->
|
||||
val keyStr = key as? String ?: return@forEach
|
||||
if (keyStr.startsWith("dm_")) {
|
||||
defaults.removeObjectForKey(keyStr)
|
||||
}
|
||||
}
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val instance by lazy { PersistenceManager() }
|
||||
|
||||
fun getInstance(): PersistenceManager = instance
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.darwin.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import platform.Foundation.NSLocale
|
||||
import platform.Foundation.NSTimeZone
|
||||
import platform.Foundation.localTimeZone
|
||||
import platform.Foundation.preferredLanguages
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
actual fun getDeviceLanguage(): String {
|
||||
val preferredLanguages = NSLocale.preferredLanguages
|
||||
val firstLanguage = preferredLanguages.firstOrNull() as? String
|
||||
// Extract just the language code (e.g., "en" from "en-US")
|
||||
return firstLanguage?.split("-")?.firstOrNull() ?: "en"
|
||||
}
|
||||
|
||||
actual fun getDeviceTimezone(): String {
|
||||
return NSTimeZone.localTimeZone.name
|
||||
}
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(Darwin) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
// Only log full request/response bodies in local dev to avoid
|
||||
// leaking auth tokens and PII in production logs.
|
||||
level = if (ApiConfig.CURRENT_ENV == ApiConfig.Environment.LOCAL) LogLevel.ALL else LogLevel.INFO
|
||||
}
|
||||
|
||||
install(DefaultRequest) {
|
||||
headers.append("Accept-Language", getDeviceLanguage())
|
||||
headers.append("X-Timezone", getDeviceTimezone())
|
||||
}
|
||||
|
||||
engine {
|
||||
configureRequest {
|
||||
setAllowsCellularAccess(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
|
||||
/**
|
||||
* iOS implementation is a no-op - import is handled in Swift layer via ContractorSharingManager.swift.
|
||||
* The iOS iOSApp.swift handles file imports directly.
|
||||
*/
|
||||
@Composable
|
||||
actual fun ContractorImportHandler(
|
||||
pendingContractorImportUri: Any?,
|
||||
onClearContractorImport: () -> Unit,
|
||||
onImportSuccess: (Contractor) -> Unit
|
||||
) {
|
||||
// No-op on iOS - import handled in Swift layer
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.interop.LocalUIViewController
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.honeyDueShareCodec
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.*
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIViewController
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareContractor(): (Contractor) -> Unit {
|
||||
val viewController = LocalUIViewController.current
|
||||
|
||||
return share@{ contractor: Contractor ->
|
||||
val currentUsername = DataManager.currentUser.value?.username ?: "Unknown"
|
||||
val jsonContent = honeyDueShareCodec.encodeContractorPackage(contractor, currentUsername)
|
||||
val fileUrl = writeShareFile(jsonContent, contractor.name) ?: return@share
|
||||
presentShareSheet(viewController, fileUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun writeShareFile(jsonContent: String, displayName: String): NSURL? {
|
||||
val fileName = honeyDueShareCodec.safeShareFileName(displayName)
|
||||
val filePath = NSTemporaryDirectory().plus(fileName)
|
||||
|
||||
val bytes = jsonContent.encodeToByteArray()
|
||||
val data = bytes.usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||
}
|
||||
val didCreate = NSFileManager.defaultManager.createFileAtPath(
|
||||
path = filePath,
|
||||
contents = data,
|
||||
attributes = null
|
||||
)
|
||||
if (!didCreate) return null
|
||||
|
||||
return NSURL.fileURLWithPath(filePath)
|
||||
}
|
||||
|
||||
private fun presentShareSheet(viewController: UIViewController, fileUrl: NSURL) {
|
||||
val activityViewController = UIActivityViewController(
|
||||
activityItems = listOf(fileUrl),
|
||||
applicationActivities = null
|
||||
)
|
||||
viewController.presentViewController(
|
||||
viewControllerToPresent = activityViewController,
|
||||
animated = true,
|
||||
completion = null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* iOS implementation - no-op since iOS haptics are handled by SwiftUI.
|
||||
* This is only used when running the shared Compose code on iOS
|
||||
* (which isn't the primary iOS UI).
|
||||
*/
|
||||
class IOSHapticFeedbackPerformer : HapticFeedbackPerformer {
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// iOS haptic feedback is handled natively in SwiftUI views
|
||||
// This is a no-op for the Compose layer on iOS
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
return remember { IOSHapticFeedbackPerformer() }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val skiaImage = Image.makeFromEncoded(imageData.bytes)
|
||||
skiaImage.toComposeImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.interop.LocalUIViewController
|
||||
import kotlinx.cinterop.*
|
||||
import platform.Foundation.*
|
||||
import platform.PhotosUI.*
|
||||
import platform.UIKit.*
|
||||
import platform.darwin.NSObject
|
||||
import platform.posix.memcpy
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
val viewController = LocalUIViewController.current
|
||||
|
||||
val pickerDelegate = remember {
|
||||
object : NSObject(), PHPickerViewControllerDelegateProtocol {
|
||||
override fun picker(
|
||||
picker: PHPickerViewController,
|
||||
didFinishPicking: List<*>
|
||||
) {
|
||||
picker.dismissViewControllerAnimated(true, null)
|
||||
|
||||
val results = didFinishPicking as List<PHPickerResult>
|
||||
if (results.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val images = mutableListOf<ImageData>()
|
||||
var processedCount = 0
|
||||
|
||||
results.forEach { result ->
|
||||
val itemProvider = result.itemProvider
|
||||
|
||||
// Check if the item has an image using UTType
|
||||
if (itemProvider.hasItemConformingToTypeIdentifier("public.image")) {
|
||||
itemProvider.loadFileRepresentationForTypeIdentifier(
|
||||
typeIdentifier = "public.image",
|
||||
completionHandler = { url, error ->
|
||||
if (error == null && url != null) {
|
||||
// Read the image data from the file URL
|
||||
val imageData = NSData.dataWithContentsOfURL(url)
|
||||
if (imageData != null) {
|
||||
// Convert to UIImage and then to JPEG for consistent format
|
||||
val image = UIImage.imageWithData(imageData)
|
||||
if (image != null) {
|
||||
val jpegData = UIImageJPEGRepresentation(image, 0.8)
|
||||
if (jpegData != null) {
|
||||
val bytes = jpegData.toByteArray()
|
||||
val fileName = "image_${NSDate().timeIntervalSince1970.toLong()}_${processedCount}.jpg"
|
||||
synchronized(images) {
|
||||
images.add(ImageData(bytes, fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processedCount++
|
||||
|
||||
// When all images are processed, call the callback
|
||||
if (processedCount == results.size) {
|
||||
if (images.isNotEmpty()) {
|
||||
onImagesPicked(images.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
processedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
val config = PHPickerConfiguration().apply {
|
||||
setSelectionLimit(5)
|
||||
setFilter(PHPickerFilter.imagesFilter())
|
||||
}
|
||||
|
||||
val picker = PHPickerViewController(config).apply {
|
||||
setDelegate(pickerDelegate)
|
||||
}
|
||||
|
||||
viewController.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
return ByteArray(length.toInt()).apply {
|
||||
usePinned {
|
||||
memcpy(it.addressOf(0), bytes, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
@Composable
|
||||
actual fun rememberCameraPicker(
|
||||
onImageCaptured: (ImageData) -> Unit
|
||||
): () -> Unit {
|
||||
val viewController = LocalUIViewController.current
|
||||
|
||||
val cameraDelegate = remember {
|
||||
object : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol {
|
||||
override fun imagePickerController(
|
||||
picker: UIImagePickerController,
|
||||
didFinishPickingMediaWithInfo: Map<Any?, *>
|
||||
) {
|
||||
picker.dismissViewControllerAnimated(true, null)
|
||||
|
||||
val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
|
||||
if (image != null) {
|
||||
// Convert to JPEG
|
||||
val jpegData = UIImageJPEGRepresentation(image, 0.8)
|
||||
if (jpegData != null) {
|
||||
val bytes = jpegData.toByteArray()
|
||||
val fileName = "camera_${NSDate().timeIntervalSince1970.toLong()}.jpg"
|
||||
onImageCaptured(ImageData(bytes, fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
|
||||
picker.dismissViewControllerAnimated(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Check if camera is available
|
||||
if (UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera)) {
|
||||
val picker = UIImagePickerController().apply {
|
||||
setSourceType(UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera)
|
||||
setDelegate(cameraDelegate)
|
||||
setCameraCaptureMode(UIImagePickerControllerCameraCaptureMode.UIImagePickerControllerCameraCaptureModePhoto)
|
||||
}
|
||||
|
||||
viewController.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> synchronized(lock: Any, block: () -> T): T {
|
||||
return block()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.ui.subscription.UpgradeScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* iOS: Purchase flow is handled in Swift via StoreKitManager.
|
||||
* Restore calls backend to refresh subscription status.
|
||||
*/
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { _ ->
|
||||
// iOS purchase flow is handled by StoreKitManager in Swift layer
|
||||
onNavigateBack()
|
||||
},
|
||||
onRestorePurchases = {
|
||||
scope.launch {
|
||||
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||
onSubscriptionChanged()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
|
||||
/**
|
||||
* iOS implementation is a no-op - import is handled in Swift layer via ResidenceSharingManager.swift.
|
||||
* The iOS iOSApp.swift handles file imports directly.
|
||||
*/
|
||||
@Composable
|
||||
actual fun ResidenceImportHandler(
|
||||
pendingResidenceImportUri: Any?,
|
||||
onClearResidenceImport: () -> Unit,
|
||||
onImportSuccess: (JoinResidenceResponse) -> Unit
|
||||
) {
|
||||
// No-op on iOS - import handled in Swift layer
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.interop.LocalUIViewController
|
||||
import com.tt.honeyDue.models.honeyDueShareCodec
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.*
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIViewController
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
|
||||
val viewController = LocalUIViewController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf(ResidenceSharingState()) }
|
||||
|
||||
val shareFunction: (Residence) -> Unit = share@{ residence ->
|
||||
scope.launch {
|
||||
state = ResidenceSharingState(isLoading = true)
|
||||
|
||||
when (val result = APILayer.generateSharePackage(residence.id)) {
|
||||
is ApiResult.Success -> {
|
||||
val jsonContent = honeyDueShareCodec.encodeSharedResidence(result.data)
|
||||
val fileUrl = writeShareFile(jsonContent, residence.name)
|
||||
if (fileUrl == null) {
|
||||
state = ResidenceSharingState(isLoading = false, error = "Failed to create share package")
|
||||
return@launch
|
||||
}
|
||||
|
||||
state = ResidenceSharingState(isLoading = false)
|
||||
presentShareSheet(viewController, fileUrl)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
state = ResidenceSharingState(
|
||||
isLoading = false,
|
||||
error = result.message.ifBlank { "Failed to generate share package" }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
state = ResidenceSharingState(isLoading = false, error = "Failed to generate share package")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(state, shareFunction)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun writeShareFile(jsonContent: String, displayName: String): NSURL? {
|
||||
val fileName = honeyDueShareCodec.safeShareFileName(displayName)
|
||||
val filePath = NSTemporaryDirectory().plus(fileName)
|
||||
|
||||
val bytes = jsonContent.encodeToByteArray()
|
||||
val data = bytes.usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||
}
|
||||
val didCreate = NSFileManager.defaultManager.createFileAtPath(
|
||||
path = filePath,
|
||||
contents = data,
|
||||
attributes = null
|
||||
)
|
||||
if (!didCreate) return null
|
||||
|
||||
return NSURL.fileURLWithPath(filePath)
|
||||
}
|
||||
|
||||
private fun presentShareSheet(viewController: UIViewController, fileUrl: NSURL) {
|
||||
val activityViewController = UIActivityViewController(
|
||||
activityItems = listOf(fileUrl),
|
||||
applicationActivities = null
|
||||
)
|
||||
viewController.presentViewController(
|
||||
viewControllerToPresent = activityViewController,
|
||||
animated = true,
|
||||
completion = null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import platform.Foundation.NSUserDefaults
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* iOS implementation of TaskCacheManager using NSUserDefaults.
|
||||
* Note: iOS widget dirty flag is handled by native Swift WidgetDataManager
|
||||
* using App Groups shared storage, but we provide these methods for KMM compatibility.
|
||||
*/
|
||||
actual class TaskCacheManager {
|
||||
private val userDefaults = NSUserDefaults.standardUserDefaults
|
||||
|
||||
actual fun saveTasks(tasksJson: String) {
|
||||
userDefaults.setObject(tasksJson, KEY_TASKS)
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
actual fun getTasks(): String? {
|
||||
return userDefaults.stringForKey(KEY_TASKS)
|
||||
}
|
||||
|
||||
actual fun clearTasks() {
|
||||
userDefaults.removeObjectForKey(KEY_TASKS)
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tasks need refresh due to widget interactions.
|
||||
* Note: iOS primarily uses native Swift WidgetDataManager for this.
|
||||
*/
|
||||
actual fun areTasksDirty(): Boolean {
|
||||
return userDefaults.boolForKey(KEY_DIRTY_FLAG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark tasks as dirty (needs refresh).
|
||||
*/
|
||||
actual fun markTasksDirty() {
|
||||
userDefaults.setBool(true, KEY_DIRTY_FLAG)
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the dirty flag after tasks have been refreshed.
|
||||
*/
|
||||
actual fun clearDirtyFlag() {
|
||||
userDefaults.setBool(false, KEY_DIRTY_FLAG)
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TASKS = "cached_tasks"
|
||||
private const val KEY_DIRTY_FLAG = "tasks_dirty"
|
||||
|
||||
@Volatile
|
||||
private var instance: TaskCacheManager? = null
|
||||
|
||||
fun getInstance(): TaskCacheManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: TaskCacheManager().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for synchronization on iOS
|
||||
private fun <T> synchronized(lock: Any, block: () -> T): T {
|
||||
return block()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
internal actual fun getPlatformTaskCacheManager(): TaskCacheManager? {
|
||||
return TaskCacheManager.getInstance()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
/**
|
||||
* iOS implementation of theme storage using NSUserDefaults.
|
||||
*/
|
||||
actual class ThemeStorageManager {
|
||||
private val defaults = NSUserDefaults.standardUserDefaults
|
||||
|
||||
actual fun saveThemeId(themeId: String) {
|
||||
defaults.setObject(themeId, forKey = KEY_THEME_ID)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
actual fun getThemeId(): String? {
|
||||
return defaults.stringForKey(KEY_THEME_ID)
|
||||
}
|
||||
|
||||
actual fun clearThemeId() {
|
||||
defaults.removeObjectForKey(KEY_THEME_ID)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_THEME_ID = "theme_id"
|
||||
|
||||
private val instance by lazy { ThemeStorageManager() }
|
||||
|
||||
fun getInstance(): ThemeStorageManager = instance
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Protocol for iOS Keychain operations. Implemented in Swift (KeychainHelper)
|
||||
* and injected before DataManager initialization.
|
||||
*
|
||||
* Kotlin/Native cannot directly use the Security framework (SecItem* APIs)
|
||||
* because CFStringRef keys like kSecClass don't bridge to NSCopying.
|
||||
*/
|
||||
interface KeychainDelegate {
|
||||
fun save(key: String, value: String): Boolean
|
||||
fun get(key: String): String?
|
||||
fun delete(key: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS implementation of TokenManager.
|
||||
*
|
||||
* Uses iOS Keychain via [KeychainDelegate] for secure token storage.
|
||||
* If delegate is missing, operations fail closed (no insecure fallback).
|
||||
*
|
||||
* On first read, migrates any existing NSUserDefaults token to Keychain.
|
||||
*/
|
||||
actual class TokenManager {
|
||||
private val prefs = platform.Foundation.NSUserDefaults.standardUserDefaults
|
||||
|
||||
actual fun saveToken(token: String) {
|
||||
val delegate = keychainDelegate
|
||||
if (delegate != null) {
|
||||
delegate.save(TOKEN_KEY, token)
|
||||
// Clean up old NSUserDefaults entry if it exists
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
} else {
|
||||
// Fail closed: never store auth tokens in insecure storage.
|
||||
println("TokenManager: Keychain delegate not set, refusing to save token insecurely")
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getToken(): String? {
|
||||
val delegate = keychainDelegate
|
||||
|
||||
// Try Keychain first
|
||||
if (delegate != null) {
|
||||
val keychainToken = delegate.get(TOKEN_KEY)
|
||||
if (keychainToken != null) return keychainToken
|
||||
|
||||
// Check NSUserDefaults for migration
|
||||
val oldToken = prefs.stringForKey(TOKEN_KEY)
|
||||
if (oldToken != null) {
|
||||
// Migrate to Keychain
|
||||
delegate.save(TOKEN_KEY, oldToken)
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
return oldToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Fail closed: no insecure fallback reads.
|
||||
println("TokenManager: Keychain delegate not set, refusing insecure token read")
|
||||
return null
|
||||
}
|
||||
|
||||
actual fun clearToken() {
|
||||
keychainDelegate?.delete(TOKEN_KEY)
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TOKEN_KEY = "auth_token"
|
||||
|
||||
/**
|
||||
* Set from Swift in iOSApp.init() BEFORE DataManager.initialize().
|
||||
* This enables Keychain storage for auth tokens.
|
||||
*/
|
||||
var keychainDelegate: KeychainDelegate? = null
|
||||
|
||||
@Volatile
|
||||
private var instance: TokenManager? = null
|
||||
|
||||
fun getInstance(): TokenManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: TokenManager().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for synchronization on iOS
|
||||
private fun <T> synchronized(lock: Any, block: () -> T): T {
|
||||
return block()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
internal actual fun getPlatformTokenManager(): TokenManager? {
|
||||
return TokenManager.getInstance()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tt.honeyDue.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* iOS stub - Google Sign In is not available on iOS (use Apple Sign In instead)
|
||||
*/
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
// No-op on iOS - Apple Sign In is used instead
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.tt.honeyDue.util
|
||||
|
||||
import com.tt.honeyDue.platform.ImageData
|
||||
import kotlinx.cinterop.*
|
||||
import platform.Foundation.*
|
||||
import platform.UIKit.*
|
||||
import platform.posix.memcpy
|
||||
|
||||
/**
|
||||
* iOS implementation of image compression
|
||||
* Compresses images to JPEG format and ensures they don't exceed MAX_IMAGE_SIZE_BYTES
|
||||
*/
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual object ImageCompressor {
|
||||
/**
|
||||
* Compress an ImageData to JPEG format with size limit
|
||||
* @param imageData The image to compress
|
||||
* @return Compressed image data as ByteArray
|
||||
*/
|
||||
actual fun compressImage(imageData: ImageData): ByteArray {
|
||||
// Convert ByteArray to NSData
|
||||
val nsData = imageData.bytes.usePinned { pinned ->
|
||||
NSData.create(
|
||||
bytes = pinned.addressOf(0),
|
||||
length = imageData.bytes.size.toULong()
|
||||
)
|
||||
}
|
||||
|
||||
// Create UIImage from data
|
||||
val image = UIImage.imageWithData(nsData) ?: return imageData.bytes
|
||||
|
||||
// Compress with iterative quality reduction
|
||||
return compressImageToTarget(image, ImageConfig.MAX_IMAGE_SIZE_BYTES.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a UIImage to target size
|
||||
*/
|
||||
private fun compressImageToTarget(image: UIImage, targetSizeBytes: Long): ByteArray {
|
||||
var quality = ImageConfig.INITIAL_JPEG_QUALITY / 100.0
|
||||
var compressedData: NSData? = null
|
||||
|
||||
while (quality >= ImageConfig.MIN_JPEG_QUALITY / 100.0) {
|
||||
compressedData = UIImageJPEGRepresentation(image, quality)
|
||||
|
||||
if (compressedData != null && compressedData.length.toLong() <= targetSizeBytes) {
|
||||
break
|
||||
}
|
||||
|
||||
quality -= 0.05 // Reduce quality by 5%
|
||||
}
|
||||
|
||||
// Convert NSData to ByteArray
|
||||
return compressedData?.toByteArray() ?: image.let {
|
||||
UIImageJPEGRepresentation(it, ImageConfig.MIN_JPEG_QUALITY / 100.0)?.toByteArray() ?: ByteArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NSData to ByteArray
|
||||
*/
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
return ByteArray(this.length.toInt()).apply {
|
||||
usePinned {
|
||||
memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user