diff --git a/gradle.properties b/gradle.properties index ee114b2..ca41477 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,4 +7,8 @@ org.gradle.configuration-cache=true org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +#Compose Multiplatform +org.jetbrains.compose.experimental.macos.enabled=true +#Kotlin/Native +kotlin.native.ignoreDisabledTargets=true \ No newline at end of file diff --git a/iosApp/execution b/iosApp/execution new file mode 100644 index 0000000..5ef9bb0 Binary files /dev/null and b/iosApp/execution differ diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index af35cb2..a1133c8 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,7 +11,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - D262C3356B38E7E13FFE64E4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + D262C3356B38E7E13FFE64E4 /* Exceptions for "iosApp" folder in "iosApp" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -23,21 +23,13 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 24D66E9CCFCEB2D844328B79 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; - explicitFileTypes = { - }; - explicitFolders = ( - ); path = Configuration; sourceTree = ""; }; 55D8B06C58654F251C5B3381 /* iosApp */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - D262C3356B38E7E13FFE64E4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, - ); - explicitFileTypes = { - }; - explicitFolders = ( + D262C3356B38E7E13FFE64E4 /* Exceptions for "iosApp" folder in "iosApp" target */, ); path = iosApp; sourceTree = ""; @@ -84,6 +76,7 @@ 5BCD356C334571BCAA0967CA /* Sources */, C2B5A146484C5EC128AEFD65 /* Frameworks */, 96EB3DBC5DECE4CD4D246992 /* Resources */, + 1DADA2DD199949A498015FFD /* Sanitize Moko Resource Bundle Names */, ); buildRules = ( ); @@ -181,6 +174,24 @@ shellPath = /bin/sh; shellScript = "\"$SRCROOT/../gradlew\" -p \"$SRCROOT/../\" :shared:copyFrameworkResourcesToApp \\\n -Pmoko.resources.PLATFORM_NAME=\"$PLATFORM_NAME\" \\\n -Pmoko.resources.CONFIGURATION=\"$CONFIGURATION\" \\\n -Pmoko.resources.ARCHS=\"$ARCHS\" \\\n -Pmoko.resources.BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\\n -Pmoko.resources.CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \n"; }; + 1DADA2DD199949A498015FFD /* Sanitize Moko Resource Bundle Names */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Sanitize Moko Resource Bundle Names"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$PLATFORM_NAME\" != \"macosx\" ]; then\n exit 0\nfi\n# moko-resources names its resource bundle after the Kotlin klib unique_name (group:module),\n# e.g. SatsPrice:shared.bundle, and drops it directly under Contents/. Colons break\n# codesign, and a loose bundle outside Contents/Resources isn't recognized as a proper\n# nested bundle either, so the final app-level CodeSign phase fails with \"code object is\n# not signed at all\" unless we rename and relocate it. NSBundle resolves this bundle by\n# directory identity, not by name/path, so moko-resources/localization lookups still work.\nfind \"$CODESIGNING_FOLDER_PATH\" -type d -name \"*:*.bundle\" | while IFS= read -r bundle; do\n safe_name=$(basename \"$bundle\" | tr ':' '_')\n mkdir -p \"$CODESIGNING_FOLDER_PATH/Contents/Resources\"\n dest=\"$CODESIGNING_FOLDER_PATH/Contents/Resources/$safe_name\"\n rm -rf \"$dest\"\n mv \"$bundle\" \"$dest\"\ndone\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -200,18 +211,21 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = iosApp/macOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = S99A5B637C; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad[sdk=iphone*]" = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone[sdk=iphone*]" = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -270,11 +284,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -334,12 +350,14 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -351,18 +369,21 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = iosApp/macOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = S99A5B637C; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*]" = YES; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad[sdk=iphone*]" = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone[sdk=iphone*]" = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 27c8f2e..43169bd 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -6,7 +6,7 @@ struct ContentView: View { @State private var showCurrencyPicker = false var body: some View { - NavigationView { + NavigationStack { Group { if let state = viewModel.state { form(for: state) @@ -121,13 +121,18 @@ struct ContentView: View { onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) } ) } + #if os(iOS) .onDelete { indexSet in for index in indexSet { viewModel.onFiatCurrencyToggled(state.fiatRows[index].code) } } + #endif } } + #if os(macOS) + .formStyle(.grouped) + #endif .sheet(isPresented: $showCurrencyPicker) { CurrencyPickerSheet( currencies: state.availableCurrencies, @@ -142,7 +147,7 @@ struct ContentView: View { private func amountRow( label: String, value: String, - keyboardType: UIKeyboardType, + keyboardType: NumericFieldKeyboard, sanitize: @escaping (String) -> String, onChange: @escaping (String) -> Void ) -> some View { @@ -150,7 +155,7 @@ struct ContentView: View { Text(label) Spacer() NumericField( - placeholder: label, + placeholder: "", value: value, keyboardType: keyboardType, sanitize: sanitize, @@ -170,7 +175,7 @@ struct ContentView: View { private struct NumericField: View { let placeholder: String let value: String - let keyboardType: UIKeyboardType + let keyboardType: NumericFieldKeyboard let sanitize: (String) -> String let onChange: (String) -> Void var alignment: TextAlignment = .leading @@ -179,10 +184,15 @@ private struct NumericField: View { var body: some View { TextField(placeholder, text: $text) - .keyboardType(keyboardType) + #if os(iOS) + .keyboardType(keyboardType.uiKeyboardType) + #endif .multilineTextAlignment(alignment) + #if os(macOS) + .frame(maxWidth: 140) + #endif .onAppear { text = value } - .onChange(of: text) { _, newValue in + .onChange(of: text) { newValue in let sanitized = sanitize(newValue) if sanitized != newValue { text = sanitized @@ -191,7 +201,7 @@ private struct NumericField: View { onChange(sanitized) } } - .onChange(of: value) { _, newValue in + .onChange(of: value) { newValue in if newValue != text { text = newValue } @@ -216,3 +226,21 @@ private func sanitizeDecimalInput(_ raw: String) -> String { private func sanitizeIntegerInput(_ raw: String) -> String { raw.filter { $0.isNumber } } + +/// `UIKeyboardType` doesn't exist on macOS (no on-screen keyboard), so this stands in for it +/// across both platforms; only iOS actually applies it, via `uiKeyboardType` below. +private enum NumericFieldKeyboard { + case decimalPad + case numberPad +} + +#if os(iOS) +private extension NumericFieldKeyboard { + var uiKeyboardType: UIKeyboardType { + switch self { + case .decimalPad: return .decimalPad + case .numberPad: return .numberPad + } + } +} +#endif diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index 0be08b3..dac2ff9 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -10,7 +10,7 @@ struct CurrencyPickerSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { - NavigationView { + NavigationStack { List(currencies, id: \.code) { info in Button { onToggle(info.code) @@ -27,13 +27,21 @@ struct CurrencyPickerSheet: View { } } .navigationTitle(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) + #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #endif .toolbar { ToolbarItem(placement: .confirmationAction) { Button(IosLocalizationKt.localizedString(resource: MR.strings.shared.done)) { dismiss() } } } } + #if os(macOS) + // macOS sizes a .sheet() to its content's ideal size rather than the parent window's + // size (unlike iOS, which presents modally full-size); without an explicit frame here, + // the List has no size to lay out rows in and renders empty. + .frame(minWidth: 420, minHeight: 480) + #endif } private func currencyLabel(for info: CurrencyInfo) -> String { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 927e0b9..e2d9040 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -5,6 +5,9 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView() + #if os(macOS) + .frame(minWidth: 420, minHeight: 500) + #endif } } } diff --git a/iosApp/iosApp/macOS.entitlements b/iosApp/iosApp/macOS.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/iosApp/iosApp/macOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 65478d1..09bc074 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -13,9 +13,10 @@ plugins { kotlin { listOf( iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { + iosSimulatorArm64(), + macosArm64() + ).forEach { appleTarget -> + appleTarget.binaries.framework { baseName = "Shared" isStatic = true export(libs.moko.resources) @@ -87,7 +88,7 @@ kotlin { implementation(libs.kotlinx.coroutinesTest) implementation(libs.ktor.client.mock) } - iosMain.dependencies { + appleMain.dependencies { implementation(libs.ktor.client.darwin) } jvmMain.dependencies { diff --git a/shared/src/iosMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt similarity index 100% rename from shared/src/iosMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt rename to shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt diff --git a/shared/src/iosMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.ios.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt similarity index 100% rename from shared/src/iosMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.ios.kt rename to shared/src/appleMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.apple.kt diff --git a/shared/src/iosMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.ios.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.apple.kt similarity index 100% rename from shared/src/iosMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.ios.kt rename to shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.apple.kt diff --git a/shared/src/iosMain/kotlin/xyz/tyiu/satsprice/ui/IosLocalization.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/IosLocalization.kt similarity index 100% rename from shared/src/iosMain/kotlin/xyz/tyiu/satsprice/ui/IosLocalization.kt rename to shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/IosLocalization.kt diff --git a/shared/src/macosMain/kotlin/xyz/tyiu/satsprice/Platform.macos.kt b/shared/src/macosMain/kotlin/xyz/tyiu/satsprice/Platform.macos.kt new file mode 100644 index 0000000..3219074 --- /dev/null +++ b/shared/src/macosMain/kotlin/xyz/tyiu/satsprice/Platform.macos.kt @@ -0,0 +1,9 @@ +package xyz.tyiu.satsprice + +import platform.Foundation.NSProcessInfo + +class MacOSPlatform : Platform { + override val name: String = "macOS " + NSProcessInfo.processInfo.operatingSystemVersionString +} + +actual fun getPlatform(): Platform = MacOSPlatform()