Replace Skip implementation with Kotlin Multiplatform implementation

This commit is contained in:
2026-09-02 22:11:32 +03:00
parent 902b683779
commit abf7cf2e83
184 changed files with 7163 additions and 2449 deletions
+7
View File
@@ -0,0 +1,7 @@
TEAM_ID=
PRODUCT_NAME=SatsPrice
PRODUCT_BUNDLE_IDENTIFIER=xyz.tyiu.SatsPrice
CURRENT_PROJECT_VERSION=11
MARKETING_VERSION=2.0.0
+400
View File
@@ -0,0 +1,400 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
D44B8BA1944015B589E35F36 /* SatsPrice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SatsPrice.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D262C3356B38E7E13FFE64E4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3739940346DC183122FC20F8 /* iosApp */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
24D66E9CCFCEB2D844328B79 /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
explicitFileTypes = {
};
explicitFolders = (
);
path = Configuration;
sourceTree = "<group>";
};
55D8B06C58654F251C5B3381 /* iosApp */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D262C3356B38E7E13FFE64E4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = iosApp;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
C2B5A146484C5EC128AEFD65 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5941F29C28B5C326171BDC0A = {
isa = PBXGroup;
children = (
24D66E9CCFCEB2D844328B79 /* Configuration */,
55D8B06C58654F251C5B3381 /* iosApp */,
BD6824195A13970C074F9FAE /* Products */,
);
sourceTree = "<group>";
};
BD6824195A13970C074F9FAE /* Products */ = {
isa = PBXGroup;
children = (
D44B8BA1944015B589E35F36 /* SatsPrice.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3739940346DC183122FC20F8 /* iosApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = B1A19F3C0B96B70D9AB9599E /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
20CB400CC39EEB23909EAA0F /* Compile Kotlin Framework */,
BC432998CDD86B2CBAA34707 /* Copy Moko Resources */,
5BCD356C334571BCAA0967CA /* Sources */,
C2B5A146484C5EC128AEFD65 /* Frameworks */,
96EB3DBC5DECE4CD4D246992 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
55D8B06C58654F251C5B3381 /* iosApp */,
);
name = iosApp;
packageProductDependencies = (
);
productName = iosApp;
productReference = D44B8BA1944015B589E35F36 /* SatsPrice.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
8882155D13AD2F794C45B4D1 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
3739940346DC183122FC20F8 = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = 3FD2DE231BB18DD5222C67BE /* Build configuration list for PBXProject "iosApp" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 5941F29C28B5C326171BDC0A;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = BD6824195A13970C074F9FAE /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
3739940346DC183122FC20F8 /* iosApp */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
96EB3DBC5DECE4CD4D246992 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
20CB400CC39EEB23909EAA0F /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Compile Kotlin Framework";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
};
BC432998CDD86B2CBAA34707 /* Copy Moko Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Copy Moko Resources";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
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";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5BCD356C334571BCAA0967CA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
ABCADF8A4EA6428EC4290AD1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
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";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EFC1D6A447B1BE51E6D06B01 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 24D66E9CCFCEB2D844328B79 /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F40BE5646F9865EB7D8E4EAA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 24D66E9CCFCEB2D844328B79 /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
F92E24F6E6DFD929A8901A4E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
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";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3FD2DE231BB18DD5222C67BE /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F40BE5646F9865EB7D8E4EAA /* Debug */,
EFC1D6A447B1BE51E6D06B01 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B1A19F3C0B96B70D9AB9599E /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ABCADF8A4EA6428EC4290AD1 /* Debug */,
F92E24F6E6DFD929A8901A4E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 8882155D13AD2F794C45B4D1 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,36 @@
{
"images": [
{
"filename": "app-icon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+218
View File
@@ -0,0 +1,218 @@
import Shared
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = ConverterViewModel()
@State private var showCurrencyPicker = false
var body: some View {
NavigationView {
Group {
if let state = viewModel.state {
form(for: state)
} else {
ProgressView()
}
}
.navigationTitle("SatsPrice")
}
}
@ViewBuilder
private func form(for state: IosConverterState) -> some View {
Form {
Section(
header: Text(IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.btc_to_currency,
args: [state.defaultCurrencyCode]
)),
footer: Text(state.statusLine)
) {
Picker(
IosLocalizationKt.localizedString(resource: MR.strings.shared.price_source),
selection: Binding(
get: { state.sourceName },
set: { viewModel.onSourceSelected($0) }
)
) {
ForEach(viewModel.sourceNames, id: \.self) { name in
Text(name).tag(name)
}
}
HStack {
if state.isManualSource {
NumericField(
placeholder: IosLocalizationKt.localizedString(resource: MR.strings.shared.rate_label),
value: state.manualRateInput,
keyboardType: .decimalPad,
sanitize: sanitizeDecimalInput,
onChange: { viewModel.onManualRateChanged($0) }
)
} else if !state.defaultCurrencyRate.isEmpty {
Text(state.defaultCurrencyRate)
Spacer()
} else {
Spacer()
}
if state.isLoading {
ProgressView()
} else {
Button {
viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
}
}
if let error = state.errorMessage {
Section {
HStack {
Text(error).foregroundColor(.red)
Spacer()
Button(IosLocalizationKt.localizedString(resource: MR.strings.shared.retry)) { viewModel.refresh() }
}
}
}
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.bitcoin_section_title)) {
amountRow(
label: IosLocalizationKt.localizedString(resource: MR.strings.shared.sats_label),
value: state.satsAmount,
keyboardType: .numberPad,
sanitize: sanitizeIntegerInput,
onChange: { viewModel.onSatsAmountChanged($0) }
)
amountRow(
label: IosLocalizationKt.localizedString(resource: MR.strings.shared.btc_label),
value: state.btcAmount,
keyboardType: .decimalPad,
sanitize: sanitizeDecimalInput,
onChange: { viewModel.onBtcAmountChanged($0) }
)
if state.exceedsMaxSupply {
Text(IosLocalizationKt.localizedString(resource: MR.strings.shared.exceeds_max_supply))
.font(.caption)
.foregroundColor(.red)
}
}
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
Button(
state.selectedCurrencyCodes.isEmpty
? IosLocalizationKt.localizedString(resource: MR.strings.shared.add_currency)
: IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currencies_selected_count,
args: [state.selectedCurrencyCodes.count]
)
) {
showCurrencyPicker = true
}
ForEach(state.fiatRows, id: \.code) { row in
amountRow(
label: row.code,
value: row.amount,
keyboardType: .decimalPad,
sanitize: sanitizeDecimalInput,
onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) }
)
}
.onDelete { indexSet in
for index in indexSet {
viewModel.onFiatCurrencyToggled(state.fiatRows[index].code)
}
}
}
}
.sheet(isPresented: $showCurrencyPicker) {
CurrencyPickerSheet(
currencies: state.availableCurrencies,
selectedCodes: state.selectedCurrencyCodes,
localeCurrencyCode: state.localeCurrencyCode,
onToggle: { viewModel.onFiatCurrencyToggled($0) }
)
}
}
@ViewBuilder
private func amountRow(
label: String,
value: String,
keyboardType: UIKeyboardType,
sanitize: @escaping (String) -> String,
onChange: @escaping (String) -> Void
) -> some View {
HStack {
Text(label)
Spacer()
NumericField(
placeholder: label,
value: value,
keyboardType: keyboardType,
sanitize: sanitize,
onChange: onChange,
alignment: .trailing
)
}
}
}
/// A text field whose displayed text is always forced back to [sanitize]'s output, even for
/// characters typed via a hardware keyboard or pasted in `.keyboardType` alone only restricts
/// the on-screen keyboard's layout, and a `TextField` bound purely to external/async state
/// (like the shared Kotlin `PriceViewModel`'s `StateFlow`) doesn't reliably self-correct what a
/// user has already typed into its own internal editing buffer. Local `@State` does, since
/// SwiftUI re-renders a view synchronously whenever its own `@State` changes.
private struct NumericField: View {
let placeholder: String
let value: String
let keyboardType: UIKeyboardType
let sanitize: (String) -> String
let onChange: (String) -> Void
var alignment: TextAlignment = .leading
@State private var text: String = ""
var body: some View {
TextField(placeholder, text: $text)
.keyboardType(keyboardType)
.multilineTextAlignment(alignment)
.onAppear { text = value }
.onChange(of: text) { _, newValue in
let sanitized = sanitize(newValue)
if sanitized != newValue {
text = sanitized
}
if sanitized != value {
onChange(sanitized)
}
}
.onChange(of: value) { _, newValue in
if newValue != text {
text = newValue
}
}
}
}
private func sanitizeDecimalInput(_ raw: String) -> String {
var result = ""
var seenDot = false
for char in raw {
if char.isNumber {
result.append(char)
} else if char == "." && !seenDot {
result.append(char)
seenDot = true
}
}
return result
}
private func sanitizeIntegerInput(_ raw: String) -> String {
raw.filter { $0.isNumber }
}
+45
View File
@@ -0,0 +1,45 @@
import Foundation
import Shared
@MainActor
final class ConverterViewModel: ObservableObject {
@Published private(set) var state: IosConverterState?
private let bridge = IosPriceViewModel()
var sourceNames: [String] { bridge.sourceNames }
init() {
bridge.observeState { [weak self] newState in
self?.state = newState
}
}
func refresh() {
bridge.refresh()
}
func onBtcAmountChanged(_ value: String) {
bridge.onBtcAmountChanged(value: value)
}
func onSatsAmountChanged(_ value: String) {
bridge.onSatsAmountChanged(value: value)
}
func onFiatAmountChanged(code: String, value: String) {
bridge.onFiatAmountChanged(code: code, value: value)
}
func onFiatCurrencyToggled(_ code: String) {
bridge.onFiatCurrencyToggled(code: code)
}
func onSourceSelected(_ name: String) {
bridge.onSourceSelected(name: name)
}
func onManualRateChanged(_ value: String) {
bridge.onManualRateChanged(value: value)
}
}
+52
View File
@@ -0,0 +1,52 @@
import Shared
import SwiftUI
struct CurrencyPickerSheet: View {
let currencies: [CurrencyInfo]
let selectedCodes: [String]
let localeCurrencyCode: String?
let onToggle: (String) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
List(currencies, id: \.code) { info in
Button {
onToggle(info.code)
} label: {
HStack {
Text(currencyLabel(for: info))
.foregroundColor(.primary)
Spacer()
if selectedCodes.contains(info.code) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
.navigationTitle(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(IosLocalizationKt.localizedString(resource: MR.strings.shared.done)) { dismiss() }
}
}
}
}
private func currencyLabel(for info: CurrencyInfo) -> String {
if info.code == localeCurrencyCode {
return IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label_local,
args: [info.code, info.displayName]
)
} else {
return IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label,
args: [info.code, info.displayName]
)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}