Implemented retrieving and displaying account transactions in iOS app

This commit is contained in:
dankito 2022-02-16 02:33:19 +01:00
parent dfa31e1422
commit c3609cd33a
14 changed files with 491 additions and 5 deletions

View File

@ -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 = "<group>"; };
36266AD827BC6F74008F3B00 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
36266ADB27BC6F74008F3B00 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
36266AE327BC7776008F3B00 /* Presenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presenter.swift; sourceTree = "<group>"; };
36266AE627BC7801008F3B00 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = "<group>"; };
36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSessionWebClient.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
36266AE527BC77E1008F3B00 /* ui */ = {
isa = PBXGroup;
children = (
36266AE627BC7801008F3B00 /* ViewExtensions.swift */,
);
path = ui;
sourceTree = "<group>";
};
36266AE827BC85C9008F3B00 /* service */ = {
isa = PBXGroup;
children = (
36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */,
);
path = service;
sourceTree = "<group>";
};
/* 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;

View File

@ -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<AccountTransaction> { // 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()

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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, ...).

View File

@ -10,6 +10,11 @@ open class SimpleFinTsClientCallback(
protected val askUserForTanMethod: ((supportedTanMethods: List<TanMethod>, 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<TanMethod>,
suggestedTanMethod: TanMethod?, callback: (TanMethod?) -> Unit) {

View File

@ -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?

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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())
}

View File

@ -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