From c3609cd33a5edc059f0af104e37457c3a7b8bf31 Mon Sep 17 00:00:00 2001 From: dankito Date: Wed, 16 Feb 2022 02:33:19 +0100 Subject: [PATCH] Implemented retrieving and displaying account transactions in iOS app --- .../fints4k iOS.xcodeproj/project.pbxproj | 67 +++++++++ .../fints4k iOS/fints4k iOS/ContentView.swift | 72 +++++++++- .../fints4k iOS/fints4k iOS/Presenter.swift | 28 ++++ .../service/UrlSessionWebClient.swift | 128 ++++++++++++++++++ .../fints4k iOS/ui/ViewExtensions.swift | 59 ++++++++ .../banking/fints/FinTsClientDeprecated.kt | 6 + .../callback/SimpleFinTsClientCallback.kt | 5 + .../utils/multiplatform/DateFormatter.kt | 2 + .../extensions/LocalDateExtensions.kt | 6 +- .../utils/multiplatform/DateFormatter.kt | 9 +- .../extensions/NSDateExtensions.kt | 99 ++++++++++++++ .../utils/multiplatform/DateFormatter.kt | 5 + .../utils/multiplatform/DateFormatter.kt | 5 + .../DateFormatter.kt | 5 + 14 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 SampleApplications/iOSApp/fints4k iOS/fints4k iOS/Presenter.swift create mode 100644 SampleApplications/iOSApp/fints4k iOS/fints4k iOS/service/UrlSessionWebClient.swift create mode 100644 SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ui/ViewExtensions.swift create mode 100644 multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/extensions/NSDateExtensions.kt diff --git a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS.xcodeproj/project.pbxproj b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS.xcodeproj/project.pbxproj index 246e9e65..c5d33e55 100644 --- a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS.xcodeproj/project.pbxproj +++ b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 36266AD727BC6F72008F3B00 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AD627BC6F72008F3B00 /* ContentView.swift */; }; 36266AD927BC6F74008F3B00 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 36266AD827BC6F74008F3B00 /* Assets.xcassets */; }; 36266ADC27BC6F74008F3B00 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 36266ADB27BC6F74008F3B00 /* Preview Assets.xcassets */; }; + 36266AE427BC7776008F3B00 /* Presenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE327BC7776008F3B00 /* Presenter.swift */; }; + 36266AE727BC7801008F3B00 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE627BC7801008F3B00 /* ViewExtensions.swift */; }; + 36266AEA27BC85D8008F3B00 /* UrlSessionWebClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -19,6 +22,9 @@ 36266AD627BC6F72008F3B00 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 36266AD827BC6F74008F3B00 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 36266ADB27BC6F74008F3B00 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 36266AE327BC7776008F3B00 /* Presenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presenter.swift; sourceTree = ""; }; + 36266AE627BC7801008F3B00 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; + 36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSessionWebClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,8 +57,11 @@ 36266AD327BC6F72008F3B00 /* fints4k iOS */ = { isa = PBXGroup; children = ( + 36266AE827BC85C9008F3B00 /* service */, + 36266AE527BC77E1008F3B00 /* ui */, 36266AD427BC6F72008F3B00 /* fints4k_iOSApp.swift */, 36266AD627BC6F72008F3B00 /* ContentView.swift */, + 36266AE327BC7776008F3B00 /* Presenter.swift */, 36266AD827BC6F74008F3B00 /* Assets.xcassets */, 36266ADA27BC6F74008F3B00 /* Preview Content */, ); @@ -67,6 +76,22 @@ path = "Preview Content"; sourceTree = ""; }; + 36266AE527BC77E1008F3B00 /* ui */ = { + isa = PBXGroup; + children = ( + 36266AE627BC7801008F3B00 /* ViewExtensions.swift */, + ); + path = ui; + sourceTree = ""; + }; + 36266AE827BC85C9008F3B00 /* service */ = { + isa = PBXGroup; + children = ( + 36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */, + ); + path = service; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -74,6 +99,7 @@ isa = PBXNativeTarget; buildConfigurationList = 36266ADF27BC6F74008F3B00 /* Build configuration list for PBXNativeTarget "fints4k iOS" */; buildPhases = ( + 36266AE227BC7189008F3B00 /* ShellScript */, 36266ACD27BC6F72008F3B00 /* Sources */, 36266ACE27BC6F72008F3B00 /* Frameworks */, 36266ACF27BC6F72008F3B00 /* Resources */, @@ -132,12 +158,35 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 36266AE227BC7189008F3B00 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd ../../..\n./gradlew fints4k:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 36266ACD27BC6F72008F3B00 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 36266AE427BC7776008F3B00 /* Presenter.swift in Sources */, 36266AD727BC6F72008F3B00 /* ContentView.swift in Sources */, + 36266AEA27BC85D8008F3B00 /* UrlSessionWebClient.swift in Sources */, + 36266AE727BC7801008F3B00 /* ViewExtensions.swift in Sources */, 36266AD527BC6F72008F3B00 /* fints4k_iOSApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -271,6 +320,10 @@ DEVELOPMENT_ASSET_PATHS = "\"fints4k iOS/Preview Content\""; DEVELOPMENT_TEAM = 7WVYN7QA7Z; ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "../../../fints4k/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "/../fints4k/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -282,6 +335,11 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + fints4k, + ); PRODUCT_BUNDLE_IDENTIFIER = "net.codinux.banking.fints.fints4k-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -300,6 +358,10 @@ DEVELOPMENT_ASSET_PATHS = "\"fints4k iOS/Preview Content\""; DEVELOPMENT_TEAM = 7WVYN7QA7Z; ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "../../../fints4k/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "/../fints4k/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -311,6 +373,11 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + fints4k, + ); PRODUCT_BUNDLE_IDENTIFIER = "net.codinux.banking.fints.fints4k-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ContentView.swift b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ContentView.swift index 935e3784..7d31bcf6 100644 --- a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ContentView.swift +++ b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ContentView.swift @@ -1,13 +1,81 @@ import SwiftUI +import fints4k struct ContentView: View { + + @State var transactions: [AccountTransaction] = [] + + private let presenter = Presenter() + + var body: some View { - Text("Hello, world!") - .padding() + VStack { + Button("Fetch transactions") { self.retrieveTransactions() } + .padding(.top, 6) + + List(self.transactions, id: \.self) { transaction in + HStack { + VStack(alignment: .leading) { + Text(transaction.otherPartyName ?? transaction.bookingText ?? "") + .font(.headline) + .lineLimit(1) + .frame(height: 20) + + Text(transaction.reference) + .styleAsDetail() + .padding(.top, 4) + .lineLimit(2) + .frame(height: 46, alignment: .center) + } + + Spacer() + + VStack(alignment: .trailing) { + + Text(presenter.formatAmount(transaction.amount)) + .styleAsDetail() + + Spacer() + + Text(presenter.formatDate(transaction.valueDate)) + .styleAsDetail() + + Spacer() + } + } + } + } } + + + private func retrieveTransactions() { + // TODO: set your credentials here + self.presenter.retrieveTransactions("", "", "", "", self.handleRetrieveTransactionsResult) + } + + private func handleRetrieveTransactionsResult(_ result: AddAccountResponse) { + NSLog("Retrieved response: \(result.retrievedTransactionsResponses)") + + if (result.successful) { + var allTransactions: [AccountTransaction] = [] + + for accountResponse in result.retrievedTransactionsResponses { + if let transactions = accountResponse.retrievedData?.bookedTransactions as? Set { // it's a Set + allTransactions.append(contentsOf: transactions) + } + if let transactions = accountResponse.retrievedData?.bookedTransactions as? [AccountTransaction] { + allTransactions.append(contentsOf: transactions) + } + } + + self.transactions = allTransactions + } + } + } + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/Presenter.swift b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/Presenter.swift new file mode 100644 index 00000000..4ed7d140 --- /dev/null +++ b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/Presenter.swift @@ -0,0 +1,28 @@ +import SwiftUI +import fints4k + +class Presenter { + + private let fintsClient = FinTsClientDeprecated(callback: SimpleFinTsClientCallback(), webClient: UrlSessionWebClient()) + + private let formatter = DateFormatter() + + + func retrieveTransactions(_ bankCode: String, _ customerId: String, _ pin: String, _ finTs3ServerAddress: String, _ callback: @escaping (AddAccountResponse) -> Void) { + self.fintsClient.addAccountAsync(parameter: AddAccountParameter(bankCode: bankCode, customerId: customerId, pin: pin, finTs3ServerAddress: finTs3ServerAddress), callback: callback) + } + + + func formatDate(_ date: Kotlinx_datetimeLocalDate) -> String { + formatter.dateStyle = .short + +// return self.formatter.string(from: date.toNSDate()) + + return date.description() + } + + func formatAmount(_ amount: Money) -> String { + return amount.description() + } + +} diff --git a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/service/UrlSessionWebClient.swift b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/service/UrlSessionWebClient.swift new file mode 100644 index 00000000..21a4132b --- /dev/null +++ b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/service/UrlSessionWebClient.swift @@ -0,0 +1,128 @@ +import SwiftUI +import fints4k + +class UrlSessionWebClient : IWebClient { + + func post(url: String, body: String, contentType: String, userAgent: String, callback: @escaping (WebClientResponse) -> Void) { + let request = requestFor(url, "POST", body) + + executeRequestAsync(request, callback) + } + + func getAsync(_ url: String, callback: @escaping (WebClientResponse) -> Void) { + let request = requestFor(url, "GET") + + executeRequestAsync(request, callback) + } + + + func get(_ url: String) -> WebClientResponse { + let request = requestFor(url, "GET") + + return executeRequestSynchronous(request) + } + + func getData(_ url: String) -> Data? { + let request = requestFor(url, "GET") + + return executeDataRequestSynchronous(request) + } + + func head(_ url: String) -> WebClientResponse { + let request = requestFor(url, "HEAD") + + return executeRequestSynchronous(request) + } + + + func requestFor(_ url: String, _ method: String, _ body: String? = nil) -> URLRequest { + guard let requestUrl = URL(string: url) else { fatalError() } + + var request = URLRequest(url: requestUrl) + request.httpMethod = method + + if let body = body { + request.httpBody = body.data(using: String.Encoding.utf8) + } + + return request + } + + func executeRequestAsync(_ request: URLRequest, _ callback: @escaping (WebClientResponse) -> Void) { + let dataTask = URLSession.shared.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in + // we have to dispatch response back to main thread as in Kotlin/Native objects can only be changed on one thread -> do all logic on main thread only network access in backbackground thread + DispatchQueue.main.async { + var webClientResponse = WebClientResponse(successful: false, responseCode: -1, error: KotlinException(message: error?.localizedDescription), body: nil) + + if let data = data, let httpResponse = response as? HTTPURLResponse { + webClientResponse = WebClientResponse(successful: true, responseCode: Int32(httpResponse.statusCode), error: nil, body: String(data: data, encoding: .ascii)) + } + + callback(webClientResponse) + } + } + + dataTask.resume() + } + + func executeRequestSynchronous(_ request: URLRequest) -> WebClientResponse { + var data: Data?, urlResponse: URLResponse?, error: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let dataTask = URLSession.shared.dataTask(with: request) { + data = $0 + urlResponse = $1 + error = $2 + + semaphore.signal() + } + + dataTask.resume() + + _ = semaphore.wait(timeout: .distantFuture) + + return mapResponse(data, urlResponse, error) + } + + private func mapResponse(_ data: Data?, _ response: URLResponse?, _ error: Error?) -> WebClientResponse { +// let mappedError = error == nil ? nil : KotlinException(message: error?.localizedDescription) // TODO: + + if let httpResponse = response as? HTTPURLResponse { + let statusCode = Int32(httpResponse.statusCode) +// let isSuccessful = mappedError == nil // TODO +// && statusCode >= 200 && statusCode <= 299 + let isSuccessful = statusCode >= 200 && statusCode <= 299 + + if let data = data { + return WebClientResponse(successful: isSuccessful, responseCode: statusCode, error: nil, body: String(data: data, encoding: .ascii)) + } + else { + return WebClientResponse(successful: isSuccessful, responseCode: statusCode, error: nil, body: nil) + } + } + else { + return WebClientResponse(successful: false, responseCode: -1, error: nil, body: nil) + } + } + + + func executeDataRequestSynchronous(_ request: URLRequest) -> Data? { + var data: Data? + + let semaphore = DispatchSemaphore(value: 0) + + let dataTask = URLSession.shared.dataTask(with: request) { receivedData, _, _ in + data = receivedData + + semaphore.signal() + } + + dataTask.resume() + + _ = semaphore.wait(timeout: .distantFuture) + + return data + } + +} diff --git a/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ui/ViewExtensions.swift b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ui/ViewExtensions.swift new file mode 100644 index 00000000..4dbb7fda --- /dev/null +++ b/SampleApplications/iOSApp/fints4k iOS/fints4k iOS/ui/ViewExtensions.swift @@ -0,0 +1,59 @@ +import SwiftUI + + + +extension Color { + static let lightText = Color(UIColor.lightText) + static let darkText = Color(UIColor.darkText) + + static let label = Color(UIColor.label) + static let secondaryLabel = Color(UIColor.secondaryLabel) + static let tertiaryLabel = Color(UIColor.tertiaryLabel) + static let quaternaryLabel = Color(UIColor.quaternaryLabel) + + static let link = Color(UIColor.link) + + static let systemBackground = Color(UIColor.systemBackground) + static let secondarySystemBackground = Color(UIColor.secondarySystemBackground) + static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground) + + static let systemGroupedBackground = Color(UIColor.systemGroupedBackground) + + // There are more.. + + static var destructive: Color { + if UIColor.responds(to: Selector(("_systemDestructiveTintColor"))) { + if let red = UIColor.perform(Selector(("_systemDestructiveTintColor")))?.takeUnretainedValue() as? UIColor { + return Color(red) + } + } + + return Color.red + } +} + + +extension View { + + func makeBackgroundTapable() -> some View { + return self.background(Color.systemBackground) + } + + + func detailForegroundColor() -> some View { + return self + .foregroundColor(Color.secondary) + } + + func detailFont() -> some View { + return self + .font(.callout) + } + + func styleAsDetail() -> some View { + return self + .detailFont() + .detailForegroundColor() + } + +} diff --git a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/FinTsClientDeprecated.kt b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/FinTsClientDeprecated.kt index 96b2a972..6a3ac044 100644 --- a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/FinTsClientDeprecated.kt +++ b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/FinTsClientDeprecated.kt @@ -11,6 +11,7 @@ import net.dankito.banking.fints.model.* import net.dankito.banking.fints.response.BankResponse import net.dankito.banking.fints.response.client.* import net.dankito.banking.fints.response.segments.* +import net.dankito.banking.fints.webclient.IWebClient import kotlin.jvm.JvmOverloads @@ -29,6 +30,11 @@ open class FinTsClientDeprecated @JvmOverloads constructor( } + constructor(callback: FinTsClientCallback) : this(callback, FinTsJobExecutor()) // Swift does not support default parameter values -> create constructor overloads + + constructor(callback: FinTsClientCallback, webClient: IWebClient) : this(callback, FinTsJobExecutor(RequestExecutor(webClient = webClient))) + + /** * Retrieves information about bank (e.g. supported HBCI versions, FinTS server address, * supported jobs, ...). diff --git a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/callback/SimpleFinTsClientCallback.kt b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/callback/SimpleFinTsClientCallback.kt index 4c325d91..db1b3d74 100644 --- a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/callback/SimpleFinTsClientCallback.kt +++ b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/callback/SimpleFinTsClientCallback.kt @@ -10,6 +10,11 @@ open class SimpleFinTsClientCallback( protected val askUserForTanMethod: ((supportedTanMethods: List, suggestedTanMethod: TanMethod?) -> TanMethod?)? = null ) : FinTsClientCallback { + constructor() : this(null) // Swift does not support default parameter values -> create constructor overloads + + constructor(enterTan: ((bank: BankData, tanChallenge: TanChallenge) -> EnterTanResult)?) : this(enterTan, null) + + override fun askUserForTanMethod(supportedTanMethods: List, suggestedTanMethod: TanMethod?, callback: (TanMethod?) -> Unit) { diff --git a/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt b/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt index f9729476..b9459c72 100644 --- a/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt +++ b/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt @@ -15,6 +15,8 @@ expect class DateFormatter constructor(pattern: String) { fun format(date: LocalDateTime): String + fun format(date: LocalDate): String + fun parseDate(dateString: String): LocalDate? fun parse(dateString: String): LocalDateTime? diff --git a/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/extensions/LocalDateExtensions.kt b/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/extensions/LocalDateExtensions.kt index 2098cbfa..a5e17015 100644 --- a/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/extensions/LocalDateExtensions.kt +++ b/multiplatform-utils/src/commonMain/kotlin/net/dankito/utils/multiplatform/extensions/LocalDateExtensions.kt @@ -55,7 +55,11 @@ val LocalDate.millisSinceEpochAtEuropeBerlin: Long get() = this.toEpochMillisecondsAt(TimeZone.europeBerlin) fun LocalDate.toEpochMillisecondsAt(timeZone: TimeZone): Long { - return this.atTime(0, 0).toInstant(timeZone).toEpochMilliseconds() + return this.toLocalDateTime().toInstant(timeZone).toEpochMilliseconds() +} + +fun LocalDate.toLocalDateTime(): LocalDateTime { + return this.atTime(0, 0) } diff --git a/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt b/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt index df97b71c..3bacf2f8 100644 --- a/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt +++ b/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt @@ -1,6 +1,8 @@ package net.dankito.utils.multiplatform import kotlinx.datetime.* +import net.dankito.utils.multiplatform.extensions.toLocalDateTime +import net.dankito.utils.multiplatform.extensions.toNSDate import platform.Foundation.* @@ -34,9 +36,12 @@ actual class DateFormatter actual constructor(val pattern: String): NSDateFormat } + actual fun format(date: LocalDate): String { + return format(date.toLocalDateTime()) + } + actual fun format(date: LocalDateTime): String { - val instant = date.toInstant(TimeZone.currentSystemDefault()) - val nsDate = instant.toNSDate() + val nsDate = date.toNSDate() return this.stringFromDate(nsDate) } diff --git a/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/extensions/NSDateExtensions.kt b/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/extensions/NSDateExtensions.kt new file mode 100644 index 00000000..bbe1516b --- /dev/null +++ b/multiplatform-utils/src/iosMain/kotlin/net/dankito/utils/multiplatform/extensions/NSDateExtensions.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2019-2020 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +package net.dankito.utils.multiplatform.extensions + +import kotlinx.datetime.* +import kotlinx.cinterop.* +import platform.Foundation.* + +/** + * Converts the [Instant] to an instance of [NSDate]. + * + * The conversion is lossy: Darwin uses millisecond precision to represent dates, and [Instant] allows for nanosecond + * resolution. + */ +fun Instant.toNSDate(): NSDate { + val secs = epochSeconds * 1.0 + nanosecondsOfSecond / 1.0e9 + if (secs < NSDate.distantPast.timeIntervalSince1970 || secs > NSDate.distantFuture.timeIntervalSince1970) { + throw IllegalArgumentException("Boundaries of NSDate exceeded") + } + return NSDate.dateWithTimeIntervalSince1970(secs) +} + +/** + * Converts the [NSDate] to the corresponding [Instant]. + * + * Even though Darwin only uses millisecond precision, it is possible that [date] uses larger resolution, storing + * microseconds or even nanoseconds. In this case, the sub-millisecond parts of [date] are rounded to the nearest + * millisecond, given that they are likely to be conversion artifacts. + */ +fun NSDate.toKotlinInstant(): Instant { + val secs = timeIntervalSince1970() + val millis = secs * 1000 + if (secs > 0) 0.5 else -0.5 + return Instant.fromEpochMilliseconds(millis.toLong()) +} + + +fun LocalDateTime.toNSDate(): NSDate { + val instant = this.toInstant(TimeZone.currentSystemDefault()) + + return instant.toNSDate() +} + +fun LocalDate.toNSDate(): NSDate { + return this.toLocalDateTime().toNSDate() +} + + +/** + * Converts the [TimeZone] to [NSTimeZone]. + * + * If the time zone is represented as a fixed number of seconds from UTC+0 (for example, if it is the result of a call + * to [TimeZone.offset]) and the offset is not given in even minutes but also includes seconds, this method throws + * [DateTimeException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets to the + * nearest minute. + */ +fun TimeZone.toNSTimeZone(): NSTimeZone = if (this is ZoneOffset) { + require (totalSeconds % 60 == 0) { + "Lossy conversion: Darwin uses minute precision for fixed-offset time zones" + } + NSTimeZone.timeZoneForSecondsFromGMT(totalSeconds.convert()) +} else { + NSTimeZone.timeZoneWithName(id) ?: NSTimeZone.timeZoneWithAbbreviation(id)!! +} + +/** + * Converts the [NSTimeZone] to the corresponding [TimeZone]. + */ +fun NSTimeZone.toKotlinTimeZone(): TimeZone = TimeZone.of(name) + +/** + * Converts the given [LocalDate] to [NSDateComponents]. + * + * Of all the fields, only the bare minimum required for uniquely identifying the date are set. + */ +fun LocalDate.toNSDateComponents(): NSDateComponents { + val components = NSDateComponents() + components.year = year.convert() + components.month = monthNumber.convert() + components.day = dayOfMonth.convert() + return components +} + +/** + * Converts the given [LocalDate] to [NSDateComponents]. + * + * Of all the fields, only the bare minimum required for uniquely identifying the date and time are set. + */ +public fun LocalDateTime.toNSDateComponents(): NSDateComponents { + val components = date.toNSDateComponents() + components.hour = hour.convert() + components.minute = minute.convert() + components.second = second.convert() + components.nanosecond = nanosecond.convert() + return components +} + diff --git a/multiplatform-utils/src/jsMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt b/multiplatform-utils/src/jsMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt index afdbad58..756250a6 100644 --- a/multiplatform-utils/src/jsMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt +++ b/multiplatform-utils/src/jsMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt @@ -2,6 +2,7 @@ package net.dankito.utils.multiplatform import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime +import net.dankito.utils.multiplatform.extensions.toLocalDateTime actual class DateFormatter actual constructor(pattern: String) { @@ -10,6 +11,10 @@ actual class DateFormatter actual constructor(pattern: String) { actual constructor(dateStyle: DateFormatStyle, timeStyle: DateFormatStyle) : this("") + actual fun format(date: LocalDate): String { + return format(date.toLocalDateTime()) + } + // TODO: implement for Logger, get current time formatted as string actual fun format(date: LocalDateTime): String { return "" // is only used in rare cases, don't implement right now diff --git a/multiplatform-utils/src/jvmMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt b/multiplatform-utils/src/jvmMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt index f31173b9..6308b066 100644 --- a/multiplatform-utils/src/jvmMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt +++ b/multiplatform-utils/src/jvmMain/kotlin/net/dankito/utils/multiplatform/DateFormatter.kt @@ -1,6 +1,7 @@ package net.dankito.utils.multiplatform import kotlinx.datetime.* +import net.dankito.utils.multiplatform.extensions.toLocalDateTime import java.text.DateFormat import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter @@ -28,6 +29,10 @@ actual class DateFormatter actual constructor(pattern: String) { : this((DateFormat.getDateTimeInstance(dateStyle.convert(), timeStyle.convert()) as? SimpleDateFormat)?.toPattern() ?: "") + actual fun format(date: LocalDate): String { + return format(date.toLocalDateTime()) + } + actual fun format(date: LocalDateTime): String { return formatter.format(date.toJavaLocalDateTime()) } diff --git a/multiplatform-utils/src/nativeMain/kotlin/net.dankito.utils.multiplatform/DateFormatter.kt b/multiplatform-utils/src/nativeMain/kotlin/net.dankito.utils.multiplatform/DateFormatter.kt index 84feed53..14a13032 100644 --- a/multiplatform-utils/src/nativeMain/kotlin/net.dankito.utils.multiplatform/DateFormatter.kt +++ b/multiplatform-utils/src/nativeMain/kotlin/net.dankito.utils.multiplatform/DateFormatter.kt @@ -1,6 +1,7 @@ package net.dankito.utils.multiplatform import kotlinx.datetime.* +import net.dankito.utils.multiplatform.extensions.toLocalDateTime actual class DateFormatter actual constructor(pattern: String) { @@ -10,6 +11,10 @@ actual class DateFormatter actual constructor(pattern: String) { actual constructor(dateStyle: DateFormatStyle, timeStyle: DateFormatStyle) : this("") + actual fun format(date: LocalDate): String { + return format(date.toLocalDateTime()) + } + // TODO: implement for Logger, get current time formatted as string actual fun format(date: LocalDateTime): String { return "" // is only used in rare cases, don't implement right now