taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit cc9d1178aa3e22c16f9b01d6cd8329e77c5ba9c8
parent 4dcd94c12f2d8e3af2aeca3e6dee1a2ccccec08d
Author: Marc Stibane <marc@taler.net>
Date:   Thu, 13 Mar 2025 15:25:57 +0100

OIMView

Diffstat:
MTalerWallet.xcodeproj/project.pbxproj | 6++++++
ATalerWallet1/Views/Balances/OIMView.swift | 484+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTalerWallet1/Views/Main/TabBarModel.swift | 15+++++++++++++--
3 files changed, 503 insertions(+), 2 deletions(-)

diff --git a/TalerWallet.xcodeproj/project.pbxproj b/TalerWallet.xcodeproj/project.pbxproj @@ -151,6 +151,8 @@ 4E40E0BE29F25ABB00B85369 /* SendAmountV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E40E0BD29F25ABB00B85369 /* SendAmountV.swift */; }; 4E448AB72C4A4109007D5C92 /* BalancesPendingRowV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E448AB62C4A4109007D5C92 /* BalancesPendingRowV.swift */; }; 4E448AB82C4A4109007D5C92 /* BalancesPendingRowV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E448AB62C4A4109007D5C92 /* BalancesPendingRowV.swift */; }; + 4E4792502D660C5600749393 /* OIMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E47924F2D660C5600749393 /* OIMView.swift */; }; + 4E4792512D660C5600749393 /* OIMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E47924F2D660C5600749393 /* OIMView.swift */; }; 4E4A3F0B2CD4B6CD00CA6A90 /* NavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4A3F0A2CD4B6CD00CA6A90 /* NavLink.swift */; }; 4E4A3F0C2CD4B6CD00CA6A90 /* NavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4A3F0A2CD4B6CD00CA6A90 /* NavLink.swift */; }; 4E4F60A82C3BBF9F003BB669 /* View+Condition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4F60A72C3BBF9F003BB669 /* View+Condition.swift */; }; @@ -410,6 +412,7 @@ 4E3EAEA72AA70157009F1BE8 /* Binding+onChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Binding+onChange.swift"; sourceTree = "<group>"; }; 4E40E0BD29F25ABB00B85369 /* SendAmountV.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendAmountV.swift; sourceTree = "<group>"; }; 4E448AB62C4A4109007D5C92 /* BalancesPendingRowV.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalancesPendingRowV.swift; sourceTree = "<group>"; }; + 4E47924F2D660C5600749393 /* OIMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIMView.swift; sourceTree = "<group>"; }; 4E4A3F0A2CD4B6CD00CA6A90 /* NavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLink.swift; sourceTree = "<group>"; }; 4E4F60A72C3BBF9F003BB669 /* View+Condition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Condition.swift"; sourceTree = "<group>"; }; 4E50B34F2A1BEE8000F9F01C /* ManualWithdraw.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualWithdraw.swift; sourceTree = "<group>"; }; @@ -870,6 +873,7 @@ isa = PBXGroup; children = ( 4EB095372989CBFE0043A8A1 /* BalancesListView.swift */, + 4E47924F2D660C5600749393 /* OIMView.swift */, 4EB0953A2989CBFE0043A8A1 /* BalancesSectionView.swift */, 4E77976E2C4BEA4E005D6ECB /* BalanceCellV.swift */, 4E448AB62C4A4109007D5C92 /* BalancesPendingRowV.swift */, @@ -1295,6 +1299,7 @@ 4E3EAE2D2A990778009F1BE8 /* Model+Exchange.swift in Sources */, 4EBC0F012B7B3CD600C0CB19 /* DepositSelectV.swift in Sources */, 4E3EAE2E2A990778009F1BE8 /* QRCodeDetailView.swift in Sources */, + 4E4792502D660C5600749393 /* OIMView.swift in Sources */, 4E3EAE2F2A990778009F1BE8 /* TransactionsEmptyView.swift in Sources */, 4EEBEFB02C8982180020D340 /* View+innerSize.swift in Sources */, 4E605DAF2AADDD13002FB9A7 /* UIScreen+screenSize.swift in Sources */, @@ -1440,6 +1445,7 @@ 4E3B4BC92A42BC4800CC88B8 /* Model+Exchange.swift in Sources */, 4EBC0F022B7B3CD600C0CB19 /* DepositSelectV.swift in Sources */, 4E5A88F52A38A4FD00072618 /* QRCodeDetailView.swift in Sources */, + 4E4792512D660C5600749393 /* OIMView.swift in Sources */, 4E87C8732A31CB7F001C6406 /* TransactionsEmptyView.swift in Sources */, 4EEBEFB12C8982180020D340 /* View+innerSize.swift in Sources */, 4E605DB02AADDD13002FB9A7 /* UIScreen+screenSize.swift in Sources */, diff --git a/TalerWallet1/Views/Balances/OIMView.swift b/TalerWallet1/Views/Balances/OIMView.swift @@ -0,0 +1,484 @@ +/* + * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import taler_swift + +let DESCENDING = true + +public let OIMBACKDARK = "tara-meinczinger-G_yCplAsnB4-unsplash" +public let OIMBACKLIGHT = "andrey-haimin-VFUTPASjhB8-unsplash" + +// MARK: - +/// renders 1 banknote from a currency with size/4 +struct OIMnoteV: View { + let value: Int + let currency: OIMcurrency + + var body: some View { + if let name = currency.noteName(value) { + Image(name) + .resizable() + .scaledToFit() + .frame(width: currency.noteWidth / 4, height: currency.noteHeight / 4) + .accessibilityLabel(Text("\(value)", comment: "VoiceOver")) + } else { + EmptyView() + } + } +} + +/// renders 1 coin from a currency with size/4 +struct OIMcoinV: View { + let value: Int + let currency: OIMcurrency + + var body: some View { + if let name = currency.coinName(value), let size = currency.coinSize(value) { + Image(name) + .resizable() + .scaledToFit() + .frame(width: size / 4, height: size / 4) + .accessibilityLabel(Text("\(value)", comment: "VoiceOver")) + } else { + EmptyView() + } + } +} + +/// renders 1 denomination from a currency +struct OIMsingleV: View { + let value: Int + let currency: OIMcurrency + + var body: some View { + if value > currency.bankCoins[0] { + OIMnoteV(value: value, currency: currency) + } else { + OIMcoinV(value: value, currency: currency) + } + } +} + +// MARK: - +// renders a stack of (identical) banknotes with offset 20 +struct OIMnoteStackV: View { + let value: Int + let count: Int + let currency: OIMcurrency + + var body: some View { + let offset = 20 + let maxIndex = count - 1 + ZStack { + ForEach(0...maxIndex, id: \.self) { index in + OIMnoteV(value: value, currency: currency) + .offset(x: CGFloat(offset * index), y: CGFloat(offset * index)) + } + } + .padding(.trailing, CGFloat(offset * maxIndex)) + .padding(.bottom, CGFloat(offset * maxIndex)) + } +} + +// renders a stack of (identical) coins with offset size/16 +struct OIMcoinStackV: View { + let value: Int + let count: Int + let currency: OIMcurrency + + var body: some View { + let number = count - 1 + let _ = print("CoinStack: \(count) * \(value) \(currency.coinBase)") + if let size = currency.coinSize(value) { + let offset = size / 16 + ZStack { + ForEach(0...number, id: \.self) { index in + OIMcoinV(value: value, currency: currency) + .offset(x: offset * CGFloat(index), y: offset * CGFloat(index)) + } + } +//#if DEBUG +// .border(Color.red) +//#endif + .padding(.trailing, offset * CGFloat(number)) + .padding(.bottom, offset * CGFloat(number)) +//#if DEBUG +// .border(Color.green) +//#endif + } + } +} + +// renders a stack of 1 denomination +struct OIMstackV: View { + let value: Int + let count: Int + let currency: OIMcurrency + + var body: some View { + if value > currency.bankCoins[0] { + OIMnoteStackV(value: value, count: count, currency: currency) + } else { + OIMcoinStackV(value: value, count: count, currency: currency) + } + } +} + +// MARK: - +// renders a spread of banknote-stacks in 1 row +struct OIMnotesView1: View { + let spread: OIMdenominations + let currency: OIMcurrency + @Binding var amount: Int + + var body: some View { + let nrOfNotes = currency.bankNotes.count - 1 + HStack(alignment: .top, spacing: 10) { + ForEach(0...nrOfNotes, id: \.self) { index in + let count = spread[index] + if count > 0 { + let value = currency.bankNotes[index] + OIMnoteStackV(value: value, count: count, currency: currency) + .onTapGesture { + withAnimation(Animation.easeIn(duration: 0.25)) { + amount -= value + } + } + } + } + } + } +} + +// renders a spread of coin-stacks in 1 row +struct OIMcoinsView1: View { + let spread: OIMdenominations + let currency: OIMcurrency + @Binding var amount: Int + + var body: some View { + let nrOfCoins = currency.bankCoins.count - 1 + HStack(alignment: .top, spacing: 10) { + ForEach(0...nrOfCoins, id: \.self) { index in + let count = spread[index] + if count > 0 { + let value = currency.bankCoins[index] + OIMcoinStackV(value: value, count: count, currency: currency) + .onTapGesture { + withAnimation(Animation.easeIn(duration: 0.25)) { + amount -= value + } + } + } + } + } + } +} + +// MARK: - + +// renders all banknotes and coins in 1 horizontal scrollview +struct OIMcurrencyScroller: View { + let currency: OIMcurrency + @Binding var amount: Int + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(currency.bankNotes, id: \.self) { value in + OIMnoteV(value: value, currency: currency) + .onTapGesture { + withAnimation(Animation.easeIn(duration: 0.25)) { + amount += value + } + } + } + ForEach(currency.bankCoins, id: \.self) { value in + OIMcoinV(value: value, currency: currency) + .onTapGesture { + withAnimation(Animation.easeIn(duration: 0.25)) { + amount += value + } + } + } + } + } + .viewExtractor { view in + if let scrollView = view as? UIScrollView { + if #available(iOS 17.4, *) { + scrollView.bouncesVertically = false + } else { // Fallback on earlier versions + scrollView.bounces = false + } + } + } + .padding(.bottom, 20) + .padding(.horizontal, 8) + } +} + +// MARK: - +// renders a spread of banknote-stacks in 2 rows +struct NotesView2: View { + let spread: OIMdenominations + let currency: OIMcurrency + + func countVal(index: Int) -> (Int, Int)? { + let nrOfNotes = currency.bankNotes.count - 1 + if index < nrOfNotes { + let count = spread[index] + if count > 0 { + let value = currency.bankNotes[index] + return (value, count) + } + } + return nil + } + + + var body: some View { + HStack(alignment: .top, spacing: 10) { +// let top0: Bool +// if let cv0 = countVal(index: 0) { +// if cv0.1 > 1 { // +// NoteStackV(value: cv0.0, count: cv0.1, currency: currency) +// top0 = false +// } else { +// NoteStackV(value: cv0.0, count: cv0.1, currency: currency) +// top0 = true +// } +// } else { +// top0 = false +// } +// if let cv1 = countVal(index: 1) { +// +// } + } + } +} + +// MARK: - +struct Column: Hashable { + let topCount: Int + let topVal: Int + let botVal: Int +} + +typealias Columns = [Column] + +struct ColumnView: View { + let column: Column + let currency: OIMcurrency + + var body: some View { + VStack { + if column.topCount > 1 { + let _ = print("Stack: \(column.topCount) * \(column.topVal) \(currency.noteBase)") + OIMstackV(value: column.topVal, count: column.topCount, currency: currency) + } else { + let _ = print("Single: \(column.topVal) and \(column.botVal) \(currency.noteBase)") + OIMsingleV(value: column.topVal, currency: currency) + if column.botVal > 0 { + OIMsingleV(value: column.botVal, currency: currency) + } + } + } + } +} + +struct OIMView: View { + let balance: Balance? + let currency: OIMcurrency +// let decimal: Int // 0 for ¥,HUF; 2 for $,€,£; 3 for ﷼,₯ (arabic) + + @Environment(\.colorScheme) private var colorScheme + @AppStorage("oimTwoRows") var oimTwoRows: Bool = false + + @State private var banknotes: Columns = [] + @State private var integerCoins: Columns = [] + @State private var fractalCoins: Columns = [] + + @State private var amount: Int = 0 + + var intValue: Int { + if let balance { + let amount = balance.available + if !amount.isZero { + let value = amount.value * 100 // TODO: currency specs instead of 100 + print("intValue: \(Int(value)) \(currency.noteBase)") + return Int(value) + } + } + return 0 + } + + func buildColumns(_ spread: OIMnotesCoins, currency: OIMcurrency) { + var topVal = 0 + var notes: Columns = [] + var intCoins: Columns = [] + var fracCoins: Columns = [] + + func addTopVal(andBot: Int, to columns: inout Columns) { + if topVal > 0 { + columns.append(Column(topCount: 1, topVal: topVal, botVal: andBot)) + print("add \(topVal) and \(andBot) \(currency.noteBase)") + topVal = 0 + } else if andBot > 0 { + topVal = andBot + } + } + + for (index, count) in spread.0.enumerated() { // banknotes + if count > 0 { + let value = currency.bankNotes[index] + if count > 1 { + if DESCENDING { + addTopVal(andBot: 0, to: &notes) + } // else fill bottom later with smaller note + var cnt = count + while cnt > 5 { + notes.append(Column(topCount: 5, topVal: value, botVal: 0)) + cnt -= 5 + } + notes.append(Column(topCount: cnt, topVal: value, botVal: 0)) + print("notes: \(count) * \(value) \(currency.noteBase)") + } else { + addTopVal(andBot: value, to: &notes) + } + } + } + addTopVal(andBot: 0, to: &notes) // TODO: comment out to fill bottom later with coins + banknotes = notes + + for (index, count) in spread.1.enumerated() { + if count > 0 { + let value = currency.bankCoins[index] + if value > 99 { // TODO: use currency decimal + if count > 1 { + if DESCENDING { + addTopVal(andBot: 0, to: &intCoins) + } // else fill bottom later with smaller coin + intCoins.append(Column(topCount: count, topVal: value, botVal: 0)) + print("intCoins: \(count) * \(value) \(currency.coinBase)") + } else { + addTopVal(andBot: value, to: &intCoins) + } + } else { + if topVal > 99 { + addTopVal(andBot: 0, to: &intCoins) // finish last intCoin + } + if count > 1 { + if DESCENDING { + addTopVal(andBot: 0, to: &fracCoins) + } // else fill bottom later with smaller coin + fracCoins.append(Column(topCount: count, topVal: value, botVal: 0)) + print("fracCoins: \(count) * \(value) \(currency.coinBase)") + } else { + addTopVal(andBot: value, to: &fracCoins) + } + } + } + } + if topVal > 99 { + addTopVal(andBot: 0, to: &intCoins) // finish last intCoin + } else { + addTopVal(andBot: 0, to: &fracCoins) + } + integerCoins = intCoins + fractalCoins = fracCoins + } + + var body: some View { +#if PRINT_CHANGES || true + let _ = Self._printChanges() +#endif + let background = colorScheme == .dark ? OIMBACKDARK + : OIMBACKLIGHT + let backImage = Image(background) + .resizable() + + let vStack = VStack { + Spacer() + HStack(alignment: .top, spacing: 10) { + if oimTwoRows { + ForEach(banknotes, id: \.self) { column in + ColumnView(column: column, currency: currency) + } + ForEach(integerCoins, id: \.self) { column in + ColumnView(column: column, currency: currency) + } + HStack(alignment: .top, spacing: 10) { + ForEach(fractalCoins, id: \.self) { column in + ColumnView(column: column, currency: currency) + } + } .padding(.leading, 20) + } else { + let result = currency.notesCoins(amount) + // Text("notes: \(result.0), coins: \(result.1)") + OIMnotesView1(spread: result.0, currency: currency, amount: $amount) + .padding(.trailing, 20) + OIMcoinsView1(spread: result.1, currency: currency, amount: $amount) + } + } + .task { + amount = intValue +// amount = 14983 + } + + Spacer() + OIMcurrencyScroller(currency: currency, amount: $amount) + .onChange(of: amount) { newValue in + print("new Amount: \(newValue)") + let result = currency.notesCoins(newValue) + buildColumns(result, currency: currency) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(backImage) + .ignoresSafeArea(edges: .all) + if #available(iOS 16.4, *) { + vStack + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + } else { // Fallback on earlier versions + vStack + } + + } +} + +/// https://stackoverflow.com/questions/57132417/swiftui-scrollview-is-not-able-to-disable-vertical-bounce +extension View { + @ViewBuilder + func viewExtractor(result: @escaping (UIView) -> ()) -> some View { + self + .background(ViewExtractorHelper(result: result)) + .compositingGroup() + } +} + +fileprivate struct ViewExtractorHelper: UIViewRepresentable { + var result: (UIView) -> () + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + DispatchQueue.main.async { + if let superView = view.superview?.superview?.subviews.last?.subviews.first { + result(superView) + } + } + return view + } + func updateUIView(_ uiView: UIView, context: Context) { + + } +} + +//#Preview { +// OIMView() +//} diff --git a/TalerWallet1/Views/Main/TabBarModel.swift b/TalerWallet1/Views/Main/TabBarModel.swift @@ -12,13 +12,24 @@ import SwiftUI class TabBarModel: ObservableObject { @Published var tabBarHidden = 0 + @Published var oimActive = 0 { + didSet { + if oimActive == 0 { + if actionSelected == nil && tosView == nil { + tabBarHidden = 0 + } + } else { + tabBarHidden += 1 + } + } + } @Published var tosView: Int? = nil { didSet { if tosView != nil { tabBarHidden += 1 } else if actionSelected == nil { - tabBarHidden = 0 + tabBarHidden = oimActive } } } @@ -28,7 +39,7 @@ class TabBarModel: ObservableObject { if actionSelected != nil { tabBarHidden += 1 } else if tosView == nil { - tabBarHidden = 0 + tabBarHidden = oimActive } } }